mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -03:00
feat: Introduce "No tags" filter option for models and recipes. fixes #728
This commit is contained in:
@@ -200,6 +200,7 @@
|
|||||||
"license": "Lizenz",
|
"license": "Lizenz",
|
||||||
"noCreditRequired": "Kein Credit erforderlich",
|
"noCreditRequired": "Kein Credit erforderlich",
|
||||||
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
"allowSellingGeneratedContent": "Verkauf erlaubt",
|
||||||
|
"noTags": "Keine Tags",
|
||||||
"clearAll": "Alle Filter löschen"
|
"clearAll": "Alle Filter löschen"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "License",
|
"license": "License",
|
||||||
"noCreditRequired": "No Credit Required",
|
"noCreditRequired": "No Credit Required",
|
||||||
"allowSellingGeneratedContent": "Allow Selling",
|
"allowSellingGeneratedContent": "Allow Selling",
|
||||||
|
"noTags": "No tags",
|
||||||
"clearAll": "Clear All Filters"
|
"clearAll": "Clear All Filters"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "Licencia",
|
"license": "Licencia",
|
||||||
"noCreditRequired": "Sin crédito requerido",
|
"noCreditRequired": "Sin crédito requerido",
|
||||||
"allowSellingGeneratedContent": "Venta permitida",
|
"allowSellingGeneratedContent": "Venta permitida",
|
||||||
|
"noTags": "Sin etiquetas",
|
||||||
"clearAll": "Limpiar todos los filtros"
|
"clearAll": "Limpiar todos los filtros"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "Licence",
|
"license": "Licence",
|
||||||
"noCreditRequired": "Crédit non requis",
|
"noCreditRequired": "Crédit non requis",
|
||||||
"allowSellingGeneratedContent": "Vente autorisée",
|
"allowSellingGeneratedContent": "Vente autorisée",
|
||||||
|
"noTags": "Aucun tag",
|
||||||
"clearAll": "Effacer tous les filtres"
|
"clearAll": "Effacer tous les filtres"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "רישיון",
|
"license": "רישיון",
|
||||||
"noCreditRequired": "ללא קרדיט נדרש",
|
"noCreditRequired": "ללא קרדיט נדרש",
|
||||||
"allowSellingGeneratedContent": "אפשר מכירה",
|
"allowSellingGeneratedContent": "אפשר מכירה",
|
||||||
|
"noTags": "ללא תגיות",
|
||||||
"clearAll": "נקה את כל המסננים"
|
"clearAll": "נקה את כל המסננים"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "ライセンス",
|
"license": "ライセンス",
|
||||||
"noCreditRequired": "クレジット不要",
|
"noCreditRequired": "クレジット不要",
|
||||||
"allowSellingGeneratedContent": "販売許可",
|
"allowSellingGeneratedContent": "販売許可",
|
||||||
|
"noTags": "タグなし",
|
||||||
"clearAll": "すべてのフィルタをクリア"
|
"clearAll": "すべてのフィルタをクリア"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "라이선스",
|
"license": "라이선스",
|
||||||
"noCreditRequired": "크레딧 표기 없음",
|
"noCreditRequired": "크레딧 표기 없음",
|
||||||
"allowSellingGeneratedContent": "판매 허용",
|
"allowSellingGeneratedContent": "판매 허용",
|
||||||
|
"noTags": "태그 없음",
|
||||||
"clearAll": "모든 필터 지우기"
|
"clearAll": "모든 필터 지우기"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "Лицензия",
|
"license": "Лицензия",
|
||||||
"noCreditRequired": "Без указания авторства",
|
"noCreditRequired": "Без указания авторства",
|
||||||
"allowSellingGeneratedContent": "Продажа разрешена",
|
"allowSellingGeneratedContent": "Продажа разрешена",
|
||||||
|
"noTags": "Без тегов",
|
||||||
"clearAll": "Очистить все фильтры"
|
"clearAll": "Очистить все фильтры"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "许可证",
|
"license": "许可证",
|
||||||
"noCreditRequired": "无需署名",
|
"noCreditRequired": "无需署名",
|
||||||
"allowSellingGeneratedContent": "允许销售",
|
"allowSellingGeneratedContent": "允许销售",
|
||||||
|
"noTags": "无标签",
|
||||||
"clearAll": "清除所有筛选"
|
"clearAll": "清除所有筛选"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@
|
|||||||
"license": "授權",
|
"license": "授權",
|
||||||
"noCreditRequired": "無需署名",
|
"noCreditRequired": "無需署名",
|
||||||
"allowSellingGeneratedContent": "允許銷售",
|
"allowSellingGeneratedContent": "允許銷售",
|
||||||
|
"noTags": "無標籤",
|
||||||
"clearAll": "清除所有篩選"
|
"clearAll": "清除所有篩選"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
|
|||||||
@@ -161,15 +161,25 @@ class ModelFilterSet:
|
|||||||
include_tags = {tag for tag in tag_filters if tag}
|
include_tags = {tag for tag in tag_filters if tag}
|
||||||
|
|
||||||
if include_tags:
|
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 = [
|
items = [
|
||||||
item for item in 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:
|
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 = [
|
items = [
|
||||||
item for item in 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 []
|
model_types = criteria.model_types or []
|
||||||
|
|||||||
@@ -1162,15 +1162,25 @@ class RecipeScanner:
|
|||||||
include_tags = {tag for tag in tag_spec if tag}
|
include_tags = {tag for tag in tag_spec if tag}
|
||||||
|
|
||||||
if include_tags:
|
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 = [
|
filtered_data = [
|
||||||
item for item in 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:
|
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 = [
|
filtered_data = [
|
||||||
item for item in 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'))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,20 @@
|
|||||||
border-color: var(--lora-error-border);
|
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 styles */
|
||||||
.tag-filter {
|
.tag-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
|||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.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';
|
||||||
|
|
||||||
export class FilterManager {
|
export class FilterManager {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
@@ -131,6 +132,28 @@ export class FilterManager {
|
|||||||
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
|
||||||
tagsContainer.appendChild(tagEl);
|
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() {
|
initializeLicenseFilters() {
|
||||||
@@ -255,12 +278,12 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Update selections based on stored filters
|
// Update selections based on stored filters
|
||||||
this.updateTagSelections();
|
this.updateTagSelections();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
console.error(`Error fetching base models for ${this.currentPage}:`, error);
|
||||||
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async createModelTypeTags() {
|
async createModelTypeTags() {
|
||||||
|
|||||||
104
tests/services/test_no_tags_filter.py
Normal file
104
tests/services/test_no_tags_filter.py
Normal 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"
|
||||||
Reference in New Issue
Block a user