mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-06 22:10:14 -03:00
feat(filter): add debounced tag search with backend search-tags endpoint
This commit is contained in:
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Voreinstellungsname...",
|
"presetNamePlaceholder": "Voreinstellungsname...",
|
||||||
"baseModel": "Basis-Modell",
|
"baseModel": "Basis-Modell",
|
||||||
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags",
|
||||||
"modelTypes": "Modelltypen",
|
"modelTypes": "Modelltypen",
|
||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
"allowSellingGeneratedContentTooltip": "Verkauf generierter Bilder erlauben",
|
||||||
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
"noCreditRequiredTooltip": "Modell ohne Nennung des Erstellers verwenden",
|
||||||
"noTags": "Keine Tags",
|
"noTags": "Keine Tags",
|
||||||
|
"tagSearchPlaceholder": "Tags durchsuchen...",
|
||||||
|
"noTagMatches": "Keine Tags entsprechen der aktuellen Suche.",
|
||||||
"autoTags": "Auto-Tags",
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
|
||||||
"clearAll": "Alle Filter löschen",
|
"clearAll": "Alle Filter löschen",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Nombre del preajuste...",
|
"presetNamePlaceholder": "Nombre del preajuste...",
|
||||||
"baseModel": "Modelo base",
|
"baseModel": "Modelo base",
|
||||||
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
"baseModelSearchPlaceholder": "Buscar modelos base...",
|
||||||
"modelTags": "Etiquetas (Top 20)",
|
"modelTags": "Etiquetas",
|
||||||
"modelTypes": "Tipos de modelos",
|
"modelTypes": "Tipos de modelos",
|
||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
"allowSellingGeneratedContentTooltip": "Permitir la venta de imágenes generadas",
|
||||||
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
"noCreditRequiredTooltip": "Usar el modelo sin atribuir al creador",
|
||||||
"noTags": "Sin etiquetas",
|
"noTags": "Sin etiquetas",
|
||||||
|
"tagSearchPlaceholder": "Buscar etiquetas...",
|
||||||
|
"noTagMatches": "Ninguna etiqueta coincide con la búsqueda actual.",
|
||||||
"autoTags": "Etiquetas automáticas",
|
"autoTags": "Etiquetas automáticas",
|
||||||
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
|
||||||
"clearAll": "Limpiar todos los filtros",
|
"clearAll": "Limpiar todos los filtros",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Nom du préréglage...",
|
"presetNamePlaceholder": "Nom du préréglage...",
|
||||||
"baseModel": "Modèle de base",
|
"baseModel": "Modèle de base",
|
||||||
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
|
||||||
"modelTags": "Tags (Top 20)",
|
"modelTags": "Tags",
|
||||||
"modelTypes": "Types de modèles",
|
"modelTypes": "Types de modèles",
|
||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
"allowSellingGeneratedContentTooltip": "Autoriser la vente d\"images générées",
|
||||||
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
"noCreditRequiredTooltip": "Utiliser le modèle sans créditer le créateur",
|
||||||
"noTags": "Aucun tag",
|
"noTags": "Aucun tag",
|
||||||
|
"tagSearchPlaceholder": "Rechercher des tags...",
|
||||||
|
"noTagMatches": "Aucun tag ne correspond à la recherche actuelle.",
|
||||||
"autoTags": "Auto-Tags",
|
"autoTags": "Auto-Tags",
|
||||||
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
|
||||||
"clearAll": "Effacer tous les filtres",
|
"clearAll": "Effacer tous les filtres",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "שם קביעה מראש...",
|
"presetNamePlaceholder": "שם קביעה מראש...",
|
||||||
"baseModel": "מודל בסיס",
|
"baseModel": "מודל בסיס",
|
||||||
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
|
||||||
"modelTags": "תגיות (20 המובילות)",
|
"modelTags": "תגיות",
|
||||||
"modelTypes": "סוגי מודלים",
|
"modelTypes": "סוגי מודלים",
|
||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
"allowSellingGeneratedContentTooltip": "אפשר מכירת תמונות שנוצרו",
|
||||||
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
"noCreditRequiredTooltip": "שימוש במודל ללא מתן קרדיט ליוצר",
|
||||||
"noTags": "ללא תגיות",
|
"noTags": "ללא תגיות",
|
||||||
|
"tagSearchPlaceholder": "חיפוש תגיות...",
|
||||||
|
"noTagMatches": "אין תגיות שתואמות את החיפוש הנוכחי.",
|
||||||
"autoTags": "תגיות אוטומטיות",
|
"autoTags": "תגיות אוטומטיות",
|
||||||
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
|
||||||
"clearAll": "נקה את כל המסננים",
|
"clearAll": "נקה את כל המסננים",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "プリセット名...",
|
"presetNamePlaceholder": "プリセット名...",
|
||||||
"baseModel": "ベースモデル",
|
"baseModel": "ベースモデル",
|
||||||
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
||||||
"modelTags": "タグ(上位20)",
|
"modelTags": "タグ",
|
||||||
"modelTypes": "モデルタイプ",
|
"modelTypes": "モデルタイプ",
|
||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
"allowSellingGeneratedContentTooltip": "生成した画像の販売を許可",
|
||||||
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
"noCreditRequiredTooltip": "クレジット表記なしでモデルを使用可能",
|
||||||
"noTags": "タグなし",
|
"noTags": "タグなし",
|
||||||
|
"tagSearchPlaceholder": "タグを検索...",
|
||||||
|
"noTagMatches": "現在の検索に一致するタグはありません。",
|
||||||
"autoTags": "自動タグ",
|
"autoTags": "自動タグ",
|
||||||
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
|
||||||
"clearAll": "すべてのフィルタをクリア",
|
"clearAll": "すべてのフィルタをクリア",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "프리셋 이름...",
|
"presetNamePlaceholder": "프리셋 이름...",
|
||||||
"baseModel": "베이스 모델",
|
"baseModel": "베이스 모델",
|
||||||
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
||||||
"modelTags": "태그 (상위 20개)",
|
"modelTags": "태그",
|
||||||
"modelTypes": "모델 유형",
|
"modelTypes": "모델 유형",
|
||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
"allowSellingGeneratedContentTooltip": "생성된 이미지 판매 허용",
|
||||||
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
"noCreditRequiredTooltip": "크리에이터 저작자 표시 없이 모델 사용 가능",
|
||||||
"noTags": "태그 없음",
|
"noTags": "태그 없음",
|
||||||
|
"tagSearchPlaceholder": "태그 검색...",
|
||||||
|
"noTagMatches": "현재 검색과 일치하는 태그가 없습니다.",
|
||||||
"autoTags": "자동 태그",
|
"autoTags": "자동 태그",
|
||||||
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||||
"clearAll": "모든 필터 지우기",
|
"clearAll": "모든 필터 지우기",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "Имя пресета...",
|
"presetNamePlaceholder": "Имя пресета...",
|
||||||
"baseModel": "Базовая модель",
|
"baseModel": "Базовая модель",
|
||||||
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
|
||||||
"modelTags": "Теги (Топ 20)",
|
"modelTags": "Теги",
|
||||||
"modelTypes": "Типы моделей",
|
"modelTypes": "Типы моделей",
|
||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
"allowSellingGeneratedContentTooltip": "Разрешить продажу сгенерированных изображений",
|
||||||
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
"noCreditRequiredTooltip": "Использование модели без указания автора",
|
||||||
"noTags": "Без тегов",
|
"noTags": "Без тегов",
|
||||||
|
"tagSearchPlaceholder": "Поиск тегов...",
|
||||||
|
"noTagMatches": "Нет тегов, соответствующих текущему поиску.",
|
||||||
"autoTags": "Авто-теги",
|
"autoTags": "Авто-теги",
|
||||||
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
|
||||||
"clearAll": "Очистить все фильтры",
|
"clearAll": "Очистить все фильтры",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "预设名称...",
|
"presetNamePlaceholder": "预设名称...",
|
||||||
"baseModel": "基础模型",
|
"baseModel": "基础模型",
|
||||||
"baseModelSearchPlaceholder": "搜索基础模型...",
|
"baseModelSearchPlaceholder": "搜索基础模型...",
|
||||||
"modelTags": "标签(前20)",
|
"modelTags": "标签",
|
||||||
"modelTypes": "模型类型",
|
"modelTypes": "模型类型",
|
||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
"allowSellingGeneratedContentTooltip": "允许出售生成的图片",
|
||||||
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
"noCreditRequiredTooltip": "使用模型时无需注明原作者",
|
||||||
"noTags": "无标签",
|
"noTags": "无标签",
|
||||||
|
"tagSearchPlaceholder": "搜索标签...",
|
||||||
|
"noTagMatches": "没有匹配当前搜索的标签。",
|
||||||
"autoTags": "自动标签",
|
"autoTags": "自动标签",
|
||||||
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
"noBaseModelMatches": "没有基础模型符合当前搜索。",
|
||||||
"clearAll": "清除所有筛选",
|
"clearAll": "清除所有筛选",
|
||||||
|
|||||||
@@ -233,7 +233,7 @@
|
|||||||
"presetNamePlaceholder": "預設名稱...",
|
"presetNamePlaceholder": "預設名稱...",
|
||||||
"baseModel": "基礎模型",
|
"baseModel": "基礎模型",
|
||||||
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
||||||
"modelTags": "標籤(前 20)",
|
"modelTags": "標籤",
|
||||||
"modelTypes": "模型類型",
|
"modelTypes": "模型類型",
|
||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
@@ -241,6 +241,8 @@
|
|||||||
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
"allowSellingGeneratedContentTooltip": "允許出售生成的圖片",
|
||||||
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
"noCreditRequiredTooltip": "使用模型時無需註明原作者",
|
||||||
"noTags": "無標籤",
|
"noTags": "無標籤",
|
||||||
|
"tagSearchPlaceholder": "搜尋標籤...",
|
||||||
|
"noTagMatches": "沒有符合目前搜尋的標籤。",
|
||||||
"autoTags": "自動標籤",
|
"autoTags": "自動標籤",
|
||||||
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
|
||||||
"clearAll": "清除所有篩選",
|
"clearAll": "清除所有篩選",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1831,6 +1831,31 @@ class ModelScanner:
|
|||||||
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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
if (this.tagSearchQuery) {
|
||||||
|
if (emptyState) emptyState.hidden = false;
|
||||||
|
} else {
|
||||||
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
|
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,7 +317,8 @@ 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)
|
||||||
|
if (!this.tagSearchQuery) {
|
||||||
const noTagsEl = document.createElement('div');
|
const noTagsEl = document.createElement('div');
|
||||||
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
noTagsEl.className = 'filter-tag tag-filter special-tag';
|
||||||
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
const noTagsLabel = translate('header.filter.noTags', {}, 'No tags');
|
||||||
@@ -254,6 +338,7 @@ export class FilterManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user