Compare commits

..

9 Commits

Author SHA1 Message Date
Will Miao 8022d12f03 chore(release): bump version to v1.1.8 2026-07-20 20:42:04 +08:00
Will Miao 3939f7f91b chore: Update lora manager basic example workflow 2026-07-20 20:20:35 +08:00
Will Miao aebf2e37dd fix(filter): apply preset on full tile click and suppress i18n double-translate warnings
- Move preset apply handler from span.preset-name to div.filter-preset so
  clicking anywhere on the tile triggers the preset, not just the label text.
- Add whitespace heuristic in showToast() to skip translate() for plain
  messages that are already translated at the call site. This prevents
  i18next from logging 'Translation key not found' for pre-translated
  strings like 'Preset "name" applied'.
2026-07-20 17:57:14 +08:00
Will Miao f53f859a71 feat(filter): add debounced tag search with backend search-tags endpoint 2026-07-20 17:37:47 +08:00
Will Miao d916375abe fix(checkpoint): populate hash index from pre-computed metadata to prevent repeated hash re-calculation (#1002) 2026-07-20 12:24:54 +08:00
Will Miao 57983df4bd fix(recipe): resolve recipe metadata update bugs in cache sort, allowed fields, and bulk API routing
- Use safe .get() in RecipeCache._resort_locked instead of itemgetter to prevent KeyError when recipe missing created_date; align sort key with _sort_cache_sync (prefer modified, fallback created_date, fallback 0)
- Add base_model to allowed_fields in persistence_service.update_recipe() so the field passes validation
- Route bulk base model updates through updateRecipeMetadata() on recipes page instead of generic saveModelMetadata(), matching existing isRecipesPage pattern used in setBulkFavorites and saveBulkTags
2026-07-20 11:20:06 +08:00
Will Miao c68d7559a0 fix(widget): correct reorder drop indicator position when container is scrolled
The drop indicator top position was calculated using only
getBoundingClientRect() offsets (post-CSS-transform viewport space)
without accounting for container.scrollTop (pre-transform layout space).
This caused the indicator to drift upward as the user scrolled down,
eventually disappearing entirely.

Fixed by adding container.scrollTop to the position calculation and
only dividing the GBCR visual-diff portion by scale, since scrollTop
is already in pre-transform coordinate space.
2026-07-19 22:40:07 +08:00
Will Miao 9a8f5bf2d6 fix(ui): reposition download settings before AI provider section 2026-07-19 17:57:02 +08:00
Will Miao a8d742b031 feat(metadata): add CivArchive API toggle and provider fallback order settings
- Add enable_civarchive_api toggle (default on) to allow disabling
  CivArchive to avoid its rate-limit windows entirely
- Add metadata_provider_order dropdown with two presets:
  CivitAI → CivArchive → Archive DB (default) and
  CivitAI → Archive DB → CivArchive
- Wire both settings through backend (metadata_service, settings_manager,
  misc_handlers) and frontend (SettingsManager, state, settings modal)
- Reorder Metadata section in settings modal: toggles → status/management
  → fallback order, for natural top-down workflow
- Make update_metadata_providers() log the effective provider chain
  using actually-registered providers rather than settings assumptions
- Add 5 test cases covering all provider-combination paths
- Complete i18n translations for 6 new keys across all 9 non-English locales
2026-07-19 17:51:50 +08:00
39 changed files with 20797 additions and 19993 deletions
+138 -125
View File
@@ -10,13 +10,17 @@
"Brennok", "Brennok",
"2018cfh", "2018cfh",
"Insomnia Art Designs", "Insomnia Art Designs",
"Rob Williams",
"Arlecchino Shion", "Arlecchino Shion",
"Charles Blakemore", "Charles Blakemore",
"Rob Williams",
"$MetaSamsara", "$MetaSamsara",
"W+K+White", "W+K+White",
"stone9k", "stone9k",
"Rosenthal",
"Francisco Tatis",
"Mozzel",
"Gingko Biloba", "Gingko Biloba",
"Birdy",
"Kiba", "Kiba",
"onesecondinosaur", "onesecondinosaur",
"Christian Byrne", "Christian Byrne",
@@ -27,22 +31,25 @@
"Phil", "Phil",
"Carl G.", "Carl G.",
"Dsperado", "Dsperado",
"Rosenthal",
"ClockDaemon", "ClockDaemon",
"Francisco Tatis",
"Tobi_Swagg", "Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"VantAI", "VantAI",
"レプサイ",
"Michael Wong", "Michael Wong",
"Illrigger", "Illrigger",
"Tom Corrigan", "Tom Corrigan",
"JackieWang", "JackieWang",
"FreelancerZ", "FreelancerZ",
"fnkylove", "fnkylove",
"Lilleman",
"Robert Stacey", "Robert Stacey",
"PM",
"Edgar Tejeda", "Edgar Tejeda",
"Liam MacDougal", "Liam MacDougal",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
@@ -50,9 +57,7 @@
"Dogwalkerbr", "Dogwalkerbr",
"Skalabananen", "Skalabananen",
"Marc Whiffen", "Marc Whiffen",
"Birdy",
"itismyelement", "itismyelement",
"Mozzel",
"quarz", "quarz",
"Reno Lam", "Reno Lam",
"jean jahren", "jean jahren",
@@ -65,40 +70,46 @@
"Jonathan Ross", "Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito", "Nolife_M",
"Melville Parrish",
"daniel dove", "daniel dove",
"Lustre",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"JW Sin", "JW Sin",
"Alex", "Alex",
"SG", "bh",
"carozzz", "carozzz",
"Marlon Daniels",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack", "Aaron Bleuer",
"LacesOut!",
"Adam Shaw", "Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"RedrockVP", "RedrockVP",
"James Todd", "James Todd",
"Wicked Choices by ASLPro3D", "Wicked Choices by ASLPro3D",
"FinalyFree",
"Weasyl",
"Steven Pfeiffer", "Steven Pfeiffer",
"レプサイ",
"Timmy", "Timmy",
"Johnny", "Johnny",
"Cory Paza",
"Tak", "Tak",
"Lisster", "Lisster",
"runte3221", "runte3221",
"Big Red", "Big Red",
"whudunit", "whudunit",
"Luc Job",
"dl0901dm", "dl0901dm",
"corde",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Bishoujoker", "Bishoujoker",
"Echo", "Echo",
"Lilleman",
"PM",
"Todd Keck", "Todd Keck",
"Briton Heilbrun", "Briton Heilbrun",
"wildnut", "wildnut",
@@ -108,53 +119,51 @@
"BadassArabianMofo", "BadassArabianMofo",
"Pascal Dahle", "Pascal Dahle",
"Greg", "Greg",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.",
"MiraiKuriyamaSy", "MiraiKuriyamaSy",
"otaku fra", "otaku fra",
"lmsupporter", "lmsupporter",
"andrew.tappan", "andrew.tappan",
"Takkan", "Takkan",
"N/A",
"Greenmoustache",
"zounic", "zounic",
"wfpearl", "wfpearl",
"ElitaSSJ4", "ElitaSSJ4",
"Matt+J", "Matt+J",
"Jack B Nimble", "Jack B Nimble",
"Melville Parrish",
"Lustre",
"bh",
"Jwk0205", "Jwk0205",
"Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Olive",
"LacesOut!",
"greebles", "greebles",
"Some Guy Named Barry", "Some Guy Named Barry",
"Resist's Creations - Spicy Edition 🔥", "Resist's Creations - Spicy Edition 🔥",
"M Postkasse", "M Postkasse",
"Wolffen", "Wolffen",
"wamekukyouzin",
"drum matthieu",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "DogmaR34",
"Matt Wenzel", "Matt Wenzel",
"Weasyl",
"Lex Song", "Lex Song",
"Cory Paza", "Christopher Michel",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp",
"Jimmy Ledbetter", "Jimmy Ledbetter",
"Luc Job", "LeoZero",
"Philip Hempel", "Philip Hempel",
"corde",
"nwalker94", "nwalker94",
"dan", "dan",
"aai", "aai",
"Tori", "Tori",
"Mouthlessman",
"Ran C", "Ran C",
"ViperC", "ViperC",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.",
"Adam Taylor", "Adam Taylor",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"N/A",
"The Spawn", "The Spawn",
"graysock", "graysock",
"Pozadine1", "Pozadine1",
@@ -162,7 +171,8 @@
"AIGooner", "AIGooner",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"Greenmoustache", "DiffDuck",
"elu3199",
"fancypants", "fancypants",
"John+Edwards", "John+Edwards",
"Joboshy", "Joboshy",
@@ -172,42 +182,39 @@
"contrite831", "contrite831",
"Dan", "Dan",
"Bro Xie", "Bro Xie",
"yer fey",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Roslynd",
"jinxedx", "jinxedx",
"Neco28",
"David Ortega",
"AELOX", "AELOX",
"Gooohokrbe", "Gooohokrbe",
"Dankin-Pics", "Dankin-Pics",
"Nicfit23", "Nicfit23",
"Cristian Vazquez", "Cristian Vazquez",
"wamekukyouzin",
"OldBones", "OldBones",
"drum matthieu",
"Dogmaster",
"Frank Nitty", "Frank Nitty",
"Magic Noob", "Magic Noob",
"Christopher Michel",
"Zach Gonser", "Zach Gonser",
"Serge Bekenkamp",
"DougPeterson", "DougPeterson",
"LeoZero",
"Antonio Pontes", "Antonio Pontes",
"nahinahi9", "Bruce",
"kushiroK9",
"Kevin John Duck", "Kevin John Duck",
"Dustin Chen", "Dustin Chen",
"Kevin Christopher",
"Blackfish95", "Blackfish95",
"Mouthlessman",
"Paul Kroll", "Paul Kroll",
"Penfore", "Penfore",
"Bas Imagineer", "Bas Imagineer",
"John Statham",
"Gordon Cole", "Gordon Cole",
"AbstractAss", "AbstractAss",
"Dušan Ryban", "Dušan Ryban",
"decoy", "decoy",
"DiffDuck",
"elu3199",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
"Ubivis", "Ubivis",
@@ -222,34 +229,34 @@
"MJG", "MJG",
"David LaVallee", "David LaVallee",
"linnfrey", "linnfrey",
"ae",
"Tr4shP4nda",
"Jackthemind", "Jackthemind",
"griffin+dahlberg", "griffin+dahlberg",
"jeaness", "jeaness",
"takyamtom", "takyamtom",
"Brian M",
"Josef Lanzl", "Josef Lanzl",
"Nerezza", "Nerezza",
"yer fey", "sanborondon",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"aezin", "aezin",
"jcay015", "jcay015",
"Erik Lopez", "Erik Lopez",
"Roslynd",
"Mateo Curić", "Mateo Curić",
"Geolog", "Geolog",
"Neco28",
"Cosmosis", "Cosmosis",
"Eris3D", "Eris3D",
"David Ortega", "m",
"FloPro4Sho", "FloPro4Sho",
"Jamie Ogletree",
"a _", "a _",
"Jeff", "Jeff",
"Bruce",
"Steven Owens", "Steven Owens",
"James Coleman", "James Coleman",
"Kevin Christopher",
"Chad Idk", "Chad Idk",
"dd", "dd",
"John Statham", "Sam",
"sjon kreutz", "sjon kreutz",
"yuxz69", "yuxz69",
"LarsesFPC", "LarsesFPC",
@@ -257,8 +264,6 @@
"esthe", "esthe",
"AlexDuKaNa", "AlexDuKaNa",
"地獄の禄", "地獄の禄",
"ae",
"Tr4shP4nda",
"Gamalonia", "Gamalonia",
"capn", "capn",
"Joseph", "Joseph",
@@ -272,12 +277,16 @@
"Hailshem", "Hailshem",
"Naomi Hale Danchi", "Naomi Hale Danchi",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Wind",
"IamAyam", "IamAyam",
"Andrew", "Andrew",
"Brian M",
"Robert Wegemund", "Robert Wegemund",
"sanborondon", "Littlehuggy",
"confiscated Zyra", "Andrew Marshall",
"Brian Buie",
"Taylor Funk", "Taylor Funk",
"Thought2Form", "Thought2Form",
"Gerald Welly", "Gerald Welly",
@@ -285,15 +294,19 @@
"Sadlip", "Sadlip",
"Tee Gee", "Tee Gee",
"tarek helmi", "tarek helmi",
"Joey Callahan",
"Max Marklund", "Max Marklund",
"m", "Mike Simone",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Pronredn", "Pronredn",
"Mikko Hemilä", "Mikko Hemilä",
"Jamie Ogletree", "Jacob McDaniel",
"X",
"Temikus", "Temikus",
"Artokun",
"Michael Taylor", "Michael Taylor",
"Derek Baker",
"lh qwe", "lh qwe",
"Martial", "Martial",
"conner", "conner",
@@ -305,24 +318,21 @@
"Decx _", "Decx _",
"Yuji Kaneko", "Yuji Kaneko",
"Rops Alot", "Rops Alot",
"Sam",
"Ace Ventura", "Ace Ventura",
"四糸凜音", "四糸凜音",
"Xeeosat", "Xeeosat",
"Douglas Gaspar", "Douglas Gaspar",
"Saya",
"George", "George",
"dw", "dw",
"FrxzenSnxw",
"WRL_SPR", "WRL_SPR",
"momokai", "momokai",
"몽타주", "몽타주",
"kudari", "kudari",
"ken", "ken",
"Crocket", "Crocket",
"Joshua Porrata",
"keemun", "keemun",
"SuBu",
"RedPIXel",
"Wind",
"Nexus", "Nexus",
"Ramneek“Guy”Ashok", "Ramneek“Guy”Ashok",
"squid_actually", "squid_actually",
@@ -337,37 +347,36 @@
"KitKatM", "KitKatM",
"socrasteeze", "socrasteeze",
"OrganicArtifact", "OrganicArtifact",
"ResidentDeviant",
"MudkipMedkitz", "MudkipMedkitz",
"deanbrian", "deanbrian",
"Alex Wortman", "Alex Wortman",
"Cody", "Cody",
"emadsultan", "emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"gzmzmvp", "gzmzmvp",
"Richard", "Richard",
"奚明 刘", "奚明 刘",
"Littlehuggy",
"Aberr", "Aberr",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"준희 김", "준희 김",
"Brian Buie",
"Eric Whitney", "Eric Whitney",
"Joey Callahan",
"Ivan Tadic", "Ivan Tadic",
"Tomohiro Baba", "Tomohiro Baba",
"Mike Simone",
"Noora", "Noora",
"John J Linehan", "John J Linehan",
"Mattssn",
"Elliot E", "Elliot E",
"Morgandel", "Morgandel",
"Theerat Jiramate", "Theerat Jiramate",
"Noah", "Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy", "Sloan Steddy",
"Artokun",
"hexxish", "hexxish",
"Derek Baker",
"Steam Steam", "Steam Steam",
"NICHOLAS BAXLEY", "NICHOLAS BAXLEY",
"CryptoTraderJK", "CryptoTraderJK",
@@ -378,23 +387,14 @@
"Fotek Design", "Fotek Design",
"Nihongasuki", "Nihongasuki",
"MadSpin", "MadSpin",
"FrxzenSnxw",
"inbijiburu", "inbijiburu",
"Nick “Loadstone” D", "Nick “Loadstone” D",
"starbugx", "starbugx",
"dc7431", "dc7431",
"ResidentDeviant",
"Ginnie", "Ginnie",
"Raku", "Raku",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Vir", "Vir",
"Skyfire83", "Skyfire83",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"moonpetal", "moonpetal",
"g9p0o", "g9p0o",
"Pkrsky", "Pkrsky",
@@ -403,6 +403,8 @@
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast", "DarkRoast",
"Nasty+Hobbit", "Nasty+Hobbit",
"letzte", "letzte",
@@ -414,12 +416,15 @@
"David Schenck", "David Schenck",
"Wolfe7D1", "Wolfe7D1",
"Draven T", "Draven T",
"Time Valentine",
"elleshar666", "elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe", "ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч", "Михал Михалыч",
"Matt",
"Aquatic Coffee", "Aquatic Coffee",
"Kauffy", "Kauffy",
"ethanfel", "ethanfel",
"SPJ",
"Focuschannel", "Focuschannel",
"Edward Kennedy", "Edward Kennedy",
"Nick Kage", "Nick Kage",
@@ -432,12 +437,13 @@
"notedfakes", "notedfakes",
"Michael Scott", "Michael Scott",
"Pat Hen", "Pat Hen",
"Saya", "Solixer",
"Jordan Shaw", "Jordan Shaw",
"Wes Sims", "Wes Sims",
"Donor4115", "Donor4115",
"g unit", "g unit",
"Jimmy Borup", "Jimmy Borup",
"Manu Thetug",
"Filippo Ferrari", "Filippo Ferrari",
"JC", "JC",
"Prompt Pirate", "Prompt Pirate",
@@ -451,6 +457,11 @@
"SomeDude", "SomeDude",
"nanana", "nanana",
"raf8osz", "raf8osz",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex",
"Karru", "Karru",
"ChaChanoKo", "ChaChanoKo",
"redcarrot", "redcarrot",
@@ -467,36 +478,34 @@
"Doug+Rintoul", "Doug+Rintoul",
"Noor", "Noor",
"Yorunai", "Yorunai",
"quantenmecha",
"Jason+Nash",
"cocona", "cocona",
"blikkies", "blikkies",
"JBsuede", "JBsuede",
"Time Valentine",
"Shock Shockor", "Shock Shockor",
"りん あめ", "りん あめ",
"Matt",
"Goldwaters", "Goldwaters",
"Zude", "Zude",
"Joaquin Hierrezuelo",
"Frogmilk", "Frogmilk",
"SPJ", "Sean voets",
"Kyler", "Kyler",
"Kor", "Kor",
"Joseph Hanson",
"John Rednoulf",
"Bryan Rutkowski", "Bryan Rutkowski",
"Justin Blaylock", "Justin Blaylock",
"aRtFuL_DodGeR", "aRtFuL_DodGeR",
"Steven",
"TenaciousD", "TenaciousD",
"Dmitry Ryzhov", "Dmitry Ryzhov",
"Edward Ten Eyck", "Edward Ten Eyck",
"Billy Gladky", "Billy Gladky",
"Probis", "Probis",
"Solixer",
"Pete Pain", "Pete Pain",
"ItsGeneralButtNaked", "ItsGeneralButtNaked",
"RHopkirk", "RHopkirk",
"jinksta187", "jinksta187",
"robin.kok.", "robin.kok.",
"Manu Thetug",
"Maxim", "Maxim",
"Karlanx", "Karlanx",
"Lyavph", "Lyavph",
@@ -504,6 +513,7 @@
"Youguang", "Youguang",
"andrewzpong", "andrewzpong",
"BossGame", "BossGame",
"Marcus thronico",
"lrdchs", "lrdchs",
"Tree Tagger", "Tree Tagger",
"Inversity", "Inversity",
@@ -511,6 +521,15 @@
"Kevinj", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"POPPIN", "POPPIN",
"PoorStudent",
"Alex+Zaw",
"Supporter",
"ExLightSaber",
"Mobius2020",
"YaboiRay",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo", "2turbo",
"Dmitry+Viznesenskiy", "Dmitry+Viznesenskiy",
"tanjin90", "tanjin90",
@@ -528,11 +547,6 @@
"Inkognito", "Inkognito",
"G", "G",
"Tan+Huynh", "Tan+Huynh",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex",
"BillyBoy84", "BillyBoy84",
"Buecyb99", "Buecyb99",
"Welkor", "Welkor",
@@ -545,28 +559,27 @@
"G", "G",
"Ronan Delevacq", "Ronan Delevacq",
"Christian Schäfer", "Christian Schäfer",
"Leslie Andrew Ridings",
"Dave Abraham", "Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel", "Locrospiel",
"Sean voets",
"Jarrid Lee", "Jarrid Lee",
"Poophead27 Blyat", "Poophead27 Blyat",
"Joseph Hanson",
"John Rednoulf",
"Kyron Mahan", "Kyron Mahan",
"Mythspire", "Mythspire",
"Boba Smith", "Boba Smith",
"TBitz33", "TBitz33",
"Anonym dkjglfleeoeldldldlkf", "Anonym dkjglfleeoeldldldlkf",
"MR.Bear", "MR.Bear",
"matt",
"somethingtosay8", "somethingtosay8",
"Ezokewn", "Ezokewn",
"Terminuz",
"ivistorm", "ivistorm",
"SendingRavens", "SendingRavens",
"Sauv", "Sauv",
"Steven",
"JackJohnnyJim", "JackJohnnyJim",
"Khánh Đặng", "Khánh Đặng",
"Borte",
"Michael Docherty", "Michael Docherty",
"Ted Cart", "Ted Cart",
"Sage Himeros", "Sage Himeros",
@@ -574,6 +587,7 @@
"Paul Hartsuyker", "Paul Hartsuyker",
"elitassj", "elitassj",
"Tigon", "Tigon",
"SkibidiRizzler",
"Tania Nayelli Fernandez", "Tania Nayelli Fernandez",
"Draconach", "Draconach",
"Jacob Winter", "Jacob Winter",
@@ -581,6 +595,8 @@
"Andrew Wilkinson", "Andrew Wilkinson",
"David", "David",
"Meilo", "Meilo",
"Nacho Ferrando",
"Marcos Tortosa Carmona",
"Dkom22", "Dkom22",
"shinonomeiro", "shinonomeiro",
"Snille", "Snille",
@@ -589,7 +605,6 @@
"xybrightsummer", "xybrightsummer",
"jreedatchison", "jreedatchison",
"PhilW", "PhilW",
"Marcus thronico",
"Janik", "Janik",
"Cruel", "Cruel",
"MRBlack", "MRBlack",
@@ -601,6 +616,13 @@
"Scott", "Scott",
"Muratoraccio", "Muratoraccio",
"D", "D",
"Somebody",
"Celestial+Kitten",
"TequiTequi",
"Homero+Banda",
"bakeliteboy",
"Nick",
"てぃんてぃんひーろー",
"Gold_miner_ego", "Gold_miner_ego",
"IshouI;_;", "IshouI;_;",
"Monix", "Monix",
@@ -620,17 +642,8 @@
"you+halo9", "you+halo9",
"cloudghost", "cloudghost",
"Yongkwan+Lee", "Yongkwan+Lee",
"PoorStudent",
"lucites", "lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname", "nickname",
"Sildoren",
"Darv",
"Seon+Song",
"Somebody",
"Balut+Omelette", "Balut+Omelette",
"eriick", "eriick",
"Lev+Lanevskiy", "Lev+Lanevskiy",
@@ -651,37 +664,35 @@
"Vinarus", "Vinarus",
"Josh Snyder", "Josh Snyder",
"ja s", "ja s",
"Leslie Andrew Ridings",
"Doug Mason", "Doug Mason",
"scoreswazey", "scoreswazey",
"Oliverfish",
"Owen Gwosdz", "Owen Gwosdz",
"Room Light", "Room Light",
"Patryk Serious", "Patryk Serious",
"AZ Party Oasis", "AZ Party Oasis",
"Devil Lude", "Gentle Sartori",
"Snorklebort", "Snorklebort",
"David Murcko", "David Murcko",
"vinter",
"TheFusion", "TheFusion",
"Jack Dole", "Jack Dole",
"matt",
"3zS4QNQ4", "3zS4QNQ4",
"Terminuz",
"max blo", "max blo",
"Matt M.", "Matt M.",
"Ivan Imes", "Ivan Imes",
"J M", "J M",
"Slacks",
"Bouya shaka", "Bouya shaka",
"Jack Lawfield", "Jack Lawfield",
"Borte",
"Maso", "Maso",
"Homero Banda",
"yyuvuvu", "yyuvuvu",
"Eric Ketchum",
"Nomki", "Nomki",
"Kevin Wallace", "Kevin Wallace",
"ChicRic", "ChicRic",
"BastardSama", "BastardSama",
"mercur", "mercur",
"SkibidiRizzler",
"Never_M", "Never_M",
"Kalle Björk", "Kalle Björk",
"Yavizu3d", "Yavizu3d",
@@ -689,9 +700,7 @@
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Nacho Ferrando",
"Вячеслав Маринин", "Вячеслав Маринин",
"Marcos Tortosa Carmona",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -734,6 +743,15 @@
"SelfishMedic", "SelfishMedic",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Brandon+G",
"fazefour33",
"plonk",
"Kotetsu",
"o",
"Tony+V",
"Anvil+G",
"draganjankovic1975dj528",
"MrSEIGE88",
"yarsev", "yarsev",
"M+Alsulaiti", "M+Alsulaiti",
"Mark+Staaf", "Mark+Staaf",
@@ -751,11 +769,6 @@
"miduzza", "miduzza",
"KB", "KB",
"shw", "shw",
"Celestial+Kitten",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Jim", "Jim",
"JoL", "JoL",
"YoruHime", "YoruHime",
@@ -781,14 +794,14 @@
"han b", "han b",
"Nico", "Nico",
"Maximilian Krischan", "Maximilian Krischan",
"Banana Joe", "socialcat",
"proto merp", "proto merp",
"_ G3n", "_ G3n",
"Brandon Thomas", "Brandon Thomas",
"Donovan Jenkins", "Donovan Jenkins",
"Hans Meier", "Hans Meier",
"Dustin Hendel", "Dustin Hendel",
"sicarius", "jboul",
"Michael Eid", "Michael Eid",
"Liberation", "Liberation",
"Bob barker", "Bob barker",
@@ -805,7 +818,6 @@
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Oliverfish",
"yfx507", "yfx507",
"uruksayshi", "uruksayshi",
"Jairus Knudsen", "Jairus Knudsen",
@@ -814,24 +826,25 @@
"nk8", "nk8",
"lylepaul", "lylepaul",
"Middo", "Middo",
"Gary Chaboya",
"Forbidden Atelier", "Forbidden Atelier",
"Thomas Sankowski", "Thomas Sankowski",
"DrB", "DrB",
"Nimhloth", "Nimhloth",
"Adictedtohumping", "Adictedtohumping",
"Moneymaker412K", "Moneymaker412K",
"vinter", "Tsani Prodanov",
"Towelie", "Towelie",
"Jean-françois SEMA", "Jean-françois SEMA",
"Myrthrac",
"Taylor Dominy",
"Andrew Ly", "Andrew Ly",
"Slacks",
"Glenn Hoetker", "Glenn Hoetker",
"john Greene", "john Greene",
"Faburizu", "Faburizu",
"jimyjomson", "jimyjomson",
"JaeHyun Jang", "JaeHyun Jang",
"Michael Hicks", "Michael Hicks",
"Homero Banda",
"Chase Kwon", "Chase Kwon",
"Bob Ling", "Bob Ling",
"Inyoshu", "Inyoshu",
@@ -858,5 +871,5 @@
"Somebody", "Somebody",
"CK" "CK"
], ],
"totalCount": 855 "totalCount": 868
} }
File diff suppressed because one or more lines are too long
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Preset name...", "presetNamePlaceholder": "Preset name...",
"baseModel": "Base Model", "baseModel": "Base Model",
"baseModelSearchPlaceholder": "Search base models...", "baseModelSearchPlaceholder": "Search base models...",
"modelTags": "Tags (Top 20)", "modelTags": "Tags",
"modelTypes": "Model Types", "modelTypes": "Model Types",
"license": "License", "license": "License",
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Allow selling generated images", "allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator", "noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags", "noTags": "No tags",
"tagSearchPlaceholder": "Search tags...",
"noTagMatches": "No tags match the current search.",
"autoTags": "Auto Tags", "autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.", "noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters", "clearAll": "Clear All Filters",
@@ -640,7 +642,13 @@
"preparing": "Preparing download...", "preparing": "Preparing download...",
"connecting": "Connecting to download server...", "connecting": "Connecting to download server...",
"completed": "Completed", "completed": "Completed",
"downloadComplete": "Download completed successfully" "downloadComplete": "Download completed successfully",
"enableCivarchiveApi": "Enable CivArchive API as metadata provider",
"enableCivarchiveApiHelp": "When on, CivArchive API is used as a fallback source for model metadata (e.g. for models deleted from CivitAI). Turn off to avoid CivArchive rate limits entirely.",
"providerOrder": "Metadata provider fallback order",
"providerOrderHelp": "CivitAI API is always tried first. Choose the order of the remaining providers when looking up metadata.",
"providerOrderCivitaiArchiveSqlite": "CivitAI → CivArchive → Archive DB",
"providerOrderCivitaiSqliteArchive": "CivitAI → Archive DB → CivArchive"
}, },
"proxySettings": { "proxySettings": {
"enableProxy": "Enable App-level Proxy", "enableProxy": "Enable App-level Proxy",
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+2202 -2194
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -1570,7 +1570,11 @@ class SettingsHandler:
else: else:
self._settings.set(key, value) self._settings.set(key, value)
if key == "enable_metadata_archive_db": if key in (
"enable_metadata_archive_db",
"enable_civarchive_api",
"metadata_provider_order",
):
await self._metadata_provider_updater() await self._metadata_provider_updater()
if key in self._PROXY_KEYS: if key in self._PROXY_KEYS:
+19
View File
@@ -973,6 +973,8 @@ class ModelQueryHandler:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
if limit < 0: if limit < 0:
limit = 20 limit = 20
elif limit > 200:
limit = 20
top_tags = await self._service.get_top_tags(limit) top_tags = await self._service.get_top_tags(limit)
return web.json_response({"success": True, "tags": top_tags}) return web.json_response({"success": True, "tags": top_tags})
except Exception as exc: except Exception as exc:
@@ -981,6 +983,22 @@ class ModelQueryHandler:
{"success": False, "error": "Internal server error"}, status=500 {"success": False, "error": "Internal server error"}, status=500
) )
async def search_tags(self, request: web.Request) -> web.Response:
try:
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tags = await self._service.search_tags(query, limit)
return web.json_response({"success": True, "tags": tags})
except Exception as exc:
self._logger.error("Error searching tags: %s", exc, exc_info=True)
return web.json_response(
{"success": False, "error": "Internal server error"}, status=500
)
async def get_base_models(self, request: web.Request) -> web.Response: async def get_base_models(self, request: web.Request) -> web.Response:
try: try:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
@@ -2947,6 +2965,7 @@ class ModelHandlerSet:
"bulk_delete_models": self.management.bulk_delete_models, "bulk_delete_models": self.management.bulk_delete_models,
"verify_duplicates": self.management.verify_duplicates, "verify_duplicates": self.management.verify_duplicates,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_model_types": self.query.get_model_types, "get_model_types": self.query.get_model_types,
"scan_models": self.query.scan_models, "scan_models": self.query.scan_models,
+55 -6
View File
@@ -72,6 +72,7 @@ class RecipeHandlerSet:
"save_recipe": self.management.save_recipe, "save_recipe": self.management.save_recipe,
"delete_recipe": self.management.delete_recipe, "delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_roots": self.query.get_roots, "get_roots": self.query.get_roots,
"get_folders": self.query.get_folders, "get_folders": self.query.get_folders,
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
cache = await recipe_scanner.get_cached_data() if limit < 0:
limit = 20
tag_counts: Dict[str, int] = {} elif limit > 200:
for recipe in getattr(cache, "raw_data", []): limit = 20
for tag in recipe.get("tags", []) or []: tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
tag_counts[tag] = tag_counts.get(tag, 0) + 1
sorted_tags = [ sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items() {"tag": tag, "count": count} for tag, count in tag_counts.items()
@@ -333,6 +333,55 @@ class RecipeQueryHandler:
self._logger.error("Error retrieving top tags: %s", exc, exc_info=True) self._logger.error("Error retrieving top tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def search_tags(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
query = request.query.get("q", "")
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
normalized_query = (query or "").strip().lower()
if not normalized_query:
sorted_tags = [
{"tag": tag, "count": count} for tag, count in tag_counts.items()
]
sorted_tags.sort(key=lambda entry: entry["count"], reverse=True)
return web.json_response(
{"success": True, "tags": sorted_tags[: (limit if limit > 0 else 20)]}
)
matched = [
{"tag": tag, "count": count}
for tag, count in tag_counts.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda entry: entry["count"], reverse=True)
if limit == 0:
result = matched
else:
result = matched[:limit]
return web.json_response({"success": True, "tags": result})
except Exception as exc:
self._logger.error("Error searching recipe tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def _get_recipe_tag_counts(self, recipe_scanner) -> Dict[str, int]:
"""Compute tag->count mapping from cached recipe data."""
cache = await recipe_scanner.get_cached_data()
tag_counts: Dict[str, int] = {}
for recipe in getattr(cache, "raw_data", []):
for tag in recipe.get("tags", []) or []:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
return tag_counts
async def get_base_models(self, request: web.Request) -> web.Response: async def get_base_models(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
+1
View File
@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress" "GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
), ),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"), RouteDefinition("GET", "/api/lm/{prefix}/model-types", "get_model_types"),
RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"), RouteDefinition("GET", "/api/lm/{prefix}/scan", "scan_models"),
+1
View File
@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"), RouteDefinition("POST", "/api/lm/recipes/save", "save_recipe"),
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/recipes/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"), RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"), RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
+6
View File
@@ -804,6 +804,12 @@ class BaseModelService(ABC):
"""Get top tags sorted by frequency""" """Get top tags sorted by frequency"""
return await self.scanner.get_top_tags(limit) return await self.scanner.get_top_tags(limit)
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict]:
"""Search tags by substring, sorted by frequency"""
return await self.scanner.search_tags(query, limit)
async def get_base_models(self, limit: int = 20) -> List[Dict]: async def get_base_models(self, limit: int = 20) -> List[Dict]:
"""Get base models sorted by frequency""" """Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit) return await self.scanner.get_base_models(limit)
+25
View File
@@ -114,6 +114,13 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed" and metadata.hash_status == "completed"
and metadata.sha256 and metadata.sha256
): ):
# Ensure the in-memory hash index is populated even when
# the hash was already computed and persisted to the metadata
# file. Without this, usage tracking (and any other caller
# that queries get_hash_by_filename first) will miss on every
# lookup and keep calling back into this method, creating a
# tight loop that never populates the index.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
async with self._hash_calculation_lock: async with self._hash_calculation_lock:
@@ -125,6 +132,7 @@ class CheckpointScanner(ModelScanner):
and metadata.hash_status == "completed" and metadata.hash_status == "completed"
and metadata.sha256 and metadata.sha256
): ):
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
task = self._hash_calculation_tasks.get(real_path) task = self._hash_calculation_tasks.get(real_path)
@@ -175,6 +183,9 @@ class CheckpointScanner(ModelScanner):
# Check if hash is already calculated # Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256: if metadata.hash_status == "completed" and metadata.sha256:
# Populate the in-memory hash index even for pre-computed
# hashes, mirroring the fix in calculate_hash_for_model.
self._hash_index.add_entry(metadata.sha256.lower(), file_path)
return metadata.sha256 return metadata.sha256
# Update status to calculating # Update status to calculating
@@ -193,6 +204,20 @@ class CheckpointScanner(ModelScanner):
# Update hash index # Update hash index
self._hash_index.add_entry(sha256.lower(), file_path) self._hash_index.add_entry(sha256.lower(), file_path)
# Update the in-memory cache entry so that subsequent
# _persist_current_cache / _save_persistent_cache calls
# write the hash back to the SQLite models table. Without
# this the hash only lives in the metadata file and the
# in-memory hash index, both of which are lost across
# restarts, causing the same re-computation loop on the
# next session.
if self._cache is not None and self._cache.raw_data:
for entry in self._cache.raw_data:
if entry.get("file_path") == file_path:
entry["sha256"] = sha256.lower()
entry["hash_status"] = "completed"
break
logger.info(f"Hash calculated for checkpoint: {file_path}") logger.info(f"Hash calculated for checkpoint: {file_path}")
return sha256 return sha256
+69 -16
View File
@@ -15,6 +15,17 @@ from .service_registry import ServiceRegistry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_PROVIDER_DISPLAY_NAMES = {
"civitai_api": "CivitAI",
"civarchive_api": "CivArchive",
"sqlite": "Archive DB",
}
_PRESET_PROVIDER_ORDERS = {
"civitai_archive_sqlite": ["civitai_api", "civarchive_api", "sqlite"],
"civitai_sqlite_archive": ["civitai_api", "sqlite", "civarchive_api"],
}
async def initialize_metadata_providers(): async def initialize_metadata_providers():
"""Initialize and configure all metadata providers based on settings""" """Initialize and configure all metadata providers based on settings"""
provider_manager = await ModelMetadataProviderManager.get_instance() provider_manager = await ModelMetadataProviderManager.get_instance()
@@ -26,7 +37,9 @@ async def initialize_metadata_providers():
# Get settings # Get settings
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False) enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
providers = [] providers = []
# Initialize archive database provider if enabled # Initialize archive database provider if enabled
@@ -59,27 +72,48 @@ async def initialize_metadata_providers():
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize Civitai API metadata provider: {e}") logger.error(f"Failed to initialize Civitai API metadata provider: {e}")
# Register CivArchive provider, and all add to fallback providers # Register CivArchive provider when enabled. Civitai API is always
try: # preferred (better metadata); CivArchive mainly recovers metadata for
civarchive_client = await ServiceRegistry.get_civarchive_client() # models deleted from Civitai, so it can be turned off to avoid its long
civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client) # rate-limit windows entirely.
provider_manager.register_provider('civarchive_api', civarchive_provider) if enable_civarchive_api:
providers.append(('civarchive_api', civarchive_provider)) try:
logger.debug("CivArchive metadata provider registered (also included in fallback)") civarchive_client = await ServiceRegistry.get_civarchive_client()
except Exception as e: civarchive_provider = CivArchiveModelMetadataProvider(civarchive_client)
logger.error(f"Failed to initialize CivArchive metadata provider: {e}") provider_manager.register_provider('civarchive_api', civarchive_provider)
providers.append(('civarchive_api', civarchive_provider))
logger.debug("CivArchive metadata provider registered (also included in fallback)")
except Exception as e:
logger.error(f"Failed to initialize CivArchive metadata provider: {e}")
else:
logger.debug("CivArchive metadata provider disabled by setting 'enable_civarchive_api'")
# Preset fallback orderings (see module-level _PRESET_PROVIDER_ORDERS).
# civitai_api is always first (better metadata); the remaining providers
# are arranged by the configured preset. Providers that are not
# registered (disabled/unavailable) are simply skipped, so each preset
# degrades gracefully.
desired_order = _PRESET_PROVIDER_ORDERS.get(
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
)
# Set up fallback provider based on available providers # Set up fallback provider based on available providers
if len(providers) > 1: if len(providers) > 1:
# Always use Civitai API (it has better metadata), then CivArchive API, then Archive DB
ordered_providers: list[tuple[str, ModelMetadataProvider]] = [] ordered_providers: list[tuple[str, ModelMetadataProvider]] = []
ordered_providers.extend([p for p in providers if p[0] == 'civitai_api']) for name in desired_order:
ordered_providers.extend([p for p in providers if p[0] == 'civarchive_api']) ordered_providers.extend([p for p in providers if p[0] == name])
ordered_providers.extend([p for p in providers if p[0] == 'sqlite']) # Include any provider not covered by the preset (defensive) at the end
for p in providers:
if p not in ordered_providers:
ordered_providers.append(p)
if ordered_providers: if ordered_providers:
fallback_provider = FallbackMetadataProvider(ordered_providers) fallback_provider = FallbackMetadataProvider(ordered_providers)
provider_manager.register_provider('fallback', fallback_provider, is_default=True) provider_manager.register_provider('fallback', fallback_provider, is_default=True)
logger.debug(
"Metadata fallback provider order: %s",
", ".join(name for name, _ in ordered_providers),
)
elif len(providers) == 1: elif len(providers) == 1:
# Only one provider available, set it as default # Only one provider available, set it as default
provider_name, provider = providers[0] provider_name, provider = providers[0]
@@ -96,11 +130,30 @@ async def update_metadata_providers():
# Get current settings # Get current settings
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
enable_archive_db = settings_manager.get('enable_metadata_archive_db', False) enable_archive_db = settings_manager.get('enable_metadata_archive_db', False)
enable_civarchive_api = settings_manager.get('enable_civarchive_api', True)
provider_order = settings_manager.get('metadata_provider_order', 'civitai_archive_sqlite')
# Reinitialize all providers with new settings # Reinitialize all providers with new settings
provider_manager = await initialize_metadata_providers() provider_manager = await initialize_metadata_providers()
logger.info(f"Updated metadata providers, archive_db enabled: {enable_archive_db}") # Build effective provider chain for logging (use actually-registered
# providers, not just settings, so a failed init is reflected correctly)
registered = set(provider_manager.providers.keys())
desired = _PRESET_PROVIDER_ORDERS.get(
provider_order, _PRESET_PROVIDER_ORDERS["civitai_archive_sqlite"]
)
chain = "".join(
_PROVIDER_DISPLAY_NAMES[p]
for p in desired
if p in registered and p in _PROVIDER_DISPLAY_NAMES
)
logger.info(
"Updated metadata providers: archive_db=%s, civarchive_api=%s, chain=%s",
enable_archive_db,
enable_civarchive_api,
chain,
)
return provider_manager return provider_manager
except Exception as e: except Exception as e:
logger.error(f"Failed to update metadata providers: {e}") logger.error(f"Failed to update metadata providers: {e}")
+26 -1
View File
@@ -1830,7 +1830,32 @@ class ModelScanner:
if limit == 0: if limit == 0:
return sorted_tags return sorted_tags
return sorted_tags[:limit] return sorted_tags[:limit]
async def search_tags(
self, query: str, limit: int = 50
) -> List[Dict[str, any]]:
"""Search tags by case-insensitive substring match, sorted by count.
If query is empty, behaves like get_top_tags (returns top ``limit``
tags). If limit is 0, all matching tags are returned.
"""
await self.get_cached_data()
normalized_query = (query or "").strip().lower()
if not normalized_query:
return await self.get_top_tags(limit if limit > 0 else 20)
matched = [
{"tag": tag, "count": count}
for tag, count in self._tags_count.items()
if normalized_query in tag.lower()
]
matched.sort(key=lambda x: x["count"], reverse=True)
if limit == 0:
return matched
return matched[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get base models sorted by count. If limit is 0, return all.""" """Get base models sorted by count. If limit is 0, return all."""
cache = await self.get_cached_data() cache = await self.get_cached_data()
+6 -2
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
from typing import Iterable, List, Dict, Optional from typing import Iterable, List, Dict, Optional
from dataclasses import dataclass, field from dataclasses import dataclass, field
from operator import itemgetter
from natsort import natsorted from natsort import natsorted
@@ -149,5 +148,10 @@ class RecipeCache:
) )
if not name_only: if not name_only:
self.sorted_by_date = sorted( self.sorted_by_date = sorted(
self.raw_data, key=itemgetter("created_date", "file_path"), reverse=True self.raw_data,
key=lambda x: (
x.get("modified", x.get("created_date", 0)),
x.get("file_path", ""),
),
reverse=True,
) )
+2 -1
View File
@@ -216,11 +216,12 @@ class RecipePersistenceService:
"preview_nsfw_level", "preview_nsfw_level",
"favorite", "favorite",
"gen_params", "gen_params",
"base_model",
) )
if not any(key in updates for key in allowed_fields): if not any(key in updates for key in allowed_fields):
raise RecipeValidationError( raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params)" "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite or gen_params or base_model)"
) )
if "gen_params" in updates and not isinstance(updates["gen_params"], dict): if "gen_params" in updates and not isinstance(updates["gen_params"], dict):
+2
View File
@@ -65,6 +65,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"onboarding_completed": False, "onboarding_completed": False,
"dismissed_banners": [], "dismissed_banners": [],
"enable_metadata_archive_db": False, "enable_metadata_archive_db": False,
"enable_civarchive_api": True,
"metadata_provider_order": "civitai_archive_sqlite",
"proxy_enabled": False, "proxy_enabled": False,
"proxy_host": "", "proxy_host": "",
"proxy_port": "", "proxy_port": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.1.7" version = "1.1.8"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
+5
View File
@@ -274,6 +274,11 @@
font-style: italic; font-style: italic;
} }
/* Inline extra tags (selected but not in top-20/appended after API results) */
.filter-tag.extra-tag {
border-style: dashed;
}
/* Ensure solid border and full opacity when active or excluded */ /* Ensure solid border and full opacity when active or excluded */
.filter-tag.special-tag.active, .filter-tag.special-tag.active,
.filter-tag.special-tag.exclude { .filter-tag.special-tag.exclude {
+1
View File
@@ -93,6 +93,7 @@ export function getApiEndpoints(modelType) {
// Query operations // Query operations
scan: `/api/lm/${modelType}/scan`, scan: `/api/lm/${modelType}/scan`,
topTags: `/api/lm/${modelType}/top-tags`, topTags: `/api/lm/${modelType}/top-tags`,
searchTags: `/api/lm/${modelType}/search-tags`,
baseModels: `/api/lm/${modelType}/base-models`, baseModels: `/api/lm/${modelType}/base-models`,
roots: `/api/lm/${modelType}/roots`, roots: `/api/lm/${modelType}/roots`,
folders: `/api/lm/${modelType}/folders`, folders: `/api/lm/${modelType}/folders`,
+7 -1
View File
@@ -1665,13 +1665,19 @@ export class BulkManager {
cancelled = true; cancelled = true;
}); });
const isRecipesPage = state.currentPageType === 'recipes';
for (const filepath of state.selectedModels) { for (const filepath of state.selectedModels) {
if (cancelled) { if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info'); showToast('toast.api.operationCancelled', {}, 'info');
break; break;
} }
try { try {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel }); if (isRecipesPage) {
await updateRecipeMetadata(filepath, { base_model: newBaseModel });
} else {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
}
successCount++; successCount++;
} catch (error) { } catch (error) {
errorCount++; errorCount++;
+123 -24
View File
@@ -1,6 +1,7 @@
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js'; import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { getApiEndpoints } from '../api/apiConfig.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js'; import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
@@ -24,6 +25,12 @@ export class FilterManager {
this.baseModelOptions = []; this.baseModelOptions = [];
this.tagsLoaded = false; this.tagsLoaded = false;
// Tag search state
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
this.tagSearchDebounceTimer = null;
this.tagSearchAbortController = null;
this.tagSearchQuery = '';
// Initialize preset manager // Initialize preset manager
this.presetManager = new FilterPresetManager({ this.presetManager = new FilterPresetManager({
page: this.currentPage, page: this.currentPage,
@@ -123,6 +130,60 @@ export class FilterManager {
this.renderBaseModelTags(); this.renderBaseModelTags();
}); });
} }
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.addEventListener('input', () => {
clearTimeout(this.tagSearchDebounceTimer);
this.tagSearchDebounceTimer = setTimeout(() => {
this.handleTagSearchInput();
}, 150);
});
}
}
handleTagSearchInput() {
const query = (this.modelTagsSearchInput?.value || '').trim();
const trimmedQuery = query.toLowerCase();
if (trimmedQuery === this.tagSearchQuery) return;
this.tagSearchQuery = trimmedQuery;
if (!trimmedQuery) {
// Empty query: reload top tags (default/common view)
this.loadTopTags();
return;
}
this.searchTags(trimmedQuery);
}
async searchTags(query) {
// Abort any in-flight search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
}
this.tagSearchAbortController = new AbortController();
const controller = this.tagSearchAbortController;
try {
const tagsEndpoint = `${getApiEndpoints(this.currentPage).searchTags}?q=${encodeURIComponent(query)}&limit=20`;
const response = await fetch(tagsEndpoint, { signal: controller.signal });
if (!response.ok) throw new Error('Failed to search tags');
const data = await response.json();
if (controller.signal.aborted) return; // stale response
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
} else {
throw new Error('Invalid response format');
}
} catch (error) {
if (error.name === 'AbortError') return; // expected, ignore
console.error('Error searching tags:', error);
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-error">Failed to search tags</div>';
}
const emptyState = document.getElementById('modelTagsEmptyState');
if (emptyState) emptyState.hidden = true;
}
} }
getNormalizedSearchQuery(input) { getNormalizedSearchQuery(input) {
@@ -146,15 +207,24 @@ export class FilterManager {
} }
async loadTopTags() { async loadTopTags() {
// Abort any in-flight tag search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
this.tagSearchQuery = '';
try { try {
// Show loading state // Show loading state
const tagsContainer = document.getElementById('modelTagsFilter'); const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return; if (!tagsContainer) return;
if (emptyState) emptyState.hidden = true;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>'; tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// Determine the API endpoint based on the page type // Determine the API endpoint based on the page type
const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`; const tagsEndpoint = `${getApiEndpoints(this.currentPage).topTags}?limit=20`;
const response = await fetch(tagsEndpoint); const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags'); if (!response.ok) throw new Error('Failed to fetch tags');
@@ -179,29 +249,38 @@ export class FilterManager {
createTagFilterElements(tags) { createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter'); const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return; if (!tagsContainer) return;
tagsContainer.innerHTML = ''; tagsContainer.innerHTML = '';
if (emptyState) emptyState.hidden = true;
// Collect existing tag names from the API response // Collect existing tag names from the API response
const existingTagNames = new Set(tags.map(t => t.tag)); const existingTagNames = new Set(tags.map(t => t.tag));
// Add any active filter tags that aren't in the top 20 // Collect active filter tags that aren't in the response (excluding __no_tags__)
const missingSelectedTags = [];
if (this.filters.tags) { if (this.filters.tags) {
Object.keys(this.filters.tags).forEach(tagName => { Object.keys(this.filters.tags).forEach(tagName => {
// Skip special tags like __no_tags__
if (tagName.startsWith('__')) return; if (tagName.startsWith('__')) return;
if (!existingTagNames.has(tagName)) { if (!existingTagNames.has(tagName)) {
// Add this tag to the list with count 0 (unknown) missingSelectedTags.push({ tag: tagName, count: 0 });
tags.push({ tag: tagName, count: 0 });
existingTagNames.add(tagName); existingTagNames.add(tagName);
} }
}); });
} }
// Append missing selected tags after the API results so they appear inline
for (const t of missingSelectedTags) {
tags.push(t);
}
if (!tags.length) { if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`; if (this.tagSearchQuery) {
if (emptyState) emptyState.hidden = false;
} else {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
}
return; return;
} }
@@ -209,6 +288,10 @@ export class FilterManager {
const tagEl = document.createElement('div'); const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter'; tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag; const tagName = tag.tag;
if (missingSelectedTags.some(t => t.tag === tagName)) {
tagEl.classList.add('extra-tag');
}
tagEl.dataset.tag = tagName; tagEl.dataset.tag = tagName;
// Show count only if it's > 0 (known count) // Show count only if it's > 0 (known count)
@@ -234,26 +317,28 @@ export class FilterManager {
tagsContainer.appendChild(tagEl); tagsContainer.appendChild(tagEl);
}); });
// Add "No tags" as a special filter at the end // Add "No tags" as a special filter at the end (skip during search)
const noTagsEl = document.createElement('div'); if (!this.tagSearchQuery) {
noTagsEl.className = 'filter-tag tag-filter special-tag'; const noTagsEl = document.createElement('div');
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags'); noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsKey = '__no_tags__'; const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
noTagsEl.dataset.tag = noTagsKey; const noTagsKey = '__no_tags__';
noTagsEl.innerHTML = noTagsLabel; noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
noTagsEl.addEventListener('click', async () => { noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none'; const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState); const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, newState); this.setTagFilterState(noTagsKey, newState);
this.applyTagElementState(noTagsEl, newState); this.applyTagElementState(noTagsEl, newState);
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
await this.applyFilters(false); await this.applyFilters(false);
}); });
tagsContainer.appendChild(noTagsEl); tagsContainer.appendChild(noTagsEl);
}
this.updateTagSelections(); this.updateTagSelections();
} }
@@ -341,7 +426,7 @@ export class FilterManager {
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page // Set the API endpoint based on current page
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`; const apiEndpoint = `${getApiEndpoints(this.currentPage).baseModels}?limit=0`;
// Fetch base models // Fetch base models
fetch(apiEndpoint) fetch(apiEndpoint)
@@ -721,6 +806,16 @@ export class FilterManager {
tagLogic: 'any' tagLogic: 'any'
}); });
// Clear tag search input and reset search state
if (this.modelTagsSearchInput) {
this.modelTagsSearchInput.value = '';
}
this.tagSearchQuery = '';
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
// Update tag logic toggle UI // Update tag logic toggle UI
this.updateTagLogicToggleUI(); this.updateTagLogicToggleUI();
@@ -731,6 +826,10 @@ export class FilterManager {
// Update UI // Update UI
this.updateTagSelections(); this.updateTagSelections();
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
// Reload tag area to drop any non-top-20 tags from the deactivated preset
if (this.tagsLoaded) {
await this.loadTopTags();
}
this.presetManager.renderPresets(); // Re-render to remove active state this.presetManager.renderPresets(); // Re-render to remove active state
// Remove from local Storage // Remove from local Storage
+13 -19
View File
@@ -478,11 +478,9 @@ export class FilterPresetManager {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
pageState.filters = this.filterManager.cloneFilters(); pageState.filters = this.filterManager.cloneFilters();
// If tags haven't been loaded yet, load them first // Refresh tag display so preset's non-top-20 tags appear inline
if (!this.filterManager.tagsLoaded) { await this.filterManager.loadTopTags();
await this.filterManager.loadTopTags(); this.filterManager.tagsLoaded = true;
this.filterManager.tagsLoaded = true;
}
// Check again after async operation // Check again after async operation
if (requestId !== this.applyPresetRequestId) return; if (requestId !== this.applyPresetRequestId) return;
@@ -745,8 +743,16 @@ export class FilterPresetManager {
presetEl.classList.add('active'); presetEl.classList.add('active');
} }
presetEl.addEventListener('click', (e) => { // Apply preset on click (toggle if already active)
e.stopPropagation(); // Bind to the whole .filter-preset div so clicking anywhere inside triggers apply
presetEl.addEventListener('click', async () => {
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
}); });
const presetName = document.createElement('span'); const presetName = document.createElement('span');
@@ -759,18 +765,6 @@ export class FilterPresetManager {
deleteBtn.innerHTML = '<i class="fas fa-times"></i>'; deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset'); deleteBtn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
// Apply preset on name click (toggle if already active)
presetName.addEventListener('click', async (e) => {
e.stopPropagation();
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
});
// Two-step delete on delete button click // Two-step delete on delete button click
deleteBtn.addEventListener('click', (e) => { deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
+10
View File
@@ -2346,6 +2346,16 @@ export class SettingsManager {
enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false; enableMetadataArchiveCheckbox.checked = state.global.settings.enable_metadata_archive_db || false;
} }
const enableCivarchiveApiCheckbox = document.getElementById('enableCivarchiveApi');
if (enableCivarchiveApiCheckbox) {
enableCivarchiveApiCheckbox.checked = state.global.settings.enable_civarchive_api ?? true;
}
const metadataProviderOrderSelect = document.getElementById('metadataProviderOrder');
if (metadataProviderOrderSelect) {
metadataProviderOrderSelect.value = state.global.settings.metadata_provider_order || 'civitai_archive_sqlite';
}
// Load status // Load status
await this.updateMetadataArchiveStatus(); await this.updateMetadataArchiveStatus();
} catch (error) { } catch (error) {
+2
View File
@@ -13,6 +13,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
language: 'en', language: 'en',
show_only_sfw: false, show_only_sfw: false,
enable_metadata_archive_db: false, enable_metadata_archive_db: false,
enable_civarchive_api: true,
metadata_provider_order: 'civitai_archive_sqlite',
proxy_enabled: false, proxy_enabled: false,
proxy_type: 'http', proxy_type: 'http',
proxy_host: '', proxy_host: '',
+4 -1
View File
@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = null) {
} }
export function showToast(key, params = {}, type = 'info', fallback = null) { export function showToast(key, params = {}, type = 'info', fallback = null) {
const message = translate(key, params, fallback); // Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
// to avoid spurious "Translation key not found" warnings from i18next
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params, fallback);
const toast = document.createElement('div'); const toast = document.createElement('div');
toast.className = `toast toast-${type}`; toast.className = `toast toast-${type}`;
toast.textContent = message; toast.textContent = message;
+5
View File
@@ -251,10 +251,15 @@
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button> <button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
</div> </div>
</div> </div>
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
<div class="filter-tags" id="modelTagsFilter"> <div class="filter-tags" id="modelTagsFilter">
<!-- Top tags will be dynamically inserted here --> <!-- Top tags will be dynamically inserted here -->
<div class="tags-loading">{{ t('common.status.loading') }}</div> <div class="tags-loading">{{ t('common.status.loading') }}</div>
</div> </div>
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
{{ t('header.filter.noTagMatches') }}
</div>
</div> </div>
{% if current_page == 'loras' or current_page == 'checkpoints' %} {% if current_page == 'loras' or current_page == 'checkpoints' %}
<div class="filter-section"> <div class="filter-section">
+80 -43
View File
@@ -144,6 +144,46 @@
</div> </div>
</div> </div>
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.downloads') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="aria2PathSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text"
id="aria2cPath"
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div>
<!-- AI Provider Configuration (BYOK) --> <!-- AI Provider Configuration (BYOK) -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
@@ -250,46 +290,6 @@
{{ provider_models_json | safe }} {{ provider_models_json | safe }}
</script> </script>
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.downloads') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="aria2PathSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text"
id="aria2cPath"
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div>
<!-- Backup --> <!-- Backup -->
<div class="settings-subsection"> <div class="settings-subsection">
<div class="settings-subsection-header"> <div class="settings-subsection-header">
@@ -1401,7 +1401,26 @@
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div> <div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
</div> </div>
<!-- Metadata Archive --> <!-- CivArchive API provider toggle -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="enableCivarchiveApi">
{{ t('settings.metadataArchive.enableCivarchiveApi') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.enableCivarchiveApiHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="enableCivarchiveApi"
onchange="settingsManager.saveToggleSetting('enableCivarchiveApi', 'enable_civarchive_api')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<!-- Metadata Archive DB -->
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -1419,13 +1438,13 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="metadata-archive-status" id="metadataArchiveStatus"> <div class="metadata-archive-status" id="metadataArchiveStatus">
<!-- Status will be populated by JavaScript --> <!-- Status will be populated by JavaScript -->
</div> </div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -1444,6 +1463,24 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Metadata provider fallback order -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="metadataProviderOrder">
{{ t('settings.metadataArchive.providerOrder') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.providerOrderHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="metadataProviderOrder" onchange="settingsManager.saveSelectSetting('metadataProviderOrder', 'metadata_provider_order')">
<option value="civitai_archive_sqlite">{{ t('settings.metadataArchive.providerOrderCivitaiArchiveSqlite') }}</option>
<option value="civitai_sqlite_archive">{{ t('settings.metadataArchive.providerOrderCivitaiSqliteArchive') }}</option>
</select>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
<div id="baseModelEmptyState" hidden></div> <div id="baseModelEmptyState" hidden></div>
<div id="filterPresets" class="filter-presets"></div> <div id="filterPresets" class="filter-presets"></div>
<div id="modelTagsFilter" class="filter-tags"></div> <div id="modelTagsFilter" class="filter-tags"></div>
<input id="modelTagsSearchInput" />
<div id="modelTagsEmptyState" hidden></div>
<button class="clear-filter"></button> <button class="clear-filter"></button>
</div> </div>
<div class="controls"> <div class="controls">
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
expect(stateModule.state.bulkMode).toBe(true); expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true); expect(pageState.duplicatesMode).toBe(true);
}); });
describe('tag search', () => {
it('fetches /search-tags when typing in the tag search input (debounced)', async () => {
vi.useFakeTimers();
const searchTagsUrls = [];
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
searchTagsUrls.push(url);
return Promise.resolve({
ok: true,
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
// Open the panel so tags load
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
// Before debounce fires, no search-tags call yet
expect(searchTagsUrls.length).toBe(0);
// Advance past the 150ms debounce
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
expect(searchTagsUrls.length).toBe(1);
expect(searchTagsUrls[0]).toContain('/search-tags');
expect(searchTagsUrls[0]).toContain('q=ani');
vi.useRealTimers();
});
it('renders selected-but-missing tags in a dedicated group at the top', async () => {
vi.useFakeTimers();
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({
ok: true,
json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
// Pre-seed an active tag filter that won't appear in search results
manager.filters.tags = { 'my-custom-tag': 'include' };
// Open panel and let top-tags load (empty)
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
// Type a search query
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
const container = document.getElementById('modelTagsFilter');
const extraTag = container.querySelector('.filter-tag.extra-tag');
expect(extraTag).not.toBeNull();
expect(extraTag.dataset.tag).toBe('my-custom-tag');
// The search result tag should also be present
const resultTag = container.querySelector('.filter-tag.tag-filter[data-tag="anime"]');
expect(resultTag).not.toBeNull();
vi.useRealTimers();
});
it('shows empty state when search returns no matches and no selected tags', async () => {
vi.useFakeTimers();
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/top-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const input = document.getElementById('modelTagsSearchInput');
input.value = 'zzz';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
const emptyState = document.getElementById('modelTagsEmptyState');
expect(emptyState.hidden).toBe(false);
vi.useRealTimers();
});
it('reloads top tags when search input is cleared', async () => {
vi.useFakeTimers();
let topTagsCallCount = 0;
global.fetch = vi.fn((url) => {
if (url.includes('/search-tags')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [{ tag: 'anime', count: 3 }] }) });
}
if (url.includes('/top-tags')) {
topTagsCallCount++;
return Promise.resolve({ ok: true, json: async () => ({ success: true, tags: [] }) });
}
if (url.includes('/base-models')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, base_models: [] }) });
}
if (url.includes('/model-types')) {
return Promise.resolve({ ok: true, json: async () => ({ success: true, model_types: [] }) });
}
return Promise.resolve({ ok: true, json: async () => ({ success: true }) });
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
manager.toggleFilterPanel();
await vi.runAllTimersAsync();
const callsAfterOpen = topTagsCallCount;
expect(callsAfterOpen).toBeGreaterThanOrEqual(1);
// Type, then clear
const input = document.getElementById('modelTagsSearchInput');
input.value = 'ani';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
input.value = '';
input.dispatchEvent(new Event('input', { bubbles: true }));
vi.advanceTimersByTime(160);
await vi.runAllTimersAsync();
// An additional top-tags call should have happened after clearing
expect(topTagsCallCount).toBeGreaterThan(callsAfterOpen);
vi.useRealTimers();
});
});
}); });
+50
View File
@@ -36,3 +36,53 @@ async def test_model_query_handler_rejects_negative_limit_for_base_models():
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"})) await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
assert service.received_limit == 20 assert service.received_limit == 20
class DummySearchTagsService:
"""Minimal service stub recording search_tags arguments."""
def __init__(self, result=None):
self.received_query = None
self.received_limit = None
self._result = result or []
async def search_tags(self, query, limit):
self.received_query = query
self.received_limit = limit
return self._result
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_passes_query_and_limit():
service = DummySearchTagsService(result=[{"tag": "anime", "count": 3}])
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.search_tags(
SimpleNamespace(query={"q": "ani", "limit": "50"})
)
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["tags"] == [{"tag": "anime", "count": 3}]
assert service.received_query == "ani"
assert service.received_limit == 50
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_defaults_limit_to_20():
service = DummySearchTagsService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.search_tags(SimpleNamespace(query={}))
assert service.received_limit == 20
@pytest.mark.asyncio
async def test_model_query_handler_search_tags_clamps_negative_limit():
service = DummySearchTagsService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.search_tags(SimpleNamespace(query={"limit": "-5"}))
assert service.received_limit == 20
+111
View File
@@ -60,3 +60,114 @@ async def test_get_metadata_provider_returns_fallback_as_is(monkeypatch):
provider = await metadata_service.get_metadata_provider() provider = await metadata_service.get_metadata_provider()
assert provider is fallback assert provider is fallback
# ---------------------------------------------------------------------------
# initialize_metadata_providers — provider gating + fallback ordering
# ---------------------------------------------------------------------------
def _stub_settings(**overrides):
"""Minimal settings stub returning configured values."""
base = {
"enable_metadata_archive_db": False,
"enable_civarchive_api": True,
"metadata_provider_order": "civitai_archive_sqlite",
}
base.update(overrides)
return SimpleNamespace(get=lambda key, default=None: base.get(key, default))
async def _run_initialize(monkeypatch, settings):
# Fresh provider manager for each test
monkeypatch.setattr(
metadata_service.ModelMetadataProviderManager,
"get_instance",
AsyncMock(return_value=metadata_service.ModelMetadataProviderManager()),
)
monkeypatch.setattr(
metadata_service, "get_settings_manager", lambda: settings
)
monkeypatch.setattr(
metadata_service.ServiceRegistry,
"get_civitai_client",
AsyncMock(return_value=object()),
)
monkeypatch.setattr(
metadata_service.ServiceRegistry,
"get_civarchive_client",
AsyncMock(return_value=object()),
)
# Make MetadataArchiveManager report a usable db path when enabled
fake_archive = SimpleNamespace(get_database_path=lambda: "/tmp/fake.db")
monkeypatch.setattr(
metadata_service, "MetadataArchiveManager", lambda _base: fake_archive
)
# Pretend the db file exists
monkeypatch.setattr(metadata_service.os.path, "exists", lambda _p: True)
manager = await metadata_service.initialize_metadata_providers()
return manager
def _fallback_provider_order(manager):
"""Return the ordered list of provider labels inside the fallback provider."""
fallback = manager.providers.get("fallback")
assert isinstance(fallback, FallbackMetadataProvider), "expected a fallback provider"
return list(fallback._provider_labels)
@pytest.mark.asyncio
async def test_initialize_providers_default_order(monkeypatch):
settings = _stub_settings(enable_metadata_archive_db=True)
manager = await _run_initialize(monkeypatch, settings)
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api", "sqlite"]
@pytest.mark.asyncio
async def test_initialize_providers_prefer_sqlite_order(monkeypatch):
settings = _stub_settings(
enable_metadata_archive_db=True,
metadata_provider_order="civitai_sqlite_archive",
)
manager = await _run_initialize(monkeypatch, settings)
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite", "civarchive_api"]
@pytest.mark.asyncio
async def test_initialize_providers_disables_civarchive(monkeypatch):
settings = _stub_settings(
enable_metadata_archive_db=True,
enable_civarchive_api=False,
)
manager = await _run_initialize(monkeypatch, settings)
# civarchive_api must not be registered at all
assert "civarchive_api" not in manager.providers
assert _fallback_provider_order(manager) == ["civitai_api", "sqlite"]
@pytest.mark.asyncio
async def test_initialize_providers_skips_unavailable_sqlite_in_preset(monkeypatch):
# Preset wants sqlite before civarchive, but archive db is disabled ->
# sqlite is unavailable and must be skipped, civarchive stays.
settings = _stub_settings(
enable_metadata_archive_db=False,
metadata_provider_order="civitai_sqlite_archive",
)
manager = await _run_initialize(monkeypatch, settings)
assert _fallback_provider_order(manager) == ["civitai_api", "civarchive_api"]
@pytest.mark.asyncio
async def test_initialize_providers_single_provider_when_only_civitai(monkeypatch):
# Both archive db and civarchive disabled -> only civitai_api remains,
# which takes the single-provider path (registered as default, no fallback).
settings = _stub_settings(
enable_metadata_archive_db=False,
enable_civarchive_api=False,
)
manager = await _run_initialize(monkeypatch, settings)
assert "fallback" not in manager.providers
assert manager.default_provider == "civitai_api"
+5 -3
View File
@@ -438,7 +438,9 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
if (firstEntry) { if (firstEntry) {
const rect = firstEntry.getBoundingClientRect(); const rect = firstEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`; // Convert GBCR visual offset to container-local space (rect/containerRect are post-scale,
// scrollTop is pre-scale), so only the visual-diff portion is divided by scale
dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
dropIndicator.style.opacity = '1'; dropIndicator.style.opacity = '1';
} }
} else if (targetIndex < entries.length) { } else if (targetIndex < entries.length) {
@@ -447,7 +449,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
if (targetEntry) { if (targetEntry) {
const rect = targetEntry.getBoundingClientRect(); const rect = targetEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`; dropIndicator.style.top = `${(rect.top - containerRect.top) / scale + container.scrollTop - 2}px`;
dropIndicator.style.opacity = '1'; dropIndicator.style.opacity = '1';
} }
} else { } else {
@@ -456,7 +458,7 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
if (lastEntry) { if (lastEntry) {
const rect = lastEntry.getBoundingClientRect(); const rect = lastEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.bottom - containerRect.top + 2) / scale}px`; dropIndicator.style.top = `${(rect.bottom - containerRect.top) / scale + container.scrollTop + 2}px`;
dropIndicator.style.opacity = '1'; dropIndicator.style.opacity = '1';
} }
} }