diff --git a/locales/de.json b/locales/de.json index 0a92283b..316d661c 100644 --- a/locales/de.json +++ b/locales/de.json @@ -223,7 +223,11 @@ "noCreditRequired": "Kein Credit erforderlich", "allowSellingGeneratedContent": "Verkauf erlaubt", "noTags": "Keine Tags", - "clearAll": "Alle Filter löschen" + "clearAll": "Alle Filter löschen", + "any": "Beliebig", + "all": "Alle", + "tagLogicAny": "Jedes Tag abgleichen (ODER)", + "tagLogicAll": "Alle Tags abgleichen (UND)" }, "theme": { "toggle": "Theme wechseln", diff --git a/locales/en.json b/locales/en.json index 61bb02ab..970549ae 100644 --- a/locales/en.json +++ b/locales/en.json @@ -223,7 +223,11 @@ "noCreditRequired": "No Credit Required", "allowSellingGeneratedContent": "Allow Selling", "noTags": "No tags", - "clearAll": "Clear All Filters" + "clearAll": "Clear All Filters", + "any": "Any", + "all": "All", + "tagLogicAny": "Match any tag (OR)", + "tagLogicAll": "Match all tags (AND)" }, "theme": { "toggle": "Toggle theme", diff --git a/locales/es.json b/locales/es.json index cf5a2662..904c8878 100644 --- a/locales/es.json +++ b/locales/es.json @@ -223,7 +223,11 @@ "noCreditRequired": "Sin crédito requerido", "allowSellingGeneratedContent": "Venta permitida", "noTags": "Sin etiquetas", - "clearAll": "Limpiar todos los filtros" + "clearAll": "Limpiar todos los filtros", + "any": "Cualquiera", + "all": "Todos", + "tagLogicAny": "Coincidir con cualquier etiqueta (O)", + "tagLogicAll": "Coincidir con todas las etiquetas (Y)" }, "theme": { "toggle": "Cambiar tema", diff --git a/locales/fr.json b/locales/fr.json index 792ce01f..edca5d25 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -223,7 +223,11 @@ "noCreditRequired": "Crédit non requis", "allowSellingGeneratedContent": "Vente autorisée", "noTags": "Aucun tag", - "clearAll": "Effacer tous les filtres" + "clearAll": "Effacer tous les filtres", + "any": "N'importe quel", + "all": "Tous", + "tagLogicAny": "Correspondre à n'importe quel tag (OU)", + "tagLogicAll": "Correspondre à tous les tags (ET)" }, "theme": { "toggle": "Basculer le thème", diff --git a/locales/he.json b/locales/he.json index e5eb3d91..24f5a6c8 100644 --- a/locales/he.json +++ b/locales/he.json @@ -223,7 +223,11 @@ "noCreditRequired": "ללא קרדיט נדרש", "allowSellingGeneratedContent": "אפשר מכירה", "noTags": "ללא תגיות", - "clearAll": "נקה את כל המסננים" + "clearAll": "נקה את כל המסננים", + "any": "כלשהו", + "all": "כל התגים", + "tagLogicAny": "התאם כל תג (או)", + "tagLogicAll": "התאם את כל התגים (וגם)" }, "theme": { "toggle": "החלף ערכת נושא", diff --git a/locales/ja.json b/locales/ja.json index 03b6f666..d357391c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -223,7 +223,11 @@ "noCreditRequired": "クレジット不要", "allowSellingGeneratedContent": "販売許可", "noTags": "タグなし", - "clearAll": "すべてのフィルタをクリア" + "clearAll": "すべてのフィルタをクリア", + "any": "いずれか", + "all": "すべて", + "tagLogicAny": "いずれかのタグに一致 (OR)", + "tagLogicAll": "すべてのタグに一致 (AND)" }, "theme": { "toggle": "テーマの切り替え", diff --git a/locales/ko.json b/locales/ko.json index 5819af65..ddd44127 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -223,7 +223,11 @@ "noCreditRequired": "크레딧 표기 없음", "allowSellingGeneratedContent": "판매 허용", "noTags": "태그 없음", - "clearAll": "모든 필터 지우기" + "clearAll": "모든 필터 지우기", + "any": "아무", + "all": "모두", + "tagLogicAny": "모든 태그 일치 (OR)", + "tagLogicAll": "모든 태그 일치 (AND)" }, "theme": { "toggle": "테마 토글", diff --git a/locales/ru.json b/locales/ru.json index 1ce928cf..c810423d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -223,7 +223,11 @@ "noCreditRequired": "Без указания авторства", "allowSellingGeneratedContent": "Продажа разрешена", "noTags": "Без тегов", - "clearAll": "Очистить все фильтры" + "clearAll": "Очистить все фильтры", + "any": "Любой", + "all": "Все", + "tagLogicAny": "Совпадение с любым тегом (ИЛИ)", + "tagLogicAll": "Совпадение со всеми тегами (И)" }, "theme": { "toggle": "Переключить тему", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 6d9aab11..a3cb1c86 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -223,7 +223,11 @@ "noCreditRequired": "无需署名", "allowSellingGeneratedContent": "允许销售", "noTags": "无标签", - "clearAll": "清除所有筛选" + "clearAll": "清除所有筛选", + "any": "任一", + "all": "全部", + "tagLogicAny": "匹配任一标签 (或)", + "tagLogicAll": "匹配所有标签 (与)" }, "theme": { "toggle": "切换主题", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 4ecaf4bd..b9c662fc 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -223,7 +223,11 @@ "noCreditRequired": "無需署名", "allowSellingGeneratedContent": "允許銷售", "noTags": "無標籤", - "clearAll": "清除所有篩選" + "clearAll": "清除所有篩選", + "any": "任一", + "all": "全部", + "tagLogicAny": "符合任一票籤 (或)", + "tagLogicAll": "符合所有標籤 (與)" }, "theme": { "toggle": "切換主題", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 62aae575..7cb9cd6e 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -270,6 +270,11 @@ class ModelListingHandler: request.query.get("update_available_only", "false").lower() == "true" ) + # Tag logic: "any" (OR) or "all" (AND) for include tags + tag_logic = request.query.get("tag_logic", "any").lower() + if tag_logic not in ("any", "all"): + tag_logic = "any" + # New license-based query filters credit_required = request.query.get("credit_required") if credit_required is not None: @@ -298,6 +303,7 @@ class ModelListingHandler: "fuzzy_search": fuzzy_search, "base_models": base_models, "tags": tag_filters, + "tag_logic": tag_logic, "search_options": search_options, "hash_filters": hash_filters, "favorites_only": favorites_only, diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 7faab2d2..fbd9a6f2 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -81,6 +81,7 @@ class BaseModelService(ABC): update_available_only: bool = False, credit_required: Optional[bool] = None, allow_selling_generated_content: Optional[bool] = None, + tag_logic: str = "any", **kwargs, ) -> Dict: """Get paginated and filtered model data""" @@ -109,6 +110,7 @@ class BaseModelService(ABC): tags=tags, favorites_only=favorites_only, search_options=search_options, + tag_logic=tag_logic, ) if search: @@ -241,6 +243,7 @@ class BaseModelService(ABC): tags: Optional[Dict[str, str]] = None, favorites_only: bool = False, search_options: dict = None, + tag_logic: str = "any", ) -> List[Dict]: """Apply common filters that work across all model types""" normalized_options = self.search_strategy.normalize_options(search_options) @@ -253,6 +256,7 @@ class BaseModelService(ABC): tags=tags, favorites_only=favorites_only, search_options=normalized_options, + tag_logic=tag_logic, ) return self.filter_set.apply(data, criteria) diff --git a/py/services/model_query.py b/py/services/model_query.py index 4666c5e6..0cc91c40 100644 --- a/py/services/model_query.py +++ b/py/services/model_query.py @@ -99,6 +99,7 @@ class FilterCriteria: favorites_only: bool = False search_options: Optional[Dict[str, Any]] = None model_types: Optional[Sequence[str]] = None + tag_logic: str = "any" # "any" (OR) or "all" (AND) class ModelCacheRepository: @@ -300,11 +301,29 @@ class ModelFilterSet: include_tags = {tag for tag in tag_filters if tag} if include_tags: + tag_logic = criteria.tag_logic.lower() if criteria.tag_logic else "any" def matches_include(item_tags): if not item_tags and "__no_tags__" in include_tags: return True - return any(tag in include_tags for tag in (item_tags or [])) + if tag_logic == "all": + # AND logic: item must have ALL include tags + # Special case: __no_tags__ is handled separately + non_special_tags = include_tags - {"__no_tags__"} + if "__no_tags__" in include_tags: + # If __no_tags__ is selected along with other tags, + # treat it as "no tags OR (all other tags)" + if not item_tags: + return True + # Otherwise, check if all non-special tags match + if non_special_tags: + return all(tag in (item_tags or []) for tag in non_special_tags) + return True + # Normal case: all tags must match + return all(tag in (item_tags or []) for tag in non_special_tags) + else: + # OR logic (default): item must have ANY include tag + return any(tag in include_tags for tag in (item_tags or [])) items = [item for item in items if matches_include(item.get("tags"))] diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index d1671283..b5ddee4a 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -673,6 +673,57 @@ +/* Tag Logic Toggle Styles */ +.filter-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.filter-section-header h4 { + margin: 0; +} + +.tag-logic-toggle { + display: flex; + background-color: var(--lora-surface); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + overflow: hidden; +} + +.tag-logic-option { + background: none; + border: none; + padding: 2px 8px; + font-size: 11px; + cursor: pointer; + color: var(--text-color); + opacity: 0.7; + transition: all 0.2s ease; + font-weight: 500; +} + +.tag-logic-option:hover { + opacity: 1; + background-color: var(--lora-surface-hover); +} + +.tag-logic-option.active { + background-color: var(--lora-accent); + color: white; + opacity: 1; +} + +.tag-logic-option:first-child { + border-right: 1px solid var(--border-color); +} + +.tag-logic-option.active:first-child { + border-right: 1px solid rgba(255, 255, 255, 0.3); +} + /* Mobile adjustments */ @media (max-width: 768px) { .search-options-panel, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 9e2a9d09..4d144f6a 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -924,6 +924,11 @@ export class BaseModelApiClient { params.append('model_type', type); }); } + + // Add tag logic parameter (any = OR, all = AND) + if (pageState.filters.tagLogic) { + params.append('tag_logic', pageState.filters.tagLogic); + } } this._addModelSpecificParams(params, pageState); diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 3cf61233..1b62eab1 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -63,6 +63,9 @@ export class FilterManager { this.initializeLicenseFilters(); } + // Initialize tag logic toggle + this.initializeTagLogicToggle(); + // Add click handler for filter button if (this.filterButton) { this.filterButton.addEventListener('click', () => { @@ -84,6 +87,45 @@ export class FilterManager { this.loadFiltersFromStorage(); } + initializeTagLogicToggle() { + const toggleContainer = document.getElementById('tagLogicToggle'); + if (!toggleContainer) return; + + const options = toggleContainer.querySelectorAll('.tag-logic-option'); + + options.forEach(option => { + option.addEventListener('click', async () => { + const value = option.dataset.value; + if (this.filters.tagLogic === value) return; + + this.filters.tagLogic = value; + this.updateTagLogicToggleUI(); + + // Auto-apply filter when logic changes + await this.applyFilters(false); + }); + }); + + // Set initial state + this.updateTagLogicToggleUI(); + } + + updateTagLogicToggleUI() { + const toggleContainer = document.getElementById('tagLogicToggle'); + if (!toggleContainer) return; + + const options = toggleContainer.querySelectorAll('.tag-logic-option'); + const currentLogic = this.filters.tagLogic || 'any'; + + options.forEach(option => { + if (option.dataset.value === currentLogic) { + option.classList.add('active'); + } else { + option.classList.remove('active'); + } + }); + } + async loadTopTags() { try { // Show loading state @@ -573,9 +615,13 @@ export class FilterManager { baseModel: [], tags: {}, license: {}, - modelTypes: [] + modelTypes: [], + tagLogic: 'any' }); + // Update tag logic toggle UI + this.updateTagLogicToggleUI(); + // Update state const pageState = getCurrentPageState(); pageState.filters = this.cloneFilters(); @@ -620,6 +666,7 @@ export class FilterManager { pageState.filters = this.cloneFilters(); this.updateTagSelections(); + this.updateTagLogicToggleUI(); this.updateActiveFiltersCount(); if (this.hasActiveFilters()) { @@ -655,7 +702,8 @@ export class FilterManager { baseModel: Array.isArray(source.baseModel) ? [...source.baseModel] : [], tags: this.normalizeTagFilters(source.tags), license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {}, - modelTypes: this.normalizeModelTypeFilters(source.modelTypes) + modelTypes: this.normalizeModelTypeFilters(source.modelTypes), + tagLogic: source.tagLogic || 'any' }; } @@ -737,7 +785,8 @@ export class FilterManager { baseModel: [...(this.filters.baseModel || [])], tags: { ...(this.filters.tags || {}) }, license: { ...(this.filters.license || {}) }, - modelTypes: [...(this.filters.modelTypes || [])] + modelTypes: [...(this.filters.modelTypes || [])], + tagLogic: this.filters.tagLogic || 'any' }; } diff --git a/templates/components/header.html b/templates/components/header.html index b980c924..b5494b70 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -150,7 +150,13 @@