Compare commits

...

4 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
27 changed files with 684 additions and 197 deletions

View File

@@ -10,13 +10,17 @@
"Brennok",
"2018cfh",
"Insomnia Art Designs",
"Rob Williams",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"$MetaSamsara",
"W+K+White",
"stone9k",
"Rosenthal",
"Francisco Tatis",
"Mozzel",
"Gingko Biloba",
"Birdy",
"Kiba",
"onesecondinosaur",
"Christian Byrne",
@@ -27,22 +31,25 @@
"Phil",
"Carl G.",
"Dsperado",
"Rosenthal",
"ClockDaemon",
"Francisco Tatis",
"Tobi_Swagg",
"SG",
"jmack",
"Andrew Wilson",
"Greybush",
"Ricky Carter",
"JongWon Han",
"VantAI",
"レプサイ",
"Michael Wong",
"Illrigger",
"Tom Corrigan",
"JackieWang",
"FreelancerZ",
"fnkylove",
"Lilleman",
"Robert Stacey",
"PM",
"Edgar Tejeda",
"Liam MacDougal",
"Polymorphic Indeterminate",
@@ -50,9 +57,7 @@
"Dogwalkerbr",
"Skalabananen",
"Marc Whiffen",
"Birdy",
"itismyelement",
"Mozzel",
"quarz",
"Reno Lam",
"jean jahren",
@@ -65,40 +70,46 @@
"Jonathan Ross",
"KD",
"Omnidex",
"Nazono_hito",
"Nolife_M",
"Melville Parrish",
"daniel dove",
"Lustre",
"Tyler Trebuchon",
"Release Cabrakan",
"JW Sin",
"Alex",
"SG",
"bh",
"carozzz",
"Marlon Daniels",
"James Dooley",
"zenbound",
"Buzzard",
"jmack",
"Aaron Bleuer",
"LacesOut!",
"Adam Shaw",
"Mark Corneglio",
"SarcasticHashtag",
"RedrockVP",
"James Todd",
"Wicked Choices by ASLPro3D",
"FinalyFree",
"Weasyl",
"Steven Pfeiffer",
"レプサイ",
"Timmy",
"Johnny",
"Cory Paza",
"Tak",
"Lisster",
"runte3221",
"Big Red",
"whudunit",
"Luc Job",
"dl0901dm",
"corde",
"Yushio",
"Vik71it",
"Bishoujoker",
"Echo",
"Lilleman",
"PM",
"Todd Keck",
"Briton Heilbrun",
"wildnut",
@@ -108,53 +119,51 @@
"BadassArabianMofo",
"Pascal Dahle",
"Greg",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.",
"MiraiKuriyamaSy",
"otaku fra",
"lmsupporter",
"andrew.tappan",
"Takkan",
"N/A",
"Greenmoustache",
"zounic",
"wfpearl",
"ElitaSSJ4",
"Matt+J",
"Jack B Nimble",
"Melville Parrish",
"Lustre",
"bh",
"Jwk0205",
"Marlon Daniels",
"Starkselle",
"Aaron Bleuer",
"LacesOut!",
"Olive",
"greebles",
"Some Guy Named Barry",
"Resist's Creations - Spicy Edition 🔥",
"M Postkasse",
"Wolffen",
"wamekukyouzin",
"drum matthieu",
"Jacob Hoehler",
"FinalyFree",
"DogmaR34",
"Matt Wenzel",
"Weasyl",
"Lex Song",
"Cory Paza",
"Christopher Michel",
"Gonzalo Andre Allendes Lopez",
"Serge Bekenkamp",
"Jimmy Ledbetter",
"Luc Job",
"LeoZero",
"Philip Hempel",
"corde",
"nwalker94",
"dan",
"aai",
"Tori",
"Mouthlessman",
"Ran C",
"ViperC",
"Sangheili460",
"MagnaInsomnia",
"Akira_HentAI",
"Karl P.",
"Adam Taylor",
"Weird_With_A_Beard",
"N/A",
"The Spawn",
"graysock",
"Pozadine1",
@@ -162,7 +171,8 @@
"AIGooner",
"Luc",
"ProtonPrince",
"Greenmoustache",
"DiffDuck",
"elu3199",
"fancypants",
"John+Edwards",
"Joboshy",
@@ -172,42 +182,39 @@
"contrite831",
"Dan",
"Bro Xie",
"yer fey",
"batblue",
"carey6409",
"Olive",
"太郎 ゲーム",
"Roslynd",
"jinxedx",
"Neco28",
"David Ortega",
"AELOX",
"Gooohokrbe",
"Dankin-Pics",
"Nicfit23",
"Cristian Vazquez",
"wamekukyouzin",
"OldBones",
"drum matthieu",
"Dogmaster",
"Frank Nitty",
"Magic Noob",
"Christopher Michel",
"Zach Gonser",
"Serge Bekenkamp",
"DougPeterson",
"LeoZero",
"Antonio Pontes",
"nahinahi9",
"Bruce",
"kushiroK9",
"Kevin John Duck",
"Dustin Chen",
"Kevin Christopher",
"Blackfish95",
"Mouthlessman",
"Paul Kroll",
"Penfore",
"Bas Imagineer",
"John Statham",
"Gordon Cole",
"AbstractAss",
"Dušan Ryban",
"decoy",
"DiffDuck",
"elu3199",
"Hasturkun",
"Jon Sandman",
"Ubivis",
@@ -222,34 +229,34 @@
"MJG",
"David LaVallee",
"linnfrey",
"ae",
"Tr4shP4nda",
"Jackthemind",
"griffin+dahlberg",
"jeaness",
"takyamtom",
"Brian M",
"Josef Lanzl",
"Nerezza",
"yer fey",
"sanborondon",
"Error_Rule34_Not_found",
"aezin",
"jcay015",
"Erik Lopez",
"Roslynd",
"Mateo Curić",
"Geolog",
"Neco28",
"Cosmosis",
"Eris3D",
"David Ortega",
"m",
"FloPro4Sho",
"Jamie Ogletree",
"a _",
"Jeff",
"Bruce",
"Steven Owens",
"James Coleman",
"Kevin Christopher",
"Chad Idk",
"dd",
"John Statham",
"Sam",
"sjon kreutz",
"yuxz69",
"LarsesFPC",
@@ -257,8 +264,6 @@
"esthe",
"AlexDuKaNa",
"地獄の禄",
"ae",
"Tr4shP4nda",
"Gamalonia",
"capn",
"Joseph",
@@ -272,12 +277,16 @@
"Hailshem",
"Naomi Hale Danchi",
"epicgamer0020690",
"Joshua Porrata",
"SuBu",
"RedPIXel",
"Wind",
"IamAyam",
"Andrew",
"Brian M",
"Robert Wegemund",
"sanborondon",
"confiscated Zyra",
"Littlehuggy",
"Andrew Marshall",
"Brian Buie",
"Taylor Funk",
"Thought2Form",
"Gerald Welly",
@@ -285,15 +294,19 @@
"Sadlip",
"Tee Gee",
"tarek helmi",
"Joey Callahan",
"Max Marklund",
"m",
"Mike Simone",
"Pierce McBride",
"Joshua Gray",
"Pronredn",
"Mikko Hemilä",
"Jamie Ogletree",
"Jacob McDaniel",
"X",
"Temikus",
"Artokun",
"Michael Taylor",
"Derek Baker",
"lh qwe",
"Martial",
"conner",
@@ -305,24 +318,21 @@
"Decx _",
"Yuji Kaneko",
"Rops Alot",
"Sam",
"Ace Ventura",
"四糸凜音",
"Xeeosat",
"Douglas Gaspar",
"Saya",
"George",
"dw",
"FrxzenSnxw",
"WRL_SPR",
"momokai",
"몽타주",
"kudari",
"ken",
"Crocket",
"Joshua Porrata",
"keemun",
"SuBu",
"RedPIXel",
"Wind",
"Nexus",
"Ramneek“Guy”Ashok",
"squid_actually",
@@ -337,37 +347,36 @@
"KitKatM",
"socrasteeze",
"OrganicArtifact",
"ResidentDeviant",
"MudkipMedkitz",
"deanbrian",
"Alex Wortman",
"Cody",
"emadsultan",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"gzmzmvp",
"Richard",
"奚明 刘",
"Littlehuggy",
"Aberr",
"Gregory Kozhemiak",
"준희 김",
"Brian Buie",
"Eric Whitney",
"Joey Callahan",
"Ivan Tadic",
"Tomohiro Baba",
"Mike Simone",
"Noora",
"John J Linehan",
"Mattssn",
"Elliot E",
"Morgandel",
"Theerat Jiramate",
"Noah",
"Jacob McDaniel",
"X",
"Sloan Steddy",
"Artokun",
"hexxish",
"Derek Baker",
"Steam Steam",
"NICHOLAS BAXLEY",
"CryptoTraderJK",
@@ -378,23 +387,14 @@
"Fotek Design",
"Nihongasuki",
"MadSpin",
"FrxzenSnxw",
"inbijiburu",
"Nick “Loadstone” D",
"starbugx",
"dc7431",
"ResidentDeviant",
"Ginnie",
"Raku",
"InformedViewz",
"CHKeeho80",
"Bubbafett",
"leaf",
"Vir",
"Skyfire83",
"Adam Rinehart",
"Pitpe11",
"TheD1rtyD03",
"moonpetal",
"g9p0o",
"Pkrsky",
@@ -403,6 +403,8 @@
"SpringBootisTrash",
"carsten",
"ikok",
"quantenmecha",
"Jason+Nash",
"DarkRoast",
"Nasty+Hobbit",
"letzte",
@@ -414,12 +416,15 @@
"David Schenck",
"Wolfe7D1",
"Draven T",
"Time Valentine",
"elleshar666",
"ACTUALLY_the_Real_Willem_Dafoe",
"Михал Михалыч",
"Matt",
"Aquatic Coffee",
"Kauffy",
"ethanfel",
"SPJ",
"Focuschannel",
"Edward Kennedy",
"Nick Kage",
@@ -432,12 +437,13 @@
"notedfakes",
"Michael Scott",
"Pat Hen",
"Saya",
"Solixer",
"Jordan Shaw",
"Wes Sims",
"Donor4115",
"g unit",
"Jimmy Borup",
"Manu Thetug",
"Filippo Ferrari",
"JC",
"Prompt Pirate",
@@ -451,6 +457,11 @@
"SomeDude",
"nanana",
"raf8osz",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex",
"Karru",
"ChaChanoKo",
"redcarrot",
@@ -467,36 +478,34 @@
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"Jason+Nash",
"cocona",
"blikkies",
"JBsuede",
"Time Valentine",
"Shock Shockor",
"りん あめ",
"Matt",
"Goldwaters",
"Zude",
"Joaquin Hierrezuelo",
"Frogmilk",
"SPJ",
"Sean voets",
"Kyler",
"Kor",
"Joseph Hanson",
"John Rednoulf",
"Bryan Rutkowski",
"Justin Blaylock",
"aRtFuL_DodGeR",
"Steven",
"TenaciousD",
"Dmitry Ryzhov",
"Edward Ten Eyck",
"Billy Gladky",
"Probis",
"Solixer",
"Pete Pain",
"ItsGeneralButtNaked",
"RHopkirk",
"jinksta187",
"robin.kok.",
"Manu Thetug",
"Maxim",
"Karlanx",
"Lyavph",
@@ -504,6 +513,7 @@
"Youguang",
"andrewzpong",
"BossGame",
"Marcus thronico",
"lrdchs",
"Tree Tagger",
"Inversity",
@@ -511,6 +521,15 @@
"Kevinj",
"Mitchell Robson",
"POPPIN",
"PoorStudent",
"Alex+Zaw",
"Supporter",
"ExLightSaber",
"Mobius2020",
"YaboiRay",
"Sildoren",
"Darv",
"Seon+Song",
"2turbo",
"Dmitry+Viznesenskiy",
"tanjin90",
@@ -528,11 +547,6 @@
"Inkognito",
"G",
"Tan+Huynh",
"Bob+Barker",
"D",
"Dark_Pest",
"Eldithor",
"Alex",
"BillyBoy84",
"Buecyb99",
"Welkor",
@@ -545,28 +559,27 @@
"G",
"Ronan Delevacq",
"Christian Schäfer",
"Leslie Andrew Ridings",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Locrospiel",
"Sean voets",
"Jarrid Lee",
"Poophead27 Blyat",
"Joseph Hanson",
"John Rednoulf",
"Kyron Mahan",
"Mythspire",
"Boba Smith",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"MR.Bear",
"matt",
"somethingtosay8",
"Ezokewn",
"Terminuz",
"ivistorm",
"SendingRavens",
"Sauv",
"Steven",
"JackJohnnyJim",
"Khánh Đặng",
"Borte",
"Michael Docherty",
"Ted Cart",
"Sage Himeros",
@@ -574,6 +587,7 @@
"Paul Hartsuyker",
"elitassj",
"Tigon",
"SkibidiRizzler",
"Tania Nayelli Fernandez",
"Draconach",
"Jacob Winter",
@@ -581,6 +595,8 @@
"Andrew Wilkinson",
"David",
"Meilo",
"Nacho Ferrando",
"Marcos Tortosa Carmona",
"Dkom22",
"shinonomeiro",
"Snille",
@@ -589,7 +605,6 @@
"xybrightsummer",
"jreedatchison",
"PhilW",
"Marcus thronico",
"Janik",
"Cruel",
"MRBlack",
@@ -601,6 +616,13 @@
"Scott",
"Muratoraccio",
"D",
"Somebody",
"Celestial+Kitten",
"TequiTequi",
"Homero+Banda",
"bakeliteboy",
"Nick",
"てぃんてぃんひーろー",
"Gold_miner_ego",
"IshouI;_;",
"Monix",
@@ -620,17 +642,8 @@
"you+halo9",
"cloudghost",
"Yongkwan+Lee",
"PoorStudent",
"lucites",
"Alex+Zaw",
"Mobius2020",
"ExLightSaber",
"YaboiRay",
"nickname",
"Sildoren",
"Darv",
"Seon+Song",
"Somebody",
"Balut+Omelette",
"eriick",
"Lev+Lanevskiy",
@@ -651,37 +664,35 @@
"Vinarus",
"Josh Snyder",
"ja s",
"Leslie Andrew Ridings",
"Doug Mason",
"scoreswazey",
"Oliverfish",
"Owen Gwosdz",
"Room Light",
"Patryk Serious",
"AZ Party Oasis",
"Devil Lude",
"Gentle Sartori",
"Snorklebort",
"David Murcko",
"vinter",
"TheFusion",
"Jack Dole",
"matt",
"3zS4QNQ4",
"Terminuz",
"max blo",
"Matt M.",
"Ivan Imes",
"J M",
"Slacks",
"Bouya shaka",
"Jack Lawfield",
"Borte",
"Maso",
"Homero Banda",
"yyuvuvu",
"Eric Ketchum",
"Nomki",
"Kevin Wallace",
"ChicRic",
"BastardSama",
"mercur",
"SkibidiRizzler",
"Never_M",
"Kalle Björk",
"Yavizu3d",
@@ -689,9 +700,7 @@
"Teriak47",
"Just me",
"Raf Stahelin",
"Nacho Ferrando",
"Вячеслав Маринин",
"Marcos Tortosa Carmona",
"Cola Matthew",
"OniNoKen",
"Iain Wisely",
@@ -734,6 +743,15 @@
"SelfishMedic",
"adderleighn",
"EnragedAntelope",
"Brandon+G",
"fazefour33",
"plonk",
"Kotetsu",
"o",
"Tony+V",
"Anvil+G",
"draganjankovic1975dj528",
"MrSEIGE88",
"yarsev",
"M+Alsulaiti",
"Mark+Staaf",
@@ -751,11 +769,6 @@
"miduzza",
"KB",
"shw",
"Celestial+Kitten",
"bakeliteboy",
"TequiTequi",
"Homero+Banda",
"Nick",
"Jim",
"JoL",
"YoruHime",
@@ -781,14 +794,14 @@
"han b",
"Nico",
"Maximilian Krischan",
"Banana Joe",
"socialcat",
"proto merp",
"_ G3n",
"Brandon Thomas",
"Donovan Jenkins",
"Hans Meier",
"Dustin Hendel",
"sicarius",
"jboul",
"Michael Eid",
"Liberation",
"Bob barker",
@@ -805,7 +818,6 @@
"jumpd",
"John C",
"Rim",
"Oliverfish",
"yfx507",
"uruksayshi",
"Jairus Knudsen",
@@ -814,24 +826,25 @@
"nk8",
"lylepaul",
"Middo",
"Gary Chaboya",
"Forbidden Atelier",
"Thomas Sankowski",
"DrB",
"Nimhloth",
"Adictedtohumping",
"Moneymaker412K",
"vinter",
"Tsani Prodanov",
"Towelie",
"Jean-françois SEMA",
"Myrthrac",
"Taylor Dominy",
"Andrew Ly",
"Slacks",
"Glenn Hoetker",
"john Greene",
"Faburizu",
"jimyjomson",
"JaeHyun Jang",
"Michael Hicks",
"Homero Banda",
"Chase Kwon",
"Bob Ling",
"Inyoshu",
@@ -858,5 +871,5 @@
"Somebody",
"CK"
],
"totalCount": 855
"totalCount": 868
}

File diff suppressed because one or more lines are too long

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Voreinstellungsname...",
"baseModel": "Basis-Modell",
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
"modelTags": "Tags (Top 20)",
"modelTags": "Tags",
"modelTypes": "Modelltypen",
"license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
"noTags": "Keine Tags",
"tagSearchPlaceholder": "Tags durchsuchen...",
"noTagMatches": "Keine Tags entsprechen der aktuellen Suche.",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen",
@@ -2232,4 +2234,4 @@
"retry": "Wiederholen"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Preset name...",
"baseModel": "Base Model",
"baseModelSearchPlaceholder": "Search base models...",
"modelTags": "Tags (Top 20)",
"modelTags": "Tags",
"modelTypes": "Model Types",
"license": "License",
"noCreditRequired": "No Credit Required",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Allow selling generated images",
"noCreditRequiredTooltip": "Use the model without crediting the creator",
"noTags": "No tags",
"tagSearchPlaceholder": "Search tags...",
"noTagMatches": "No tags match the current search.",
"autoTags": "Auto Tags",
"noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters",

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Nombre del preajuste...",
"baseModel": "Modelo base",
"baseModelSearchPlaceholder": "Buscar modelos base...",
"modelTags": "Etiquetas (Top 20)",
"modelTags": "Etiquetas",
"modelTypes": "Tipos de modelos",
"license": "Licencia",
"noCreditRequired": "Sin crédito requerido",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
"noTags": "Sin etiquetas",
"tagSearchPlaceholder": "Buscar etiquetas...",
"noTagMatches": "Ninguna etiqueta coincide con la búsqueda actual.",
"autoTags": "Etiquetas automáticas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros",
@@ -2232,4 +2234,4 @@
"retry": "Reintentar"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Nom du préréglage...",
"baseModel": "Modèle de base",
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
"modelTags": "Tags (Top 20)",
"modelTags": "Tags",
"modelTypes": "Types de modèles",
"license": "Licence",
"noCreditRequired": "Crédit non requis",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
"noTags": "Aucun tag",
"tagSearchPlaceholder": "Rechercher des tags...",
"noTagMatches": "Aucun tag ne correspond à la recherche actuelle.",
"autoTags": "Auto-Tags",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres",
@@ -2232,4 +2234,4 @@
"retry": "Réessayer"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "שם קביעה מראש...",
"baseModel": "מודל בסיס",
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
"modelTags": "תגיות (20 המובילות)",
"modelTags": "תגיות",
"modelTypes": "סוגי מודלים",
"license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
"noTags": "ללא תגיות",
"tagSearchPlaceholder": "חיפוש תגיות...",
"noTagMatches": "אין תגיות שתואמות את החיפוש הנוכחי.",
"autoTags": "תגיות אוטומטיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים",
@@ -2232,4 +2234,4 @@
"retry": "נסה שוב"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "プリセット名...",
"baseModel": "ベースモデル",
"baseModelSearchPlaceholder": "ベースモデルを検索...",
"modelTags": "タグ上位20",
"modelTags": "タグ",
"modelTypes": "モデルタイプ",
"license": "ライセンス",
"noCreditRequired": "クレジット不要",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
"noTags": "タグなし",
"tagSearchPlaceholder": "タグを検索...",
"noTagMatches": "現在の検索に一致するタグはありません。",
"autoTags": "自動タグ",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア",
@@ -2232,4 +2234,4 @@
"retry": "再試行"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "프리셋 이름...",
"baseModel": "베이스 모델",
"baseModelSearchPlaceholder": "베이스 모델 검색...",
"modelTags": "태그 (상위 20개)",
"modelTags": "태그",
"modelTypes": "모델 유형",
"license": "라이선스",
"noCreditRequired": "크레딧 표기 없음",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
"noTags": "태그 없음",
"tagSearchPlaceholder": "태그 검색...",
"noTagMatches": "현재 검색과 일치하는 태그가 없습니다.",
"autoTags": "자동 태그",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기",
@@ -2232,4 +2234,4 @@
"retry": "다시 시도"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "Имя пресета...",
"baseModel": "Базовая модель",
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
"modelTags": "Теги (Топ 20)",
"modelTags": "Теги",
"modelTypes": "Типы моделей",
"license": "Лицензия",
"noCreditRequired": "Без указания авторства",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
"noCreditRequiredTooltip": "Использование модели без указания автора",
"noTags": "Без тегов",
"tagSearchPlaceholder": "Поиск тегов...",
"noTagMatches": "Нет тегов, соответствующих текущему поиску.",
"autoTags": "Авто-теги",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры",
@@ -2232,4 +2234,4 @@
"retry": "Повторить"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "预设名称...",
"baseModel": "基础模型",
"baseModelSearchPlaceholder": "搜索基础模型...",
"modelTags": "标签前20",
"modelTags": "标签",
"modelTypes": "模型类型",
"license": "许可证",
"noCreditRequired": "无需署名",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
"noTags": "无标签",
"tagSearchPlaceholder": "搜索标签...",
"noTagMatches": "没有匹配当前搜索的标签。",
"autoTags": "自动标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选",
@@ -2232,4 +2234,4 @@
"retry": "重试"
}
}
}
}

View File

@@ -233,7 +233,7 @@
"presetNamePlaceholder": "預設名稱...",
"baseModel": "基礎模型",
"baseModelSearchPlaceholder": "搜尋基礎模型...",
"modelTags": "標籤(前 20",
"modelTags": "標籤",
"modelTypes": "模型類型",
"license": "授權",
"noCreditRequired": "無需署名",
@@ -241,6 +241,8 @@
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
"noTags": "無標籤",
"tagSearchPlaceholder": "搜尋標籤...",
"noTagMatches": "沒有符合目前搜尋的標籤。",
"autoTags": "自動標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選",
@@ -2232,4 +2234,4 @@
"retry": "重試"
}
}
}
}

View File

@@ -973,6 +973,8 @@ class ModelQueryHandler:
limit = int(request.query.get("limit", "20"))
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
top_tags = await self._service.get_top_tags(limit)
return web.json_response({"success": True, "tags": top_tags})
except Exception as exc:
@@ -981,6 +983,22 @@ class ModelQueryHandler:
{"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:
try:
limit = int(request.query.get("limit", "20"))
@@ -2947,6 +2965,7 @@ class ModelHandlerSet:
"bulk_delete_models": self.management.bulk_delete_models,
"verify_duplicates": self.management.verify_duplicates,
"get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models,
"get_model_types": self.query.get_model_types,
"scan_models": self.query.scan_models,

View File

@@ -72,6 +72,7 @@ class RecipeHandlerSet:
"save_recipe": self.management.save_recipe,
"delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags,
"search_tags": self.query.search_tags,
"get_base_models": self.query.get_base_models,
"get_roots": self.query.get_roots,
"get_folders": self.query.get_folders,
@@ -317,12 +318,11 @@ class RecipeQueryHandler:
raise RuntimeError("Recipe scanner unavailable")
limit = int(request.query.get("limit", "20"))
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
if limit < 0:
limit = 20
elif limit > 200:
limit = 20
tag_counts = await self._get_recipe_tag_counts(recipe_scanner)
sorted_tags = [
{"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)
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:
try:
await self._ensure_dependencies_ready()

View File

@@ -46,6 +46,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"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}/search-tags", "search_tags"),
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}/scan", "scan_models"),

View File

@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipes/save", "save_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/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),

View File

@@ -804,6 +804,12 @@ class BaseModelService(ABC):
"""Get top tags sorted by frequency"""
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]:
"""Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit)

View File

@@ -1830,7 +1830,32 @@ class ModelScanner:
if limit == 0:
return sorted_tags
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]]:
"""Get base models sorted by count. If limit is 0, return all."""
cache = await self.get_cached_data()

View File

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

View File

@@ -274,6 +274,11 @@
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 */
.filter-tag.special-tag.active,
.filter-tag.special-tag.exclude {

View File

@@ -93,6 +93,7 @@ export function getApiEndpoints(modelType) {
// Query operations
scan: `/api/lm/${modelType}/scan`,
topTags: `/api/lm/${modelType}/top-tags`,
searchTags: `/api/lm/${modelType}/search-tags`,
baseModels: `/api/lm/${modelType}/base-models`,
roots: `/api/lm/${modelType}/roots`,
folders: `/api/lm/${modelType}/folders`,

View File

@@ -1,6 +1,7 @@
import { getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { getApiEndpoints } from '../api/apiConfig.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -24,6 +25,12 @@ export class FilterManager {
this.baseModelOptions = [];
this.tagsLoaded = false;
// Tag search state
this.modelTagsSearchInput = document.getElementById('modelTagsSearchInput');
this.tagSearchDebounceTimer = null;
this.tagSearchAbortController = null;
this.tagSearchQuery = '';
// Initialize preset manager
this.presetManager = new FilterPresetManager({
page: this.currentPage,
@@ -123,6 +130,60 @@ export class FilterManager {
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) {
@@ -146,15 +207,24 @@ export class FilterManager {
}
async loadTopTags() {
// Abort any in-flight tag search request
if (this.tagSearchAbortController) {
this.tagSearchAbortController.abort();
this.tagSearchAbortController = null;
}
this.tagSearchQuery = '';
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return;
if (emptyState) emptyState.hidden = true;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// 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);
if (!response.ok) throw new Error('Failed to fetch tags');
@@ -179,29 +249,38 @@ export class FilterManager {
createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter');
const emptyState = document.getElementById('modelTagsEmptyState');
if (!tagsContainer) return;
tagsContainer.innerHTML = '';
if (emptyState) emptyState.hidden = true;
// Collect existing tag names from the API response
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) {
Object.keys(this.filters.tags).forEach(tagName => {
// Skip special tags like __no_tags__
if (tagName.startsWith('__')) return;
if (!existingTagNames.has(tagName)) {
// Add this tag to the list with count 0 (unknown)
tags.push({ tag: tagName, count: 0 });
missingSelectedTags.push({ tag: tagName, count: 0 });
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) {
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;
}
@@ -209,6 +288,10 @@ export class FilterManager {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag;
if (missingSelectedTags.some(t => t.tag === tagName)) {
tagEl.classList.add('extra-tag');
}
tagEl.dataset.tag = tagName;
// Show count only if it's > 0 (known count)
@@ -234,26 +317,28 @@ export class FilterManager {
tagsContainer.appendChild(tagEl);
});
// Add "No tags" as a special filter at the end
const noTagsEl = document.createElement('div');
noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
const noTagsKey = '__no_tags__';
noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
// Add "No tags" as a special filter at the end (skip during search)
if (!this.tagSearchQuery) {
const noTagsEl = document.createElement('div');
noTagsEl.className = 'filter-tag tag-filter special-tag';
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
const noTagsKey = '__no_tags__';
noTagsEl.dataset.tag = noTagsKey;
noTagsEl.innerHTML = noTagsLabel;
noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, newState);
this.applyTagElementState(noTagsEl, newState);
noTagsEl.addEventListener('click', async () => {
const currentState = (this.filters.tags && this.filters.tags[noTagsKey]) || 'none';
const newState = this.getNextTriStateState(currentState);
this.setTagFilterState(noTagsKey, 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();
}
@@ -341,7 +426,7 @@ export class FilterManager {
if (!baseModelTagsContainer) return;
// 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(apiEndpoint)
@@ -721,6 +806,16 @@ export class FilterManager {
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
this.updateTagLogicToggleUI();
@@ -731,6 +826,10 @@ export class FilterManager {
// Update UI
this.updateTagSelections();
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
// Remove from local Storage

View File

@@ -478,11 +478,9 @@ export class FilterPresetManager {
const pageState = getCurrentPageState();
pageState.filters = this.filterManager.cloneFilters();
// If tags haven't been loaded yet, load them first
if (!this.filterManager.tagsLoaded) {
await this.filterManager.loadTopTags();
this.filterManager.tagsLoaded = true;
}
// Refresh tag display so preset's non-top-20 tags appear inline
await this.filterManager.loadTopTags();
this.filterManager.tagsLoaded = true;
// Check again after async operation
if (requestId !== this.applyPresetRequestId) return;
@@ -745,8 +743,16 @@ export class FilterPresetManager {
presetEl.classList.add('active');
}
presetEl.addEventListener('click', (e) => {
e.stopPropagation();
// Apply preset on click (toggle if already active)
// 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');
@@ -759,18 +765,6 @@ export class FilterPresetManager {
deleteBtn.innerHTML = '<i class="fas fa-times"></i>';
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
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();

View File

@@ -134,7 +134,10 @@ export async function copyToClipboard(text, successMessage = 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');
toast.className = `toast toast-${type}`;
toast.textContent = message;

View File

@@ -251,10 +251,15 @@
<button class="tag-logic-option" data-value="all" title="{{ t('header.filter.tagLogicAll') }}">{{ t('header.filter.all') }}</button>
</div>
</div>
<input type="text" id="modelTagsSearchInput" class="filter-search-input"
placeholder="{{ t('header.filter.tagSearchPlaceholder') }}" autocomplete="off">
<div class="filter-tags" id="modelTagsFilter">
<!-- Top tags will be dynamically inserted here -->
<div class="tags-loading">{{ t('common.status.loading') }}</div>
</div>
<div id="modelTagsEmptyState" class="filter-empty-state" hidden>
{{ t('header.filter.noTagMatches') }}
</div>
</div>
{% if current_page == 'loras' or current_page == 'checkpoints' %}
<div class="filter-section">

View File

@@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
<div id="baseModelEmptyState" hidden></div>
<div id="filterPresets" class="filter-presets"></div>
<div id="modelTagsFilter" class="filter-tags"></div>
<input id="modelTagsSearchInput" />
<div id="modelTagsEmptyState" hidden></div>
<button class="clear-filter"></button>
</div>
<div class="controls">
@@ -961,4 +963,198 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
expect(stateModule.state.bulkMode).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();
});
});
});

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"}))
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