feat: Introduce "No tags" filter option for models and recipes. fixes #728

This commit is contained in:
Will Miao
2025-12-23 18:48:35 +08:00
parent 39195aa529
commit 00e6904664
15 changed files with 254 additions and 83 deletions

View File

@@ -200,6 +200,7 @@
"license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags",
"clearAll": "Alle Filter löschen"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "License",
"noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags",
"clearAll": "Clear All Filters"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "Licencia",
"noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas",
"clearAll": "Limpiar todos los filtros"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "Licence",
"noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag",
"clearAll": "Effacer tous les filtres"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות",
"clearAll": "נקה את כל המסננים"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "ライセンス",
"noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし",
"clearAll": "すべてのフィルタをクリア"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "라이선스",
"noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음",
"clearAll": "모든 필터 지우기"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "Лицензия",
"noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов",
"clearAll": "Очистить все фильтры"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "许可证",
"noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售",
"noTags": "无标签",
"clearAll": "清除所有筛选"
},
"theme": {

View File

@@ -200,6 +200,7 @@
"license": "授權",
"noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤",
"clearAll": "清除所有篩選"
},
"theme": {

View File

@@ -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 []

View File

@@ -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'))
]

View File

@@ -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;

View File

@@ -3,6 +3,7 @@ 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 = {}) {
@@ -131,6 +132,28 @@ export class FilterManager {
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() {
@@ -255,12 +278,12 @@ export class FilterManager {
// Update selections based on stored filters
this.updateTagSelections();
}
})
.catch(error => {
console.error(`Error fetching base models for ${this.currentPage}:`, error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
}
})
.catch(error => {
console.error(`Error fetching base models for ${this.currentPage}:`, error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
}
async createModelTypeTags() {

View File

@@ -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"