diff --git a/locales/de.json b/locales/de.json index 03e74c70..003d85e6 100644 --- a/locales/de.json +++ b/locales/de.json @@ -200,6 +200,7 @@ "license": "Lizenz", "noCreditRequired": "Kein Credit erforderlich", "allowSellingGeneratedContent": "Verkauf erlaubt", + "noTags": "Keine Tags", "clearAll": "Alle Filter löschen" }, "theme": { diff --git a/locales/en.json b/locales/en.json index 13bd170f..8df93044 100644 --- a/locales/en.json +++ b/locales/en.json @@ -200,6 +200,7 @@ "license": "License", "noCreditRequired": "No Credit Required", "allowSellingGeneratedContent": "Allow Selling", + "noTags": "No tags", "clearAll": "Clear All Filters" }, "theme": { diff --git a/locales/es.json b/locales/es.json index f5da4abf..9ad9eb66 100644 --- a/locales/es.json +++ b/locales/es.json @@ -200,6 +200,7 @@ "license": "Licencia", "noCreditRequired": "Sin crédito requerido", "allowSellingGeneratedContent": "Venta permitida", + "noTags": "Sin etiquetas", "clearAll": "Limpiar todos los filtros" }, "theme": { diff --git a/locales/fr.json b/locales/fr.json index 54cac2d3..5488742f 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -200,6 +200,7 @@ "license": "Licence", "noCreditRequired": "Crédit non requis", "allowSellingGeneratedContent": "Vente autorisée", + "noTags": "Aucun tag", "clearAll": "Effacer tous les filtres" }, "theme": { diff --git a/locales/he.json b/locales/he.json index 41402dfc..8a000812 100644 --- a/locales/he.json +++ b/locales/he.json @@ -200,6 +200,7 @@ "license": "רישיון", "noCreditRequired": "ללא קרדיט נדרש", "allowSellingGeneratedContent": "אפשר מכירה", + "noTags": "ללא תגיות", "clearAll": "נקה את כל המסננים" }, "theme": { diff --git a/locales/ja.json b/locales/ja.json index 8fcd070d..961f3c9c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -200,6 +200,7 @@ "license": "ライセンス", "noCreditRequired": "クレジット不要", "allowSellingGeneratedContent": "販売許可", + "noTags": "タグなし", "clearAll": "すべてのフィルタをクリア" }, "theme": { diff --git a/locales/ko.json b/locales/ko.json index 9cdd36fb..5d674689 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -200,6 +200,7 @@ "license": "라이선스", "noCreditRequired": "크레딧 표기 없음", "allowSellingGeneratedContent": "판매 허용", + "noTags": "태그 없음", "clearAll": "모든 필터 지우기" }, "theme": { diff --git a/locales/ru.json b/locales/ru.json index 58e7ef0e..4eff37ef 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -200,6 +200,7 @@ "license": "Лицензия", "noCreditRequired": "Без указания авторства", "allowSellingGeneratedContent": "Продажа разрешена", + "noTags": "Без тегов", "clearAll": "Очистить все фильтры" }, "theme": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 11f5c226..53a16b16 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -200,6 +200,7 @@ "license": "许可证", "noCreditRequired": "无需署名", "allowSellingGeneratedContent": "允许销售", + "noTags": "无标签", "clearAll": "清除所有筛选" }, "theme": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index de95602f..3a8375df 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -200,6 +200,7 @@ "license": "授權", "noCreditRequired": "無需署名", "allowSellingGeneratedContent": "允許銷售", + "noTags": "無標籤", "clearAll": "清除所有篩選" }, "theme": { diff --git a/py/services/model_query.py b/py/services/model_query.py index 5b370138..c5dfc59b 100644 --- a/py/services/model_query.py +++ b/py/services/model_query.py @@ -161,15 +161,25 @@ class ModelFilterSet: include_tags = {tag for tag in tag_filters if tag} if include_tags: + 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 [])) + items = [ item for item in items - if any(tag in include_tags for tag in (item.get("tags", []) or [])) + if matches_include(item.get("tags")) ] if exclude_tags: + def matches_exclude(item_tags): + if not item_tags and "__no_tags__" in exclude_tags: + return True + return any(tag in exclude_tags for tag in (item_tags or [])) + items = [ item for item in items - if not any(tag in exclude_tags for tag in (item.get("tags", []) or [])) + if not matches_exclude(item.get("tags")) ] model_types = criteria.model_types or [] diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index c8f1c078..d5afa324 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -1162,15 +1162,25 @@ class RecipeScanner: include_tags = {tag for tag in tag_spec if tag} if include_tags: + 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 [])) + filtered_data = [ item for item in filtered_data - if any(tag in include_tags for tag in (item.get('tags', []) or [])) + if matches_include(item.get('tags')) ] if exclude_tags: + def matches_exclude(item_tags): + if not item_tags and "__no_tags__" in exclude_tags: + return True + return any(tag in exclude_tags for tag in (item_tags or [])) + filtered_data = [ item for item in filtered_data - if not any(tag in exclude_tags for tag in (item.get('tags', []) or [])) + if not matches_exclude(item.get('tags')) ] diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index 8feb9709..920a868e 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -242,6 +242,20 @@ border-color: var(--lora-error-border); } +/* Subtle styling for special system tags like "No tags" */ +.filter-tag.special-tag { + border-style: dashed; + opacity: 0.8; + font-style: italic; +} + +/* Ensure solid border and full opacity when active or excluded */ +.filter-tag.special-tag.active, +.filter-tag.special-tag.exclude { + border-style: solid; + opacity: 1; +} + /* Tag filter styles */ .tag-filter { display: flex; diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 8d28fb31..17de20f9 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -3,32 +3,33 @@ import { showToast, updatePanelPositions } from '../utils/uiHelpers.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js'; +import { translate } from '../utils/i18nHelpers.js'; export class FilterManager { constructor(options = {}) { this.options = { ...options }; - + this.currentPage = options.page || document.body.dataset.page || 'loras'; const pageState = getCurrentPageState(); - + this.filters = this.initializeFilters(pageState ? pageState.filters : undefined); - + this.filterPanel = document.getElementById('filterPanel'); this.filterButton = document.getElementById('filterButton'); this.activeFiltersCount = document.getElementById('activeFiltersCount'); this.tagsLoaded = false; - + this.initialize(); - + // Store this instance in the state if (pageState) { pageState.filterManager = this; pageState.filters = this.cloneFilters(); } } - + initialize() { // Create base model filter tags if they exist if (document.getElementById('baseModelTags')) { @@ -50,39 +51,39 @@ export class FilterManager { this.toggleFilterPanel(); }); } - + // Close filter panel when clicking outside document.addEventListener('click', (e) => { - if (this.filterPanel && !this.filterPanel.contains(e.target) && + if (this.filterPanel && !this.filterPanel.contains(e.target) && e.target !== this.filterButton && !this.filterButton.contains(e.target) && !this.filterPanel.classList.contains('hidden')) { this.closeFilterPanel(); } }); - + // Initialize active filters from localStorage if available this.loadFiltersFromStorage(); } - + async loadTopTags() { try { // Show loading state const tagsContainer = document.getElementById('modelTagsFilter'); if (!tagsContainer) return; - + tagsContainer.innerHTML = '
'; - + // Determine the API endpoint based on the page type const tagsEndpoint = `/api/lm/${this.currentPage}/top-tags?limit=20`; const response = await fetch(tagsEndpoint); if (!response.ok) throw new Error('Failed to fetch tags'); - + const data = await response.json(); if (data.success && data.tags) { this.createTagFilterElements(data.tags); - + // After creating tag elements, mark any previously selected ones this.updateTagSelections(); } else { @@ -96,57 +97,79 @@ export class FilterManager { } } } - + createTagFilterElements(tags) { const tagsContainer = document.getElementById('modelTagsFilter'); if (!tagsContainer) return; - + tagsContainer.innerHTML = ''; - + if (!tags.length) { tagsContainer.innerHTML = ``; return; } - + tags.forEach(tag => { const tagEl = document.createElement('div'); tagEl.className = 'filter-tag tag-filter'; const tagName = tag.tag; tagEl.dataset.tag = tagName; tagEl.innerHTML = `${tagName} ${tag.count}`; - + // Add click handler to cycle through tri-state filter and automatically apply tagEl.addEventListener('click', async () => { const currentState = (this.filters.tags && this.filters.tags[tagName]) || 'none'; const newState = this.getNextTriStateState(currentState); this.setTagFilterState(tagName, newState); this.applyTagElementState(tagEl, newState); - + this.updateActiveFiltersCount(); - + // Auto-apply filter when tag is clicked await this.applyFilters(false); }); - + this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none'); 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; + + 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(); + + await this.applyFilters(false); + }); + + this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none'); + tagsContainer.appendChild(noTagsEl); } - + initializeLicenseFilters() { const licenseTags = document.querySelectorAll('.license-tag'); licenseTags.forEach(tag => { tag.addEventListener('click', async () => { const licenseType = tag.dataset.license; - + // Ensure license object exists if (!this.filters.license) { this.filters.license = {}; } - + // Get current state let currentState = this.filters.license[licenseType] || 'none'; // none, include, exclude - + // Cycle through states: none -> include -> exclude -> none let newState; switch (currentState) { @@ -165,7 +188,7 @@ export class FilterManager { tag.classList.remove('active', 'exclude'); break; } - + // Update filter state if (newState === 'none') { delete this.filters.license[licenseType]; @@ -176,27 +199,27 @@ export class FilterManager { } else { this.filters.license[licenseType] = newState; } - + this.updateActiveFiltersCount(); - + // Auto-apply filter when tag is clicked await this.applyFilters(false); }); }); - + // Update selections based on stored filters this.updateLicenseSelections(); } - + updateLicenseSelections() { const licenseTags = document.querySelectorAll('.license-tag'); licenseTags.forEach(tag => { const licenseType = tag.dataset.license; const state = (this.filters.license && this.filters.license[licenseType]) || 'none'; - + // Reset classes tag.classList.remove('active', 'exclude'); - + // Apply appropriate class based on state switch (state) { case 'include': @@ -211,31 +234,31 @@ export class FilterManager { } }); } - + createBaseModelTags() { const baseModelTagsContainer = document.getElementById('baseModelTags'); if (!baseModelTagsContainer) return; - + // Set the API endpoint based on current page const apiEndpoint = `/api/lm/${this.currentPage}/base-models`; - + // Fetch base models fetch(apiEndpoint) .then(response => response.json()) .then(data => { if (data.success && data.base_models) { baseModelTagsContainer.innerHTML = ''; - + data.base_models.forEach(model => { const tag = document.createElement('div'); tag.className = `filter-tag base-model-tag`; tag.dataset.baseModel = model.name; tag.innerHTML = `${model.name} ${model.count}`; - + // Add click handler to toggle selection and automatically apply tag.addEventListener('click', async () => { tag.classList.toggle('active'); - + if (tag.classList.contains('active')) { if (!this.filters.baseModel.includes(model.name)) { this.filters.baseModel.push(model.name); @@ -243,24 +266,24 @@ export class FilterManager { } else { this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name); } - + this.updateActiveFiltersCount(); - + // Auto-apply filter when tag is clicked await this.applyFilters(false); }); - + baseModelTagsContainer.appendChild(tag); }); - + // Update selections based on stored filters this.updateTagSelections(); - } - }) - .catch(error => { - console.error(`Error fetching base models for ${this.currentPage}:`, error); - baseModelTagsContainer.innerHTML = ''; - }); + } + }) + .catch(error => { + console.error(`Error fetching base models for ${this.currentPage}:`, error); + baseModelTagsContainer.innerHTML = ''; + }); } async createModelTypeTags() { @@ -336,18 +359,18 @@ export class FilterManager { modelTypeContainer.innerHTML = ''; } } - - toggleFilterPanel() { + + toggleFilterPanel() { if (this.filterPanel) { const isHidden = this.filterPanel.classList.contains('hidden'); - + if (isHidden) { // Update panel positions before showing updatePanelPositions(); - + this.filterPanel.classList.remove('hidden'); this.filterButton.classList.add('active'); - + // Load tags if they haven't been loaded yet if (!this.tagsLoaded) { this.loadTopTags(); @@ -358,7 +381,7 @@ export class FilterManager { } } } - + closeFilterPanel() { if (this.filterPanel) { this.filterPanel.classList.add('hidden'); @@ -367,7 +390,7 @@ export class FilterManager { this.filterButton.classList.remove('active'); } } - + updateTagSelections() { // Update base model tags const baseModelTags = document.querySelectorAll('.base-model-tag'); @@ -379,7 +402,7 @@ export class FilterManager { tag.classList.remove('active'); } }); - + // Update model tags const modelTags = document.querySelectorAll('.tag-filter'); modelTags.forEach(tag => { @@ -387,7 +410,7 @@ export class FilterManager { const state = (this.filters.tags && this.filters.tags[tagName]) || 'none'; this.applyTagElementState(tag, state); }); - + // Update license tags if visible on this page if (this.shouldShowLicenseFilters()) { this.updateLicenseSelections(); @@ -406,13 +429,13 @@ export class FilterManager { } }); } - + updateActiveFiltersCount() { const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0; const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0; const modelTypeFilterCount = this.filters.modelTypes.length; const totalActiveFilters = this.filters.baseModel.length + tagFilterCount + licenseFilterCount + modelTypeFilterCount; - + if (this.activeFiltersCount) { if (totalActiveFilters > 0) { this.activeFiltersCount.textContent = totalActiveFilters; @@ -422,18 +445,18 @@ export class FilterManager { } } } - + async applyFilters(showToastNotification = true) { const pageState = getCurrentPageState(); const storageKey = `${this.currentPage}_filters`; - + // Save filters to localStorage const filtersSnapshot = this.cloneFilters(); setStorageItem(storageKey, filtersSnapshot); - + // Update state with current filters pageState.filters = filtersSnapshot; - + // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); @@ -441,14 +464,14 @@ export class FilterManager { // For models page, reset the page and reload await getModelApiClient().loadMoreWithVirtualScroll(true, false); } - + // Update filter button to show active state if (this.hasActiveFilters()) { this.filterButton.classList.add('active'); if (showToastNotification) { const baseModelCount = this.filters.baseModel.length; const tagsCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0; - + let message = ''; if (baseModelCount > 0 && tagsCount > 0) { message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`; @@ -457,7 +480,7 @@ export class FilterManager { } else if (tagsCount > 0) { message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`; } - + showToast('toast.filters.applied', { message }, 'success'); } } else { @@ -467,7 +490,7 @@ export class FilterManager { } } } - + async clearFilters() { // Clear all filters this.filters = this.initializeFilters({ @@ -477,52 +500,52 @@ export class FilterManager { license: {}, modelTypes: [] }); - + // Update state const pageState = getCurrentPageState(); pageState.filters = this.cloneFilters(); - + // Update UI this.updateTagSelections(); this.updateActiveFiltersCount(); - + // Remove from local Storage const storageKey = `${this.currentPage}_filters`; removeStorageItem(storageKey); - + // Update UI if (this.hasActiveFilters()) { this.filterButton.classList.add('active'); } else { this.filterButton.classList.remove('active'); } - + // Reload data using the appropriate method for the current page if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { await getModelApiClient().loadMoreWithVirtualScroll(true, true); } - + showToast('toast.filters.cleared', {}, 'info'); } - + loadFiltersFromStorage() { const storageKey = `${this.currentPage}_filters`; const savedFilters = getStorageItem(storageKey); - + if (savedFilters) { try { // Ensure backward compatibility with older filter format this.filters = this.initializeFilters(savedFilters); - + // Update state with loaded filters const pageState = getCurrentPageState(); pageState.filters = this.cloneFilters(); this.updateTagSelections(); this.updateActiveFiltersCount(); - + if (this.hasActiveFilters()) { this.filterButton.classList.add('active'); } @@ -531,7 +554,7 @@ export class FilterManager { } } } - + hasActiveFilters() { const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0; const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0; diff --git a/tests/services/test_no_tags_filter.py b/tests/services/test_no_tags_filter.py new file mode 100644 index 00000000..473750f2 --- /dev/null +++ b/tests/services/test_no_tags_filter.py @@ -0,0 +1,104 @@ +import pytest +from py.services.model_query import ModelFilterSet, FilterCriteria +from py.services.recipe_scanner import RecipeScanner +from pathlib import Path +from py.config import config +import asyncio +from types import SimpleNamespace + +class StubSettings: + def get(self, key, default=None): + return default + +# --- Model Filtering Tests --- + +def test_model_filter_set_no_tags_include(): + filter_set = ModelFilterSet(StubSettings()) + data = [ + {"name": "m1", "tags": ["tag1"]}, + {"name": "m2", "tags": []}, + {"name": "m3", "tags": None}, + {"name": "m4", "tags": ["tag2"]}, + ] + + # Include __no_tags__ + criteria = FilterCriteria(tags={"__no_tags__": "include"}) + result = filter_set.apply(data, criteria) + assert len(result) == 2 + assert {item["name"] for item in result} == {"m2", "m3"} + +def test_model_filter_set_no_tags_exclude(): + filter_set = ModelFilterSet(StubSettings()) + data = [ + {"name": "m1", "tags": ["tag1"]}, + {"name": "m2", "tags": []}, + {"name": "m3", "tags": None}, + {"name": "m4", "tags": ["tag2"]}, + ] + + # Exclude __no_tags__ + criteria = FilterCriteria(tags={"__no_tags__": "exclude"}) + result = filter_set.apply(data, criteria) + assert len(result) == 2 + assert {item["name"] for item in result} == {"m1", "m4"} + +def test_model_filter_set_no_tags_mixed(): + filter_set = ModelFilterSet(StubSettings()) + data = [ + {"name": "m1", "tags": ["tag1"]}, + {"name": "m2", "tags": []}, + {"name": "m3", "tags": None}, + {"name": "m4", "tags": ["tag1", "tag2"]}, + ] + + # Include tag1 AND __no_tags__ + criteria = FilterCriteria(tags={"tag1": "include", "__no_tags__": "include"}) + result = filter_set.apply(data, criteria) + # m1 (tag1), m2 (no tags), m3 (no tags), m4 (tag1) + assert len(result) == 4 + +# --- Recipe Filtering Tests --- + +class StubLoraScanner: + def __init__(self): + self._cache = SimpleNamespace(raw_data=[], version_index={}) + async def get_cached_data(self): + return self._cache + async def refresh_cache(self, force=False): + pass + +@pytest.fixture +def recipe_scanner(tmp_path, monkeypatch): + monkeypatch.setattr(config, "loras_roots", [str(tmp_path)]) + stub = StubLoraScanner() + scanner = RecipeScanner(lora_scanner=stub) + return scanner + +@pytest.mark.asyncio +async def test_recipe_scanner_no_tags_filter(recipe_scanner): + scanner = recipe_scanner + + # Mock some recipe data + recipes = [ + {"id": "r1", "tags": ["tag1"], "title": "R1"}, + {"id": "r2", "tags": [], "title": "R2"}, + {"id": "r3", "tags": None, "title": "R3"}, + ] + + # We need to inject these into the scanner's cache + # Since get_paginated_data calls get_cached_data() which we stubbed + scanner._cache = SimpleNamespace( + raw_data=recipes, + sorted_by_date=recipes, + sorted_by_name=recipes + ) + + # Test Include __no_tags__ + result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "include"}}) + assert len(result["items"]) == 2 + assert {item["id"] for item in result["items"]} == {"r2", "r3"} + + # Test Exclude __no_tags__ + result = await scanner.get_paginated_data(page=1, page_size=10, filters={"tags": {"__no_tags__": "exclude"}}) + assert len(result["items"]) == 1 + assert result["items"][0]["id"] == "r1"