mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): add Unknown base-model filter bucket for undetermined recipes
Normalize undetermined recipe base_model to None in RecipeFormatParser (previously ''). get_base_models now reports an "Unknown" bucket backed by a dedicated __unknown__ marker, and the listing filter matches it against recipes whose base model is falsy. Frontend renders the bucket label as "Unknown" while filtering via the marker. Tests: handler, scanner, parser, and frontend filtering.
This commit is contained in:
@@ -196,7 +196,7 @@ class RecipeFormatParser(RecipeMetadataParser):
|
|||||||
filtered_gen_params[key] = value
|
filtered_gen_params[key] = value
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
|
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else (recipe_metadata.get('base_model') or None),
|
||||||
'loras': loras,
|
'loras': loras,
|
||||||
'gen_params': filtered_gen_params,
|
'gen_params': filtered_gen_params,
|
||||||
'tags': recipe_metadata.get('tags', []),
|
'tags': recipe_metadata.get('tags', []),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from ...services.recipes import (
|
|||||||
RecipeValidationError,
|
RecipeValidationError,
|
||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
|
from ...services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
|
||||||
from ...utils.civitai_utils import (
|
from ...utils.civitai_utils import (
|
||||||
build_civitai_image_page_url,
|
build_civitai_image_page_url,
|
||||||
extract_civitai_image_id,
|
extract_civitai_image_id,
|
||||||
@@ -473,17 +474,32 @@ class RecipeQueryHandler:
|
|||||||
cache = await recipe_scanner.get_cached_data()
|
cache = await recipe_scanner.get_cached_data()
|
||||||
|
|
||||||
base_model_counts: Dict[str, int] = {}
|
base_model_counts: Dict[str, int] = {}
|
||||||
|
unknown_count = 0
|
||||||
for recipe in getattr(cache, "raw_data", []):
|
for recipe in getattr(cache, "raw_data", []):
|
||||||
base_model = recipe.get("base_model")
|
base_model = recipe.get("base_model")
|
||||||
if base_model:
|
if base_model:
|
||||||
base_model_counts[base_model] = (
|
base_model_counts[base_model] = (
|
||||||
base_model_counts.get(base_model, 0) + 1
|
base_model_counts.get(base_model, 0) + 1
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
unknown_count += 1
|
||||||
|
|
||||||
sorted_models = [
|
sorted_models = [
|
||||||
{"name": model, "count": count}
|
{"name": model, "count": count}
|
||||||
for model, count in base_model_counts.items()
|
for model, count in base_model_counts.items()
|
||||||
]
|
]
|
||||||
|
if unknown_count:
|
||||||
|
# Synthetic "Unknown" bucket for recipes whose base model could
|
||||||
|
# not be determined. `value` carries the filter marker so the
|
||||||
|
# UI can display "Unknown" without colliding with real base
|
||||||
|
# model strings.
|
||||||
|
sorted_models.append(
|
||||||
|
{
|
||||||
|
"name": "Unknown",
|
||||||
|
"value": UNKNOWN_BASE_MODEL_FILTER,
|
||||||
|
"count": unknown_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
||||||
if limit > 0:
|
if limit > 0:
|
||||||
sorted_models = sorted_models[:limit]
|
sorted_models = sorted_models[:limit]
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ _CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
|||||||
# Valid LoRA availability statuses for the recipe listing filter.
|
# Valid LoRA availability statuses for the recipe listing filter.
|
||||||
_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
|
_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
|
||||||
|
|
||||||
|
# Filter marker for recipes whose base model could not be determined
|
||||||
|
# (base_model is None or empty). The UI displays "Unknown" for this bucket;
|
||||||
|
# the marker keeps the semantics explicit and disjoint from any real base
|
||||||
|
# model string.
|
||||||
|
UNKNOWN_BASE_MODEL_FILTER = "__unknown__"
|
||||||
|
|
||||||
|
|
||||||
class RecipeScanner:
|
class RecipeScanner:
|
||||||
"""Service for scanning and managing recipe images"""
|
"""Service for scanning and managing recipe images"""
|
||||||
@@ -3530,11 +3536,23 @@ class RecipeScanner:
|
|||||||
if filters:
|
if filters:
|
||||||
# Filter by base model
|
# Filter by base model
|
||||||
if "base_model" in filters and filters["base_model"]:
|
if "base_model" in filters and filters["base_model"]:
|
||||||
filtered_data = [
|
base_model_filter = filters["base_model"]
|
||||||
item
|
if UNKNOWN_BASE_MODEL_FILTER in base_model_filter:
|
||||||
for item in filtered_data
|
# The unknown bucket matches recipes whose base model
|
||||||
if item.get("base_model", "") in filters["base_model"]
|
# could not be determined (None/empty); real base
|
||||||
]
|
# models in the list still match by exact name.
|
||||||
|
filtered_data = [
|
||||||
|
item
|
||||||
|
for item in filtered_data
|
||||||
|
if not item.get("base_model")
|
||||||
|
or item.get("base_model") in base_model_filter
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
filtered_data = [
|
||||||
|
item
|
||||||
|
for item in filtered_data
|
||||||
|
if item.get("base_model", "") in base_model_filter
|
||||||
|
]
|
||||||
|
|
||||||
# Filter by favorite
|
# Filter by favorite
|
||||||
if "favorite" in filters and filters["favorite"]:
|
if "favorite" in filters and filters["favorite"]:
|
||||||
|
|||||||
@@ -511,18 +511,21 @@ export class FilterManager {
|
|||||||
filteredModels.forEach(model => {
|
filteredModels.forEach(model => {
|
||||||
const tag = document.createElement('div');
|
const tag = document.createElement('div');
|
||||||
tag.className = 'filter-tag base-model-tag';
|
tag.className = 'filter-tag base-model-tag';
|
||||||
tag.dataset.baseModel = model.name;
|
// Display name may differ from the filter value (e.g. the "Unknown"
|
||||||
|
// bucket shows "Unknown" but filters via a dedicated marker).
|
||||||
|
const filterValue = model.value ?? model.name;
|
||||||
|
tag.dataset.baseModel = filterValue;
|
||||||
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
|
||||||
|
|
||||||
tag.addEventListener('click', async () => {
|
tag.addEventListener('click', async () => {
|
||||||
tag.classList.toggle('active');
|
tag.classList.toggle('active');
|
||||||
|
|
||||||
if (tag.classList.contains('active')) {
|
if (tag.classList.contains('active')) {
|
||||||
if (!this.filters.baseModel.includes(model.name)) {
|
if (!this.filters.baseModel.includes(filterValue)) {
|
||||||
this.filters.baseModel.push(model.name);
|
this.filters.baseModel.push(filterValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
|
this.filters.baseModel = this.filters.baseModel.filter(m => m !== filterValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateActiveFiltersCount();
|
this.updateActiveFiltersCount();
|
||||||
|
|||||||
@@ -350,6 +350,49 @@ describe('FilterManager tag and base model filters', () => {
|
|||||||
expect(baseModelChip.classList.contains('active')).toBe(false);
|
expect(baseModelChip.classList.contains('active')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters recipes by the unknown base model bucket via its marker value', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
base_models: [
|
||||||
|
{ name: 'Unknown', value: '__unknown__', count: 3 },
|
||||||
|
{ name: 'SDXL', count: 2 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderControlsDom('recipes');
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('recipes');
|
||||||
|
const { getCurrentPageState } = stateModule;
|
||||||
|
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
|
||||||
|
|
||||||
|
const loadRecipesMock = vi.fn().mockResolvedValue(undefined);
|
||||||
|
window.recipeManager = { loadRecipes: loadRecipesMock };
|
||||||
|
|
||||||
|
new FilterManager({ page: 'recipes' });
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const chip = document.querySelector('[data-base-model="__unknown__"]');
|
||||||
|
expect(chip).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unknownChip = document.querySelector('[data-base-model="__unknown__"]');
|
||||||
|
// Display label is "Unknown" even though the filter value is the marker
|
||||||
|
expect(unknownChip.textContent).toContain('Unknown');
|
||||||
|
|
||||||
|
unknownChip.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
await vi.waitFor(() => expect(loadRecipesMock).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
expect(getCurrentPageState().filters.baseModel).toEqual(['__unknown__']);
|
||||||
|
expect(unknownChip.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
const storageKey = 'lora_manager_recipes_filters';
|
||||||
|
const storedFilters = JSON.parse(localStorage.getItem(storageKey));
|
||||||
|
expect(storedFilters.baseModel).toEqual(['__unknown__']);
|
||||||
|
});
|
||||||
|
|
||||||
it('filters base model chips locally without changing selected state', async () => {
|
it('filters base model chips locally without changing selected state', async () => {
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.routes.handlers.recipe_handlers import RecipeQueryHandler
|
from py.routes.handlers.recipe_handlers import RecipeQueryHandler
|
||||||
|
from py.services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
|
||||||
|
|
||||||
|
|
||||||
async def _noop():
|
async def _noop():
|
||||||
@@ -46,3 +47,42 @@ async def test_recipe_query_handler_base_models_limit_zero_returns_all():
|
|||||||
{"name": "SDXL", "count": 2},
|
{"name": "SDXL", "count": 2},
|
||||||
{"name": "LTXV 2.3", "count": 1},
|
{"name": "LTXV 2.3", "count": 1},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipe_query_handler_base_models_includes_unknown_bucket():
|
||||||
|
cache = SimpleNamespace(
|
||||||
|
raw_data=[
|
||||||
|
{"base_model": "SDXL"},
|
||||||
|
{"base_model": None},
|
||||||
|
{"base_model": ""},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
scanner = SimpleNamespace(get_cached_data=lambda: None)
|
||||||
|
|
||||||
|
async def get_cached_data():
|
||||||
|
return cache
|
||||||
|
|
||||||
|
scanner.get_cached_data = get_cached_data
|
||||||
|
|
||||||
|
handler = RecipeQueryHandler(
|
||||||
|
ensure_dependencies_ready=_noop,
|
||||||
|
recipe_scanner_getter=lambda: scanner,
|
||||||
|
format_recipe_file_url=lambda value: value,
|
||||||
|
logger=logging.getLogger(__name__),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await handler.get_base_models(
|
||||||
|
SimpleNamespace(query={"limit": "0"}) # pyright: ignore[reportArgumentType]
|
||||||
|
)
|
||||||
|
text = response.text
|
||||||
|
assert text is not None
|
||||||
|
payload = json.loads(text)
|
||||||
|
|
||||||
|
assert payload["success"] is True
|
||||||
|
# Unknown bucket carries a dedicated marker so the UI can show "Unknown"
|
||||||
|
# without colliding with real base model strings.
|
||||||
|
assert payload["base_models"] == [
|
||||||
|
{"name": "Unknown", "value": UNKNOWN_BASE_MODEL_FILTER, "count": 2},
|
||||||
|
{"name": "SDXL", "count": 1},
|
||||||
|
]
|
||||||
|
|||||||
@@ -111,6 +111,36 @@ async def test_recipe_format_parser_populates_checkpoint(monkeypatch):
|
|||||||
assert result["model"] == checkpoint
|
assert result["model"] == checkpoint
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recipe_format_parser_base_model_defaults_to_none_when_unknown(monkeypatch):
|
||||||
|
class _FakeScanner:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# No checkpoint and empty base_model -> unknown renders as None (not "")
|
||||||
|
result = await _parse(
|
||||||
|
monkeypatch,
|
||||||
|
{"title": "T", "base_model": "", "loras": [], "gen_params": {}},
|
||||||
|
_FakeScanner(),
|
||||||
|
)
|
||||||
|
assert result["base_model"] is None
|
||||||
|
|
||||||
|
# Missing base_model key behaves the same
|
||||||
|
result = await _parse(
|
||||||
|
monkeypatch,
|
||||||
|
{"title": "T", "loras": [], "gen_params": {}},
|
||||||
|
_FakeScanner(),
|
||||||
|
)
|
||||||
|
assert result["base_model"] is None
|
||||||
|
|
||||||
|
# A real base_model in recipe metadata is kept
|
||||||
|
result = await _parse(
|
||||||
|
monkeypatch,
|
||||||
|
{"title": "T", "base_model": "Illustrious", "loras": [], "gen_params": {}},
|
||||||
|
_FakeScanner(),
|
||||||
|
)
|
||||||
|
assert result["base_model"] == "Illustrious"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recipe_format_parser_marks_lora_in_library_by_version(monkeypatch):
|
async def test_recipe_format_parser_marks_lora_in_library_by_version(monkeypatch):
|
||||||
async def fake_metadata_provider():
|
async def fake_metadata_provider():
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from py.services import model_scanner as model_scanner_module
|
|||||||
from py.services.model_cache import ModelCache
|
from py.services.model_cache import ModelCache
|
||||||
from py.services.model_hash_index import ModelHashIndex
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
from py.services.model_scanner import CacheBuildResult, ModelScanner
|
from py.services.model_scanner import CacheBuildResult, ModelScanner
|
||||||
from py.services.recipe_scanner import RecipeScanner
|
from py.services.recipe_scanner import RecipeScanner, UNKNOWN_BASE_MODEL_FILTER
|
||||||
from py.services import settings_manager as settings_manager_module
|
from py.services import settings_manager as settings_manager_module
|
||||||
from py.utils.models import BaseModelMetadata
|
from py.utils.models import BaseModelMetadata
|
||||||
from py.utils.utils import calculate_recipe_fingerprint
|
from py.utils.utils import calculate_recipe_fingerprint
|
||||||
@@ -1954,6 +1954,59 @@ async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
|||||||
assert len(result_fav_false["items"]) == 2
|
assert len(result_fav_false["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_base_model_unknown_bucket(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": "known",
|
||||||
|
"file_path": "path/known.png",
|
||||||
|
"title": "Known Base Model",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"base_model": "SDXL 1.0",
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await scanner.add_recipe(
|
||||||
|
{
|
||||||
|
"id": "unknown",
|
||||||
|
"file_path": "path/unknown.png",
|
||||||
|
"title": "Unknown Base Model",
|
||||||
|
"modified": 2.0,
|
||||||
|
"created_date": 2.0,
|
||||||
|
"base_model": None,
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await _wait_for_resort(scanner)
|
||||||
|
|
||||||
|
# Exact-name filter matches only the recipe with that base model
|
||||||
|
result_known = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, filters={"base_model": ["SDXL 1.0"]}
|
||||||
|
)
|
||||||
|
assert [item["id"] for item in result_known["items"]] == ["known"]
|
||||||
|
|
||||||
|
# Unknown bucket matches recipes whose base model could not be determined
|
||||||
|
result_unknown = await scanner.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
filters={"base_model": [UNKNOWN_BASE_MODEL_FILTER]},
|
||||||
|
)
|
||||||
|
assert [item["id"] for item in result_unknown["items"]] == ["unknown"]
|
||||||
|
|
||||||
|
# Mixing known values with the unknown bucket matches both groups
|
||||||
|
result_both = await scanner.get_paginated_data(
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
filters={"base_model": ["SDXL 1.0", UNKNOWN_BASE_MODEL_FILTER]},
|
||||||
|
)
|
||||||
|
assert {item["id"] for item in result_both["items"]} == {"known", "unknown"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||||
scanner, _ = recipe_scanner
|
scanner, _ = recipe_scanner
|
||||||
|
|||||||
Reference in New Issue
Block a user