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:
Will Miao
2026-08-31 09:09:53 +08:00
parent 8d46d26abe
commit 2a3c632dc5
8 changed files with 214 additions and 11 deletions
@@ -350,6 +350,49 @@ describe('FilterManager tag and base model filters', () => {
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 () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
+40
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
import pytest
from py.routes.handlers.recipe_handlers import RecipeQueryHandler
from py.services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
async def _noop():
@@ -46,3 +47,42 @@ async def test_recipe_query_handler_base_models_limit_zero_returns_all():
{"name": "SDXL", "count": 2},
{"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
@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
async def test_recipe_format_parser_marks_lora_in_library_by_version(monkeypatch):
async def fake_metadata_provider():
+54 -1
View File
@@ -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_hash_index import ModelHashIndex
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.utils.models import BaseModelMetadata
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
@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
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
scanner, _ = recipe_scanner