From f53f859a7167be0ce824663c65d7306d56f604fb Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 20 Jul 2026 17:37:47 +0800 Subject: [PATCH] feat(filter): add debounced tag search with backend search-tags endpoint --- locales/de.json | 6 +- locales/en.json | 4 +- locales/es.json | 6 +- locales/fr.json | 6 +- locales/he.json | 6 +- locales/ja.json | 6 +- locales/ko.json | 6 +- locales/ru.json | 6 +- locales/zh-CN.json | 6 +- locales/zh-TW.json | 6 +- py/routes/handlers/model_handlers.py | 19 ++ py/routes/handlers/recipe_handlers.py | 61 +++++- py/routes/model_route_registrar.py | 1 + py/routes/recipe_route_registrar.py | 1 + py/services/base_model_service.py | 6 + py/services/model_scanner.py | 27 ++- static/css/components/search-filter.css | 5 + static/js/api/apiConfig.js | 1 + static/js/managers/FilterManager.js | 147 ++++++++++--- static/js/managers/FilterPresetManager.js | 8 +- templates/components/header.html | 5 + .../components/pageControls.filtering.test.js | 196 ++++++++++++++++++ tests/routes/test_model_query_handler.py | 50 +++++ 23 files changed, 530 insertions(+), 55 deletions(-) diff --git a/locales/de.json b/locales/de.json index 194f58cb..f7ddd226 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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" } } -} \ No newline at end of file +} diff --git a/locales/en.json b/locales/en.json index ebb07446..a487d27a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index e7c7952d..b588be0c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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" } } -} \ No newline at end of file +} diff --git a/locales/fr.json b/locales/fr.json index b893b786..58a24794 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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" } } -} \ No newline at end of file +} diff --git a/locales/he.json b/locales/he.json index b35d737a..9b22296b 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "נסה שוב" } } -} \ No newline at end of file +} diff --git a/locales/ja.json b/locales/ja.json index 00c4910f..14aae4df 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "再試行" } } -} \ No newline at end of file +} diff --git a/locales/ko.json b/locales/ko.json index f3012b89..44a4dd25 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "다시 시도" } } -} \ No newline at end of file +} diff --git a/locales/ru.json b/locales/ru.json index 6d563bbe..a1dddf90 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Повторить" } } -} \ No newline at end of file +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 34874363..7b7cd999 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "重试" } } -} \ No newline at end of file +} diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d829e561..00584cb8 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "重試" } } -} \ No newline at end of file +} diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index ff09209d..408df67f 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -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, diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 24fe47fe..6477e69a 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -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() diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index 9373f4f5..7e6e130f 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -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"), diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 8055a139..375d4180 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -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"), diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 5d6ebaea..47bdb692 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -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) diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index 4e00e69d..ed7d4da5 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -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() diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index 6b0ca6e8..6d42988b 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -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 { diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index ccafba35..8673c929 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -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`, diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 5f45abcc..71587b3b 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -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 = '
Failed to search tags
'; + } + 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 = '
Loading tags...
'; // 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 = `
No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available
`; + if (this.tagSearchQuery) { + if (emptyState) emptyState.hidden = false; + } else { + tagsContainer.innerHTML = `
No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available
`; + } 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 diff --git a/static/js/managers/FilterPresetManager.js b/static/js/managers/FilterPresetManager.js index bd8d89c1..a8d12234 100644 --- a/static/js/managers/FilterPresetManager.js +++ b/static/js/managers/FilterPresetManager.js @@ -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; diff --git a/templates/components/header.html b/templates/components/header.html index a16dd14e..00a53da1 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -251,10 +251,15 @@ +
{{ t('common.status.loading') }}
+ {% if current_page == 'loras' or current_page == 'checkpoints' %}
diff --git a/tests/frontend/components/pageControls.filtering.test.js b/tests/frontend/components/pageControls.filtering.test.js index d4d6cfe4..9ea8b0f5 100644 --- a/tests/frontend/components/pageControls.filtering.test.js +++ b/tests/frontend/components/pageControls.filtering.test.js @@ -113,6 +113,8 @@ function renderControlsDom(pageKey) {
+ +
@@ -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(); + }); + }); }); \ No newline at end of file diff --git a/tests/routes/test_model_query_handler.py b/tests/routes/test_model_query_handler.py index 11278528..f39cb357 100644 --- a/tests/routes/test_model_query_handler.py +++ b/tests/routes/test_model_query_handler.py @@ -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