From 0e73db06692e4ef3f432c1c3cb4a14a73523790f Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 17 Nov 2025 19:26:41 +0800 Subject: [PATCH 1/3] feat: implement same_base update strategy for model annotations Add support for configurable update flag strategy with new "same_base" mode that considers base model versions when determining update availability. The strategy is controlled by the "update_flag_strategy" setting. When strategy is set to "same_base": - Uses get_records_bulk instead of has_updates_bulk - Compares model versions against highest local versions per base model - Provides more granular update detection based on base model relationships Fallback to existing bulk or individual update checks when: - Strategy is not "same_base" - Bulk operations fail - Records are unavailable This enables more precise update flagging for models sharing common bases. --- py/services/base_model_service.py | 139 +++++++++++-- py/services/model_update_service.py | 131 +++++++++--- py/services/settings_manager.py | 1 + tests/services/test_base_model_service.py | 212 ++++++++++++++++++++ tests/services/test_model_update_service.py | 23 ++- 5 files changed, 458 insertions(+), 48 deletions(-) diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index d28b8f72..a9d1fa09 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -267,20 +267,49 @@ class BaseModelService(ABC): if not ordered_ids: return annotated + strategy_value = self.settings.get("update_flag_strategy") + if isinstance(strategy_value, str) and strategy_value.strip(): + strategy = strategy_value.strip().lower() + else: + strategy = "any" + same_base_mode = strategy == "same_base" + + records = None resolved: Optional[Dict[int, bool]] = None - bulk_method = getattr(self.update_service, "has_updates_bulk", None) - if callable(bulk_method): - try: - resolved = await bulk_method(self.model_type, ordered_ids) - except Exception as exc: - logger.error( - "Failed to resolve update status in bulk for %s models (%s): %s", - self.model_type, - ordered_ids, - exc, - exc_info=True, - ) - resolved = None + if same_base_mode: + record_method = getattr(self.update_service, "get_records_bulk", None) + if callable(record_method): + try: + records = await record_method(self.model_type, ordered_ids) + resolved = { + model_id: record.has_update() + for model_id, record in records.items() + } + except Exception as exc: + logger.error( + "Failed to resolve update records in bulk for %s models (%s): %s", + self.model_type, + ordered_ids, + exc, + exc_info=True, + ) + records = None + resolved = None + + if resolved is None: + bulk_method = getattr(self.update_service, "has_updates_bulk", None) + if callable(bulk_method): + try: + resolved = await bulk_method(self.model_type, ordered_ids) + except Exception as exc: + logger.error( + "Failed to resolve update status in bulk for %s models (%s): %s", + self.model_type, + ordered_ids, + exc, + exc_info=True, + ) + resolved = None if resolved is None: tasks = [ @@ -301,8 +330,24 @@ class BaseModelService(ABC): resolved[model_id] = bool(result) for model_id, items_for_id in id_to_items.items(): - flag = bool(resolved.get(model_id, False)) + default_flag = bool(resolved.get(model_id, False)) if resolved else False + record = records.get(model_id) if records else None + base_highest_versions = ( + self._build_highest_local_versions_by_base(record) if same_base_mode and record else {} + ) for item in items_for_id: + if same_base_mode and record is not None: + base_model = self._extract_base_model(item) + normalized_base = self._normalize_base_model_name(base_model) + threshold_version = base_highest_versions.get(normalized_base) if normalized_base else None + if threshold_version is None: + threshold_version = self._extract_version_id(item) + flag = record.has_update_for_base( + threshold_version, + base_model, + ) + else: + flag = default_flag item['update_available'] = flag return annotated @@ -319,7 +364,71 @@ class BaseModelService(ABC): return int(value) except (TypeError, ValueError): return None - + + @staticmethod + def _extract_version_id(item: Dict) -> Optional[int]: + civitai = item.get('civitai') if isinstance(item, dict) else None + if not isinstance(civitai, dict): + return None + value = civitai.get('id') + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _extract_base_model(item: Dict) -> Optional[str]: + value = item.get('base_model') + if value is None: + return None + if isinstance(value, str): + candidate = value.strip() + else: + try: + candidate = str(value).strip() + except Exception: + return None + return candidate if candidate else None + + @staticmethod + def _normalize_base_model_name(value: Optional[str]) -> Optional[str]: + """Return a lowercased, trimmed base model name for comparison.""" + + if value is None: + return None + if isinstance(value, str): + candidate = value.strip() + else: + try: + candidate = str(value).strip() + except Exception: + return None + return candidate.lower() if candidate else None + + def _build_highest_local_versions_by_base(self, record) -> Dict[str, int]: + """Return the highest local version id known for each normalized base model.""" + + if record is None: + return {} + + highest_by_base: Dict[str, int] = {} + for version in getattr(record, "versions", []): + if not getattr(version, "is_in_library", False): + continue + normalized_base = self._normalize_base_model_name(getattr(version, "base_model", None)) + if normalized_base is None: + continue + version_id = getattr(version, "version_id", None) + if version_id is None: + continue + current_max = highest_by_base.get(normalized_base) + if current_max is None or version_id > current_max: + highest_by_base[normalized_base] = version_id + + return highest_by_base + def _paginate(self, data: List[Dict], page: int, page_size: int) -> Dict: """Apply pagination to filtered data""" total_items = len(data) diff --git a/py/services/model_update_service.py b/py/services/model_update_service.py index d31ad28f..d75e1cfd 100644 --- a/py/services/model_update_service.py +++ b/py/services/model_update_service.py @@ -17,6 +17,41 @@ from ..utils.preview_selection import select_preview_media logger = logging.getLogger(__name__) +def _normalize_int(value) -> Optional[int]: + """Safely convert a value to an integer.""" + + try: + if value is None: + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _normalize_string(value) -> Optional[str]: + """Return a stripped string or None if the value is empty.""" + + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + return stripped or None + try: + normalized = str(value).strip() + return normalized or None + except Exception: + return None + + +def _normalize_base_model(value) -> Optional[str]: + """Normalize base-model names for case-insensitive comparison.""" + + normalized = _normalize_string(value) + if normalized is None: + return None + return normalized.lower() + + @dataclass class ModelVersionRecord: """Persisted metadata for a single model version.""" @@ -85,6 +120,47 @@ class ModelUpdateRecord: return True return False + def has_update_for_base( + self, + local_version_id: Optional[int], + local_base_model: Optional[str], + ) -> bool: + """Return True when a newer remote version with the same base model exists.""" + + if self.should_ignore_model: + return False + + normalized_base = _normalize_base_model(local_base_model) + if normalized_base is None: + return False + + threshold = _normalize_int(local_version_id) + if threshold is None: + highest_local = None + for version in self.versions: + if not version.is_in_library: + continue + version_base = _normalize_base_model(version.base_model) + if version_base != normalized_base: + continue + if highest_local is None or version.version_id > highest_local: + highest_local = version.version_id + threshold = highest_local + + if threshold is None: + return False + + for version in self.versions: + if version.is_in_library or version.should_ignore: + continue + version_base = _normalize_base_model(version.base_model) + if version_base != normalized_base: + continue + if version.version_id > threshold: + return True + + return False + class ModelUpdateService: """Persist and query remote model version metadata.""" @@ -628,6 +704,20 @@ class ModelUpdateService: for model_id in normalized_ids } + async def get_records_bulk( + self, + model_type: str, + model_ids: Sequence[int], + ) -> Dict[int, ModelUpdateRecord]: + """Return cached update records for the requested models.""" + + normalized_ids = self._normalize_sequence(model_ids) + if not normalized_ids: + return {} + + async with self._lock: + return self._get_records_bulk(model_type, normalized_ids) + async def _refresh_single_model( self, model_type: str, @@ -799,7 +889,7 @@ class ModelUpdateService: ) continue for key, value in response.items(): - normalized_key = self._normalize_int(key) + normalized_key = _normalize_int(key) if normalized_key is None: continue if isinstance(value, Mapping): @@ -832,8 +922,8 @@ class ModelUpdateService: civitai = item.get("civitai") if isinstance(item, dict) else None if not isinstance(civitai, dict): continue - model_id = self._normalize_int(civitai.get("modelId")) - version_id = self._normalize_int(civitai.get("id")) + model_id = _normalize_int(civitai.get("modelId")) + version_id = _normalize_int(civitai.get("id")) if model_id is None or version_id is None: continue if target_set is not None and model_id not in target_set: @@ -973,35 +1063,14 @@ class ModelUpdateService: return True return (now - record.last_checked_at) >= self._ttl_seconds - @staticmethod - def _normalize_int(value) -> Optional[int]: - try: - if value is None: - return None - return int(value) - except (TypeError, ValueError): - return None - def _normalize_sequence(self, values: Sequence[int]) -> List[int]: normalized = [ item - for item in (self._normalize_int(value) for value in values) + for item in (_normalize_int(value) for value in values) if item is not None ] return sorted(dict.fromkeys(normalized)) - @staticmethod - def _normalize_string(value) -> Optional[str]: - if value is None: - return None - if isinstance(value, str): - stripped = value.strip() - return stripped or None - try: - return str(value) - except Exception: # pragma: no cover - defensive conversion - return None - def _extract_versions(self, response) -> Optional[List[ModelVersionRecord]]: if not isinstance(response, Mapping): return None @@ -1014,12 +1083,12 @@ class ModelUpdateService: for index, entry in enumerate(versions): if not isinstance(entry, Mapping): continue - version_id = self._normalize_int(entry.get("id")) + version_id = _normalize_int(entry.get("id")) if version_id is None: continue - name = self._normalize_string(entry.get("name")) - base_model = self._normalize_string(entry.get("baseModel")) - released_at = self._normalize_string(entry.get("publishedAt") or entry.get("createdAt")) + name = _normalize_string(entry.get("name")) + base_model = _normalize_string(entry.get("baseModel")) + released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt")) size_bytes = self._extract_size_bytes(entry.get("files")) preview_url = self._extract_preview_url(entry.get("images")) extracted.append( @@ -1152,11 +1221,11 @@ class ModelUpdateService: name=row["name"], base_model=row["base_model"], released_at=row["released_at"], - size_bytes=self._normalize_int(row["size_bytes"]), + size_bytes=_normalize_int(row["size_bytes"]), preview_url=row["preview_url"], is_in_library=bool(row["is_in_library"]), should_ignore=bool(row["should_ignore"]), - sort_index=self._normalize_int(row["sort_index"]) or 0, + sort_index=_normalize_int(row["sort_index"]) or 0, ) ) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 37476f68..6ff96c4b 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -61,6 +61,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), "model_name_display": "model_name", "model_card_footer_action": "example_images", + "update_flag_strategy": "any", } diff --git a/tests/services/test_base_model_service.py b/tests/services/test_base_model_service.py index 77a2a1b4..8c412aa1 100644 --- a/tests/services/test_base_model_service.py +++ b/tests/services/test_base_model_service.py @@ -11,6 +11,7 @@ from py.services.model_query import ( SearchStrategy, SortParams, ) +from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord from py.utils.models import BaseModelMetadata @@ -98,6 +99,25 @@ class StubUpdateService: return result +class StubUpdateServiceWithRecords(StubUpdateService): + def __init__(self, records, *, bulk_error: bool = False): + decisions = { + model_id: record.has_update() + for model_id, record in records.items() + } + super().__init__(decisions, bulk_error=bulk_error) + self.records = dict(records) + self.records_bulk_calls = [] + + async def get_records_bulk(self, model_type, model_ids): + self.records_bulk_calls.append((model_type, list(model_ids))) + return { + model_id: self.records[model_id] + for model_id in model_ids + if model_id in self.records + } + + @pytest.mark.asyncio async def test_get_paginated_data_uses_injected_collaborators(): data = [ @@ -461,6 +481,198 @@ async def test_get_paginated_data_annotates_update_flags_with_bulk_dedup(): assert response["total_pages"] == 1 +@pytest.mark.asyncio +async def test_update_flag_strategy_same_base_prefers_matching_base(): + items = [ + { + "model_name": "Pony Version", + "civitai": {"modelId": 1, "id": 10, "baseModel": "Pony"}, + "base_model": "Pony", + }, + { + "model_name": "Flux Version", + "civitai": {"modelId": 1, "id": 20, "baseModel": "Flux 1.D"}, + "base_model": "Flux 1.D", + }, + ] + repository = StubRepository(items) + filter_set = PassThroughFilterSet() + search_strategy = NoSearchStrategy() + record = ModelUpdateRecord( + model_type="stub", + model_id=1, + versions=[ + ModelVersionRecord( + version_id=10, + name="Pony Local", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=0, + ), + ModelVersionRecord( + version_id=20, + name="Flux Local", + base_model="Flux 1.D", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=1, + ), + ModelVersionRecord( + version_id=30, + name="Pony Remote", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + sort_index=2, + ), + ModelVersionRecord( + version_id=40, + name="SDXL Remote", + base_model="SDXL", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + sort_index=3, + ), + ], + last_checked_at=None, + should_ignore_model=False, + ) + update_service = StubUpdateServiceWithRecords({1: record}) + settings = StubSettings({"update_flag_strategy": "same_base"}) + + service = DummyService( + model_type="stub", + scanner=object(), + metadata_class=BaseModelMetadata, + cache_repository=repository, + filter_set=filter_set, + search_strategy=search_strategy, + settings_provider=settings, + update_service=update_service, + ) + + response = await service.get_paginated_data( + page=1, + page_size=10, + sort_by="name:asc", + ) + + assert update_service.records_bulk_calls == [("stub", [1])] + assert update_service.bulk_calls == [] + assert len(response["items"]) == 2 + flags = {item["model_name"]: item["update_available"] for item in response["items"]} + assert flags["Pony Version"] is True + assert flags["Flux Version"] is False + + +@pytest.mark.asyncio +async def test_update_flag_strategy_same_base_honors_latest_local_version(): + items = [ + { + "model_name": "Pony v0.1", + "civitai": {"modelId": 1, "id": 101, "baseModel": "Pony"}, + "base_model": "Pony", + }, + { + "model_name": "Pony v0.3", + "civitai": {"modelId": 1, "id": 103, "baseModel": "Pony"}, + "base_model": "Pony", + }, + ] + repository = StubRepository(items) + filter_set = PassThroughFilterSet() + search_strategy = NoSearchStrategy() + record = ModelUpdateRecord( + model_type="stub", + model_id=1, + versions=[ + ModelVersionRecord( + version_id=101, + name="Old Pony", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=0, + ), + ModelVersionRecord( + version_id=102, + name="Pony Remote", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=False, + should_ignore=False, + sort_index=1, + ), + ModelVersionRecord( + version_id=103, + name="Middle Pony", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=2, + ), + ModelVersionRecord( + version_id=104, + name="Latest Pony", + base_model="Pony", + released_at=None, + size_bytes=None, + preview_url=None, + is_in_library=True, + should_ignore=False, + sort_index=3, + ), + ], + last_checked_at=None, + should_ignore_model=False, + ) + update_service = StubUpdateServiceWithRecords({1: record}) + settings = StubSettings({"update_flag_strategy": "same_base"}) + + service = DummyService( + model_type="stub", + scanner=object(), + metadata_class=BaseModelMetadata, + cache_repository=repository, + filter_set=filter_set, + search_strategy=search_strategy, + settings_provider=settings, + update_service=update_service, + ) + + response = await service.get_paginated_data( + page=1, + page_size=10, + sort_by="name:asc", + ) + + assert update_service.records_bulk_calls == [("stub", [1])] + flags = {item["model_name"]: item["update_available"] for item in response["items"]} + assert flags["Pony v0.1"] is False + assert flags["Pony v0.3"] is False + + @pytest.mark.asyncio async def test_get_paginated_data_filters_update_available_only(): items = [ diff --git a/tests/services/test_model_update_service.py b/tests/services/test_model_update_service.py index 23c7d003..5e4534e8 100644 --- a/tests/services/test_model_update_service.py +++ b/tests/services/test_model_update_service.py @@ -52,11 +52,11 @@ class NotFoundProvider: return {} -def make_version(version_id, *, in_library, should_ignore=False): +def make_version(version_id, *, in_library, base_model=None, should_ignore=False): return ModelVersionRecord( version_id=version_id, name=None, - base_model=None, + base_model=base_model, released_at=None, size_bytes=None, preview_url=None, @@ -147,6 +147,25 @@ def test_has_update_detects_newer_remote_version(): assert record.has_update() is True +def test_has_update_for_base_matches_same_base_model(): + record = make_record( + make_version(5, in_library=True, base_model="Pony"), + make_version(6, in_library=False, base_model="Pony"), + make_version(7, in_library=False, base_model="Flux.1"), + ) + + assert record.has_update_for_base(5, "Pony") is True + + +def test_has_update_for_base_rejects_other_base_models(): + record = make_record( + make_version(10, in_library=True, base_model="Flux"), + make_version(20, in_library=False, base_model="SDXL"), + ) + + assert record.has_update_for_base(10, "Flux") is False + + @pytest.mark.asyncio async def test_refresh_persists_versions_and_uses_cache(tmp_path): db_path = tmp_path / "updates.sqlite" From 3661b11b70f910f02f3e90fc84e9aaa9cf4b0857 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 17 Nov 2025 20:02:26 +0800 Subject: [PATCH 2/3] feat(i18n): add update flag strategy settings Add new "updateFlags" section to settings navigation and implement update flag strategy configuration. The strategy allows users to choose when update badges appear: - Match updates by base model (only show when new release shares same base model) - Flag any available update (show whenever newer version exists) Includes translations for English, German, Spanish, and French locales. --- locales/de.json | 10 +++- locales/en.json | 9 ++++ locales/es.json | 9 ++++ locales/fr.json | 53 +++++++++++-------- locales/he.json | 9 ++++ locales/ja.json | 53 +++++++++++-------- locales/ko.json | 53 +++++++++++-------- locales/ru.json | 53 +++++++++++-------- locales/zh-CN.json | 53 +++++++++++-------- locales/zh-TW.json | 53 +++++++++++-------- py/routes/handlers/misc_handlers.py | 1 + py/services/base_model_service.py | 2 +- py/services/settings_manager.py | 2 +- static/js/managers/SettingsManager.js | 11 ++-- static/js/state/index.js | 1 + .../components/modals/settings_modal.html | 21 ++++++++ 16 files changed, 253 insertions(+), 140 deletions(-) diff --git a/locales/de.json b/locales/de.json index ad01fa97..15b4cf47 100644 --- a/locales/de.json +++ b/locales/de.json @@ -230,6 +230,7 @@ "priorityTags": "Prioritäts-Tags", "downloadPathTemplates": "Download-Pfad-Vorlagen", "exampleImages": "Beispielbilder", + "updateFlags": "Update-Markierungen", "misc": "Verschiedenes", "metadataArchive": "Metadaten-Archiv-Datenbank", "storageLocation": "Einstellungsort", @@ -271,7 +272,6 @@ "hover": "Bei Hover anzeigen" }, "cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen", - "modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche", "modelCardFooterActionOptions": { "exampleImages": "Beispielbilder öffnen", @@ -365,6 +365,14 @@ "download": "Herunterladen", "restartRequired": "Neustart erforderlich" }, + "updateFlagStrategy": { + "label": "Strategie für Update-Markierungen", + "help": "Entscheide, ob Update-Badges nur dann erscheinen, wenn eine neue Version dasselbe Basismodell wie deine lokalen Dateien verwendet, oder sobald es irgendein neueres Release für dieses Modell gibt.", + "options": { + "sameBase": "Updates nach Basismodell abgleichen", + "any": "Jede verfügbare Aktualisierung markieren" + } + }, "misc": { "includeTriggerWords": "Trigger Words in LoRA-Syntax einschließen", "includeTriggerWordsHelp": "Trainierte Trigger Words beim Kopieren der LoRA-Syntax in die Zwischenablage einschließen" diff --git a/locales/en.json b/locales/en.json index 97aad0fc..7ac844da 100644 --- a/locales/en.json +++ b/locales/en.json @@ -230,6 +230,7 @@ "priorityTags": "Priority Tags", "downloadPathTemplates": "Download Path Templates", "exampleImages": "Example Images", + "updateFlags": "Update Flags", "misc": "Misc.", "metadataArchive": "Metadata Archive Database", "storageLocation": "Settings Location", @@ -364,6 +365,14 @@ "download": "Download", "restartRequired": "Requires restart" }, + "updateFlagStrategy": { + "label": "Update Flag Strategy", + "help": "Decide whether update badges should only appear when a new release shares the same base model as your local files or whenever any newer version exists for that model.", + "options": { + "sameBase": "Match updates by base model", + "any": "Flag any available update" + } + }, "misc": { "includeTriggerWords": "Include Trigger Words in LoRA Syntax", "includeTriggerWordsHelp": "Include trained trigger words when copying LoRA syntax to clipboard" diff --git a/locales/es.json b/locales/es.json index 56de455e..89333ca8 100644 --- a/locales/es.json +++ b/locales/es.json @@ -230,6 +230,7 @@ "priorityTags": "Etiquetas prioritarias", "downloadPathTemplates": "Plantillas de rutas de descarga", "exampleImages": "Imágenes de ejemplo", + "updateFlags": "Indicadores de actualización", "misc": "Varios", "metadataArchive": "Base de datos de archivo de metadatos", "storageLocation": "Ubicación de ajustes", @@ -364,6 +365,14 @@ "download": "Descargar", "restartRequired": "Requiere reinicio" }, + "updateFlagStrategy": { + "label": "Estrategia de indicadores de actualización", + "help": "Decide si las insignias de actualización deben mostrarse solo cuando una nueva versión comparte el mismo modelo base que tus archivos locales o siempre que exista cualquier versión más reciente de ese modelo.", + "options": { + "sameBase": "Coincidir actualizaciones por modelo base", + "any": "Marcar cualquier actualización disponible" + } + }, "misc": { "includeTriggerWords": "Incluir palabras clave en la sintaxis de LoRA", "includeTriggerWordsHelp": "Incluir palabras clave entrenadas al copiar la sintaxis de LoRA al portapapeles" diff --git a/locales/fr.json b/locales/fr.json index aa8af106..cf3c8ce3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -227,13 +227,14 @@ "videoSettings": "Paramètres vidéo", "layoutSettings": "Paramètres d'affichage", "folderSettings": "Paramètres des dossiers", + "priorityTags": "Étiquettes prioritaires", "downloadPathTemplates": "Modèles de chemin de téléchargement", "exampleImages": "Images d'exemple", + "updateFlags": "Indicateurs de mise à jour", "misc": "Divers", "metadataArchive": "Base de données d'archive des métadonnées", "storageLocation": "Emplacement des paramètres", - "proxySettings": "Paramètres du proxy", - "priorityTags": "Étiquettes prioritaires" + "proxySettings": "Paramètres du proxy" }, "storage": { "locationLabel": "Mode portable", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", "noDefault": "Aucun par défaut" }, + "priorityTags": { + "title": "Étiquettes prioritaires", + "description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "Étiquettes prioritaires mises à jour.", + "saveError": "Échec de la mise à jour des étiquettes prioritaires.", + "loadingSuggestions": "Chargement des suggestions...", + "validation": { + "missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.", + "missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.", + "duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.", + "unknown": "Configuration d'étiquettes prioritaires invalide." + } + }, "downloadPathTemplates": { "title": "Modèles de chemin de téléchargement", "help": "Configurer les structures de dossiers pour différents types de modèles lors du téléchargement depuis Civitai.", @@ -344,6 +365,14 @@ "download": "Télécharger", "restartRequired": "Redémarrage requis" }, + "updateFlagStrategy": { + "label": "Stratégie des indicateurs de mise à jour", + "help": "Choisissez si les badges de mise à jour doivent apparaître uniquement lorsqu’une nouvelle version partage le même modèle de base que vos fichiers locaux, ou dès qu’il existe une version plus récente pour ce modèle.", + "options": { + "sameBase": "Faire correspondre les mises à jour par modèle de base", + "any": "Signaler n’importe quelle mise à jour disponible" + } + }, "misc": { "includeTriggerWords": "Inclure les mots-clés dans la syntaxe LoRA", "includeTriggerWordsHelp": "Inclure les mots-clés d'entraînement lors de la copie de la syntaxe LoRA dans le presse-papiers" @@ -389,26 +418,6 @@ "proxyPassword": "Mot de passe (optionnel)", "proxyPasswordPlaceholder": "mot_de_passe", "proxyPasswordHelp": "Mot de passe pour l'authentification proxy (si nécessaire)" - }, - "priorityTags": { - "title": "Étiquettes prioritaires", - "description": "Personnalisez l'ordre de priorité des étiquettes pour chaque type de modèle (par ex. : character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "Ouvrir l'aide sur les étiquettes prioritaires", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "Étiquettes prioritaires mises à jour.", - "saveError": "Échec de la mise à jour des étiquettes prioritaires.", - "loadingSuggestions": "Chargement des suggestions...", - "validation": { - "missingClosingParen": "L'entrée {index} n'a pas de parenthèse fermante.", - "missingCanonical": "L'entrée {index} doit inclure un nom d'étiquette canonique.", - "duplicateCanonical": "L'étiquette canonique \"{tag}\" apparaît plusieurs fois.", - "unknown": "Configuration d'étiquettes prioritaires invalide." - } } }, "loras": { diff --git a/locales/he.json b/locales/he.json index 0cb19801..ac9571c6 100644 --- a/locales/he.json +++ b/locales/he.json @@ -229,6 +229,7 @@ "folderSettings": "הגדרות תיקייה", "downloadPathTemplates": "תבניות נתיב הורדה", "exampleImages": "תמונות דוגמה", + "updateFlags": "תגי עדכון", "misc": "שונות", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "storageLocation": "מיקום ההגדרות", @@ -344,6 +345,14 @@ "download": "הורד", "restartRequired": "דורש הפעלה מחדש" }, + "updateFlagStrategy": { + "label": "אסטרטגיית תגי עדכון", + "help": "בחרו אם תוויות העדכון יוצגו רק כאשר גרסה חדשה חולקת את אותו דגם בסיס כמו הקבצים המקומיים שלכם או בכל מקרה שבו קיימת גרסה חדשה עבור אותו דגם.", + "options": { + "sameBase": "התאמת עדכונים לפי דגם בסיס", + "any": "תוויות לכל עדכון זמין" + } + }, "misc": { "includeTriggerWords": "כלול מילות טריגר בתחביר LoRA", "includeTriggerWordsHelp": "כלול מילות טריגר מאומנות בעת העתקת תחביר LoRA ללוח" diff --git a/locales/ja.json b/locales/ja.json index 66b1b5ff..ad5b300f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -227,13 +227,14 @@ "videoSettings": "動画設定", "layoutSettings": "レイアウト設定", "folderSettings": "フォルダ設定", + "priorityTags": "優先タグ", "downloadPathTemplates": "ダウンロードパステンプレート", "exampleImages": "例画像", + "updateFlags": "アップデートフラグ", "misc": "その他", "metadataArchive": "メタデータアーカイブデータベース", "storageLocation": "設定の場所", - "proxySettings": "プロキシ設定", - "priorityTags": "優先タグ" + "proxySettings": "プロキシ設定" }, "storage": { "locationLabel": "ポータブルモード", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", "noDefault": "デフォルトなし" }, + "priorityTags": { + "title": "優先タグ", + "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "優先タグのヘルプを開く", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "チェックポイント", + "embedding": "埋め込み" + }, + "saveSuccess": "優先タグを更新しました。", + "saveError": "優先タグの更新に失敗しました。", + "loadingSuggestions": "候補を読み込み中...", + "validation": { + "missingClosingParen": "エントリ {index} に閉じ括弧がありません。", + "missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。", + "duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。", + "unknown": "無効な優先タグ設定です。" + } + }, "downloadPathTemplates": { "title": "ダウンロードパステンプレート", "help": "Civitaiからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。", @@ -344,6 +365,14 @@ "download": "ダウンロード", "restartRequired": "再起動が必要" }, + "updateFlagStrategy": { + "label": "アップデートフラグの表示戦略", + "help": "新リリースがローカルファイルと同じベースモデルを共有する場合にのみ更新バッジを表示するか、そのモデルに新しいバージョンがあれば常に表示するかを決めます。", + "options": { + "sameBase": "ベースモデルで更新をマッチ", + "any": "利用可能な更新すべてを表示" + } + }, "misc": { "includeTriggerWords": "LoRA構文にトリガーワードを含める", "includeTriggerWordsHelp": "LoRA構文をクリップボードにコピーする際、学習済みトリガーワードを含めます" @@ -389,26 +418,6 @@ "proxyPassword": "パスワード(任意)", "proxyPasswordPlaceholder": "パスワード", "proxyPasswordHelp": "プロキシ認証用のパスワード(必要な場合)" - }, - "priorityTags": { - "title": "優先タグ", - "description": "各モデルタイプのタグ優先順位をカスタマイズします (例: character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "優先タグのヘルプを開く", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "チェックポイント", - "embedding": "埋め込み" - }, - "saveSuccess": "優先タグを更新しました。", - "saveError": "優先タグの更新に失敗しました。", - "loadingSuggestions": "候補を読み込み中...", - "validation": { - "missingClosingParen": "エントリ {index} に閉じ括弧がありません。", - "missingCanonical": "エントリ {index} には正規タグ名を含める必要があります。", - "duplicateCanonical": "正規タグ \"{tag}\" が複数回登場しています。", - "unknown": "無効な優先タグ設定です。" - } } }, "loras": { diff --git a/locales/ko.json b/locales/ko.json index e735b42d..2fef79b2 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -227,13 +227,14 @@ "videoSettings": "비디오 설정", "layoutSettings": "레이아웃 설정", "folderSettings": "폴더 설정", + "priorityTags": "우선순위 태그", "downloadPathTemplates": "다운로드 경로 템플릿", "exampleImages": "예시 이미지", + "updateFlags": "업데이트 표시", "misc": "기타", "metadataArchive": "메타데이터 아카이브 데이터베이스", "storageLocation": "설정 위치", - "proxySettings": "프록시 설정", - "priorityTags": "우선순위 태그" + "proxySettings": "프록시 설정" }, "storage": { "locationLabel": "휴대용 모드", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", "noDefault": "기본값 없음" }, + "priorityTags": { + "title": "우선순위 태그", + "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "우선순위 태그 도움말 열기", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "체크포인트", + "embedding": "임베딩" + }, + "saveSuccess": "우선순위 태그가 업데이트되었습니다.", + "saveError": "우선순위 태그를 업데이트하지 못했습니다.", + "loadingSuggestions": "추천을 불러오는 중...", + "validation": { + "missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.", + "missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.", + "duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.", + "unknown": "잘못된 우선순위 태그 구성입니다." + } + }, "downloadPathTemplates": { "title": "다운로드 경로 템플릿", "help": "Civitai에서 다운로드할 때 다양한 모델 유형의 폴더 구조를 구성합니다.", @@ -344,6 +365,14 @@ "download": "다운로드", "restartRequired": "재시작 필요" }, + "updateFlagStrategy": { + "label": "업데이트 표시 전략", + "help": "새 릴리스가 로컬 파일과 동일한 베이스 모델을 공유할 때만 업데이트 배지를 표시할지, 또는 해당 모델에 사용 가능한 새 버전이 있으면 항상 표시할지 결정합니다.", + "options": { + "sameBase": "베이스 모델로 업데이트 일치", + "any": "사용 가능한 모든 업데이트 표시" + } + }, "misc": { "includeTriggerWords": "LoRA 문법에 트리거 단어 포함", "includeTriggerWordsHelp": "LoRA 문법을 클립보드에 복사할 때 학습된 트리거 단어를 포함합니다" @@ -389,26 +418,6 @@ "proxyPassword": "비밀번호 (선택사항)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "프록시 인증에 필요한 비밀번호 (필요한 경우)" - }, - "priorityTags": { - "title": "우선순위 태그", - "description": "모델 유형별 태그 우선순위를 사용자 지정합니다(예: character, concept, style(toon|toon_style)).", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "우선순위 태그 도움말 열기", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "체크포인트", - "embedding": "임베딩" - }, - "saveSuccess": "우선순위 태그가 업데이트되었습니다.", - "saveError": "우선순위 태그를 업데이트하지 못했습니다.", - "loadingSuggestions": "추천을 불러오는 중...", - "validation": { - "missingClosingParen": "{index}번째 항목에 닫는 괄호가 없습니다.", - "missingCanonical": "{index}번째 항목에는 정식 태그 이름이 포함되어야 합니다.", - "duplicateCanonical": "정식 태그 \"{tag}\"가 여러 번 나타납니다.", - "unknown": "잘못된 우선순위 태그 구성입니다." - } } }, "loras": { diff --git a/locales/ru.json b/locales/ru.json index 158a9fb8..2d739730 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -227,13 +227,14 @@ "videoSettings": "Настройки видео", "layoutSettings": "Настройки макета", "folderSettings": "Настройки папок", + "priorityTags": "Приоритетные теги", "downloadPathTemplates": "Шаблоны путей загрузки", "exampleImages": "Примеры изображений", + "updateFlags": "Метки обновлений", "misc": "Разное", "metadataArchive": "Архив метаданных", "storageLocation": "Расположение настроек", - "proxySettings": "Настройки прокси", - "priorityTags": "Приоритетные теги" + "proxySettings": "Настройки прокси" }, "storage": { "locationLabel": "Портативный режим", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", "noDefault": "Не задано" }, + "priorityTags": { + "title": "Приоритетные теги", + "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "Открыть справку по приоритетным тегам", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Чекпойнт", + "embedding": "Эмбеддинг" + }, + "saveSuccess": "Приоритетные теги обновлены.", + "saveError": "Не удалось обновить приоритетные теги.", + "loadingSuggestions": "Загрузка подсказок...", + "validation": { + "missingClosingParen": "В записи {index} отсутствует закрывающая скобка.", + "missingCanonical": "Запись {index} должна содержать каноническое имя тега.", + "duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.", + "unknown": "Недопустимая конфигурация приоритетных тегов." + } + }, "downloadPathTemplates": { "title": "Шаблоны путей загрузки", "help": "Настройте структуру папок для разных типов моделей при загрузке с Civitai.", @@ -344,6 +365,14 @@ "download": "Загрузить", "restartRequired": "Требует перезапуска" }, + "updateFlagStrategy": { + "label": "Стратегия меток обновлений", + "help": "Выберите, отображать ли значки обновления только когда новая версия имеет тот же базовый модель, что и локальные файлы, или всегда при наличии любого нового релиза для этой модели.", + "options": { + "sameBase": "Совпадение обновлений по базовой модели", + "any": "Отмечать любые доступные обновления" + } + }, "misc": { "includeTriggerWords": "Включать триггерные слова в синтаксис LoRA", "includeTriggerWordsHelp": "Включать обученные триггерные слова при копировании синтаксиса LoRA в буфер обмена" @@ -389,26 +418,6 @@ "proxyPassword": "Пароль (необязательно)", "proxyPasswordPlaceholder": "пароль", "proxyPasswordHelp": "Пароль для аутентификации на прокси (если требуется)" - }, - "priorityTags": { - "title": "Приоритетные теги", - "description": "Настройте порядок приоритетов тегов для каждого типа моделей (например, character, concept, style(toon|toon_style)).", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "Открыть справку по приоритетным тегам", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Чекпойнт", - "embedding": "Эмбеддинг" - }, - "saveSuccess": "Приоритетные теги обновлены.", - "saveError": "Не удалось обновить приоритетные теги.", - "loadingSuggestions": "Загрузка подсказок...", - "validation": { - "missingClosingParen": "В записи {index} отсутствует закрывающая скобка.", - "missingCanonical": "Запись {index} должна содержать каноническое имя тега.", - "duplicateCanonical": "Канонический тег \"{tag}\" встречается более одного раза.", - "unknown": "Недопустимая конфигурация приоритетных тегов." - } } }, "loras": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f1fd9cd9..1742ab69 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -227,13 +227,14 @@ "videoSettings": "视频设置", "layoutSettings": "布局设置", "folderSettings": "文件夹设置", + "priorityTags": "优先标签", "downloadPathTemplates": "下载路径模板", "exampleImages": "示例图片", + "updateFlags": "更新标记", "misc": "其他", "metadataArchive": "元数据归档数据库", "storageLocation": "设置位置", - "proxySettings": "代理设置", - "priorityTags": "优先标签" + "proxySettings": "代理设置" }, "storage": { "locationLabel": "便携模式", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", "noDefault": "无默认" }, + "priorityTags": { + "title": "优先标签", + "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "打开优先标签帮助", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "优先标签已更新。", + "saveError": "优先标签更新失败。", + "loadingSuggestions": "正在加载建议...", + "validation": { + "missingClosingParen": "条目 {index} 缺少右括号。", + "missingCanonical": "条目 {index} 必须包含规范标签名称。", + "duplicateCanonical": "规范标签 \"{tag}\" 出现多次。", + "unknown": "优先标签配置无效。" + } + }, "downloadPathTemplates": { "title": "下载路径模板", "help": "配置从 Civitai 下载不同模型类型的文件夹结构。", @@ -344,6 +365,14 @@ "download": "下载", "restartRequired": "需要重启" }, + "updateFlagStrategy": { + "label": "更新标记策略", + "help": "决定更新徽章是否仅在新版本与本地文件共享相同基础模型时显示,或只要该模型有任何更新版本就显示。", + "options": { + "sameBase": "按基础模型匹配更新", + "any": "显示任何可用更新" + } + }, "misc": { "includeTriggerWords": "复制 LoRA 语法时包含触发词", "includeTriggerWordsHelp": "复制 LoRA 语法到剪贴板时包含训练触发词" @@ -389,26 +418,6 @@ "proxyPassword": "密码 (可选)", "proxyPasswordPlaceholder": "密码", "proxyPasswordHelp": "代理认证的密码 (如果需要)" - }, - "priorityTags": { - "title": "优先标签", - "description": "为每种模型类型自定义标签优先级顺序 (例如: character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "打开优先标签帮助", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "优先标签已更新。", - "saveError": "优先标签更新失败。", - "loadingSuggestions": "正在加载建议...", - "validation": { - "missingClosingParen": "条目 {index} 缺少右括号。", - "missingCanonical": "条目 {index} 必须包含规范标签名称。", - "duplicateCanonical": "规范标签 \"{tag}\" 出现多次。", - "unknown": "优先标签配置无效。" - } } }, "loras": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c5301fb0..c8404531 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -227,13 +227,14 @@ "videoSettings": "影片設定", "layoutSettings": "版面設定", "folderSettings": "資料夾設定", + "priorityTags": "優先標籤", "downloadPathTemplates": "下載路徑範本", "exampleImages": "範例圖片", + "updateFlags": "更新標記", "misc": "其他", "metadataArchive": "中繼資料封存資料庫", "storageLocation": "設定位置", - "proxySettings": "代理設定", - "priorityTags": "優先標籤" + "proxySettings": "代理設定" }, "storage": { "locationLabel": "可攜式模式", @@ -297,6 +298,26 @@ "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", "noDefault": "未設定預設" }, + "priorityTags": { + "title": "優先標籤", + "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", + "placeholder": "character, concept, style(toon|toon_style)", + "helpLinkLabel": "開啟優先標籤說明", + "modelTypes": { + "lora": "LoRA", + "checkpoint": "Checkpoint", + "embedding": "Embedding" + }, + "saveSuccess": "優先標籤已更新。", + "saveError": "更新優先標籤失敗。", + "loadingSuggestions": "正在載入建議...", + "validation": { + "missingClosingParen": "項目 {index} 缺少右括號。", + "missingCanonical": "項目 {index} 必須包含正規標籤名稱。", + "duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。", + "unknown": "優先標籤設定無效。" + } + }, "downloadPathTemplates": { "title": "下載路徑範本", "help": "設定從 Civitai 下載時不同模型類型的資料夾結構。", @@ -344,6 +365,14 @@ "download": "下載", "restartRequired": "需要重新啟動" }, + "updateFlagStrategy": { + "label": "更新標記策略", + "help": "決定更新徽章是否僅在新版本與本地檔案共享相同基礎模型時顯示,或只要該模型有任何更新版本就顯示。", + "options": { + "sameBase": "依基礎模型匹配更新", + "any": "顯示任何可用更新" + } + }, "misc": { "includeTriggerWords": "在 LoRA 語法中包含觸發詞", "includeTriggerWordsHelp": "複製 LoRA 語法到剪貼簿時包含訓練觸發詞" @@ -389,26 +418,6 @@ "proxyPassword": "密碼(選填)", "proxyPasswordPlaceholder": "password", "proxyPasswordHelp": "代理驗證所需的密碼(如有需要)" - }, - "priorityTags": { - "title": "優先標籤", - "description": "為每種模型類型自訂標籤的優先順序 (例如: character, concept, style(toon|toon_style))", - "placeholder": "character, concept, style(toon|toon_style)", - "helpLinkLabel": "開啟優先標籤說明", - "modelTypes": { - "lora": "LoRA", - "checkpoint": "Checkpoint", - "embedding": "Embedding" - }, - "saveSuccess": "優先標籤已更新。", - "saveError": "更新優先標籤失敗。", - "loadingSuggestions": "正在載入建議...", - "validation": { - "missingClosingParen": "項目 {index} 缺少右括號。", - "missingCanonical": "項目 {index} 必須包含正規標籤名稱。", - "duplicateCanonical": "正規標籤 \"{tag}\" 出現多於一次。", - "unknown": "優先標籤設定無效。" - } } }, "loras": { diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index b7750ce1..1e00ab75 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -201,6 +201,7 @@ class SettingsHandler: "priority_tags", "model_card_footer_action", "model_name_display", + "update_flag_strategy", ) _PROXY_KEYS = {"proxy_enabled", "proxy_host", "proxy_port", "proxy_username", "proxy_password", "proxy_type"} diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index a9d1fa09..2fd393ec 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -271,7 +271,7 @@ class BaseModelService(ABC): if isinstance(strategy_value, str) and strategy_value.strip(): strategy = strategy_value.strip().lower() else: - strategy = "any" + strategy = "same_base" same_base_mode = strategy == "same_base" records = None diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 6ff96c4b..03b628da 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -61,7 +61,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), "model_name_display": "model_name", "model_card_footer_action": "example_images", - "update_flag_strategy": "any", + "update_flag_strategy": "same_base", } diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 705afc43..7a6b02ca 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -412,6 +412,11 @@ export class SettingsManager { modelNameDisplaySelect.value = state.global.settings.model_name_display || 'model_name'; } + const updateFlagStrategySelect = document.getElementById('updateFlagStrategy'); + if (updateFlagStrategySelect) { + updateFlagStrategySelect.value = state.global.settings.update_flag_strategy || 'same_base'; + } + // Set optimize example images setting const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages'); if (optimizeExampleImagesCheckbox) { @@ -1334,11 +1339,7 @@ export class SettingsManager { showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success'); - if (settingKey === 'model_name_display') { - this.reloadContent(); - } - - if (settingKey === 'model_card_footer_action') { + if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') { this.reloadContent(); } } catch (error) { diff --git a/static/js/state/index.js b/static/js/state/index.js index ee54c066..b8157f66 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -33,6 +33,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ include_trigger_words: false, compact_mode: false, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, + update_flag_strategy: 'same_base', }); export function createDefaultSettings() { diff --git a/templates/components/modals/settings_modal.html b/templates/components/modals/settings_modal.html index 4b40e976..3efcea9a 100644 --- a/templates/components/modals/settings_modal.html +++ b/templates/components/modals/settings_modal.html @@ -320,6 +320,27 @@ + +
+

{{ t('settings.sections.updateFlags') }}

+
+
+
+ +
+
+ +
+
+
+ {{ t('settings.updateFlagStrategy.help') }} +
+
+
+

{{ t('settings.downloadPathTemplates.title') }}

From 655157434ec8759f77cf7acd0778752b22b2ab8b Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 18 Nov 2025 06:46:50 +0800 Subject: [PATCH 3/3] feat(versions): add base filter toggle UI and styling Add CSS classes and JavaScript logic for the base filter toggle button in the versions toolbar. The filter allows users to switch between showing all versions or only versions matching the current base model. Includes styling for different states (active, hover, disabled) and accessibility features like screen reader support. --- locales/de.json | 12 + locales/en.json | 12 + locales/es.json | 12 + locales/fr.json | 12 + locales/he.json | 12 + locales/ja.json | 12 + locales/ko.json | 12 + locales/ru.json | 12 + locales/zh-CN.json | 12 + locales/zh-TW.json | 12 + static/css/components/lora-modal/versions.css | 52 ++++ .../js/components/shared/ModelVersionsTab.js | 262 ++++++++++++++---- .../components/modelVersionsTab.media.test.js | 139 +++++++++- 13 files changed, 521 insertions(+), 52 deletions(-) diff --git a/locales/de.json b/locales/de.json index 15b4cf47..e6a43fdc 100644 --- a/locales/de.json +++ b/locales/de.json @@ -938,6 +938,18 @@ "viewLocalVersions": "Alle lokalen Versionen anzeigen", "viewLocalTooltip": "Demnächst verfügbar" }, + "filters": { + "label": "Basisfilter", + "state": { + "showAll": "Alle Versionen", + "showSameBase": "Gleiches Basismodell" + }, + "tooltip": { + "showAllVersions": "Wechseln, um alle Versionen anzuzeigen", + "showSameBaseVersions": "Wechseln, um nur Versionen mit demselben Basismodell anzuzeigen" + }, + "empty": "Keine Versionen entsprechen dem Filter für das aktuelle Basismodell." + }, "empty": "Noch keine Versionshistorie für dieses Modell vorhanden.", "error": "Versionen konnten nicht geladen werden.", "missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.", diff --git a/locales/en.json b/locales/en.json index 7ac844da..19ed4b4f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -938,6 +938,18 @@ "viewLocalVersions": "View all local versions", "viewLocalTooltip": "Coming soon" }, + "filters": { + "label": "Base filter", + "state": { + "showAll": "All versions", + "showSameBase": "Same base" + }, + "tooltip": { + "showAllVersions": "Switch to showing all versions", + "showSameBaseVersions": "Switch to showing only versions that match the current base model" + }, + "empty": "No versions match the current base model filter." + }, "empty": "No version history available for this model yet.", "error": "Failed to load versions.", "missingModelId": "This model is missing a Civitai model id.", diff --git a/locales/es.json b/locales/es.json index 89333ca8..4395c7e7 100644 --- a/locales/es.json +++ b/locales/es.json @@ -938,6 +938,18 @@ "viewLocalVersions": "Ver todas las versiones locales", "viewLocalTooltip": "Disponible pronto" }, + "filters": { + "label": "Filtro base", + "state": { + "showAll": "Todas las versiones", + "showSameBase": "Mismo modelo base" + }, + "tooltip": { + "showAllVersions": "Cambiar para mostrar todas las versiones", + "showSameBaseVersions": "Cambiar para mostrar solo versiones del mismo modelo base" + }, + "empty": "Ninguna versión coincide con el filtro del modelo base actual." + }, "empty": "Aún no hay historial de versiones para este modelo.", "error": "No se pudieron cargar las versiones.", "missingModelId": "Este modelo no tiene un ID de modelo de Civitai.", diff --git a/locales/fr.json b/locales/fr.json index cf3c8ce3..68261795 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -938,6 +938,18 @@ "viewLocalVersions": "Voir toutes les versions locales", "viewLocalTooltip": "Bientôt disponible" }, + "filters": { + "label": "Filtre de base", + "state": { + "showAll": "Toutes les versions", + "showSameBase": "Même modèle de base" + }, + "tooltip": { + "showAllVersions": "Passer à l'affichage de toutes les versions", + "showSameBaseVersions": "Passer à l'affichage des versions du même modèle de base" + }, + "empty": "Aucune version ne correspond au filtre du modèle de base actuel." + }, "empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.", "error": "Échec du chargement des versions.", "missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.", diff --git a/locales/he.json b/locales/he.json index ac9571c6..ec33f7db 100644 --- a/locales/he.json +++ b/locales/he.json @@ -938,6 +938,18 @@ "viewLocalVersions": "הצג את כל הגרסאות המקומיות", "viewLocalTooltip": "יגיע בקרוב" }, + "filters": { + "label": "מסנן בסיס", + "state": { + "showAll": "כל הגרסאות", + "showSameBase": "אותו מודל בסיס" + }, + "tooltip": { + "showAllVersions": "החלף להצגת כל הגרסאות", + "showSameBaseVersions": "החלף להצגת גרסאות עם אותו מודל בסיס" + }, + "empty": "אין גרסאות התואמות את המסנן של מודל הבסיס הנוכחי." + }, "empty": "אין עדיין היסטוריית גרסאות למודל זה.", "error": "טעינת הגרסאות נכשלה.", "missingModelId": "למודל זה אין מזהה מודל של Civitai.", diff --git a/locales/ja.json b/locales/ja.json index ad5b300f..45ff3c10 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -938,6 +938,18 @@ "viewLocalVersions": "ローカルの全バージョンを表示", "viewLocalTooltip": "近日対応予定" }, + "filters": { + "label": "ベースフィルター", + "state": { + "showAll": "すべてのバージョン", + "showSameBase": "同じベース" + }, + "tooltip": { + "showAllVersions": "すべてのバージョンを表示する", + "showSameBaseVersions": "同じベースモデルのバージョンのみ表示する" + }, + "empty": "現在のベースモデルフィルターに一致するバージョンがありません。" + }, "empty": "このモデルにはまだバージョン履歴がありません。", "error": "バージョンの読み込みに失敗しました。", "missingModelId": "このモデルにはCivitaiのモデルIDがありません。", diff --git a/locales/ko.json b/locales/ko.json index 2fef79b2..ccedfc57 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -938,6 +938,18 @@ "viewLocalVersions": "로컬 버전 모두 보기", "viewLocalTooltip": "곧 제공 예정" }, + "filters": { + "label": "기본 필터", + "state": { + "showAll": "모든 버전", + "showSameBase": "같은 베이스" + }, + "tooltip": { + "showAllVersions": "모든 버전을 표시하도록 전환", + "showSameBaseVersions": "같은 베이스 모델 버전만 표시하도록 전환" + }, + "empty": "현재 베이스 모델 필터와 일치하는 버전이 없습니다." + }, "empty": "이 모델에는 아직 버전 기록이 없습니다.", "error": "버전을 불러오지 못했습니다.", "missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.", diff --git a/locales/ru.json b/locales/ru.json index 2d739730..d703ca4e 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -938,6 +938,18 @@ "viewLocalVersions": "Показать все локальные версии", "viewLocalTooltip": "Скоро появится" }, + "filters": { + "label": "Фильтр по базе", + "state": { + "showAll": "Все версии", + "showSameBase": "Тот же базовый" + }, + "tooltip": { + "showAllVersions": "Переключиться на отображение всех версий", + "showSameBaseVersions": "Переключиться на отображение только версий с тем же базовым" + }, + "empty": "Нет версий, соответствующих текущему фильтру базовой модели." + }, "empty": "Для этой модели пока нет истории версий.", "error": "Не удалось загрузить версии.", "missingModelId": "У этой модели отсутствует идентификатор модели Civitai.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 1742ab69..e3804de8 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -938,6 +938,18 @@ "viewLocalVersions": "查看所有本地版本", "viewLocalTooltip": "敬请期待" }, + "filters": { + "label": "基础筛选", + "state": { + "showAll": "全部版本", + "showSameBase": "相同基模型" + }, + "tooltip": { + "showAllVersions": "切换为显示所有版本", + "showSameBaseVersions": "仅显示与当前基模型匹配的版本" + }, + "empty": "没有与当前基模型筛选匹配的版本。" + }, "empty": "该模型还没有版本历史。", "error": "加载版本失败。", "missingModelId": "该模型缺少 Civitai 模型 ID。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c8404531..9416fd8a 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -938,6 +938,18 @@ "viewLocalVersions": "檢視所有本地版本", "viewLocalTooltip": "敬請期待" }, + "filters": { + "label": "基礎篩選", + "state": { + "showAll": "所有版本", + "showSameBase": "相同基礎模型" + }, + "tooltip": { + "showAllVersions": "切換為顯示所有版本", + "showSameBaseVersions": "僅顯示與目前基礎模型相符的版本" + }, + "empty": "沒有符合目前基礎模型篩選的版本。" + }, "empty": "此模型尚無版本歷史。", "error": "載入版本失敗。", "missingModelId": "此模型缺少 Civitai 模型 ID。", diff --git a/static/css/components/lora-modal/versions.css b/static/css/components/lora-modal/versions.css index 1eaa9eac..929bf6d4 100644 --- a/static/css/components/lora-modal/versions.css +++ b/static/css/components/lora-modal/versions.css @@ -24,12 +24,29 @@ color: var(--text-color); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + .versions-toolbar-info p { margin: 0; font-size: 0.85rem; color: var(--text-muted); } +.versions-toolbar-info-heading { + display: flex; + align-items: center; + gap: var(--space-2); +} + .versions-toolbar-actions { display: flex; flex-wrap: wrap; @@ -68,6 +85,41 @@ color: var(--text-color); } +.versions-filter-toggle { + appearance: none; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + padding: 0; + margin-bottom: 4px; + width: 30px; + height: 30px; + background: color-mix(in oklch, var(--card-bg) 80%, var(--bg-color)); + align-self: center; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease, transform 0.2s ease; + position: relative; + cursor: pointer; +} + +.versions-filter-toggle i { + font-size: 1rem; +} + +.versions-filter-toggle:hover:not(:disabled) { + border-color: var(--text-color); + color: var(--text-color); + transform: translateY(-1px); +} + +.versions-filter-toggle[data-filter-active="true"] { + border-color: color-mix(in oklch, var(--lora-accent) 65%, transparent); + color: var(--lora-accent); + background: color-mix(in oklch, var(--lora-accent) 20%, var(--card-bg) 80%); +} + .versions-toolbar-btn:disabled { opacity: 0.6; cursor: not-allowed; diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js index 8aff2efd..8b04e25c 100644 --- a/static/js/components/shared/ModelVersionsTab.js +++ b/static/js/components/shared/ModelVersionsTab.js @@ -152,6 +152,81 @@ function buildBadge(label, tone) { return `${escapeHtml(label)}`; } +const DISPLAY_FILTER_MODES = Object.freeze({ + SAME_BASE: 'same_base', + ANY: 'any', +}); + +const FILTER_LABEL_KEY = 'modals.model.versions.filters.label'; +const FILTER_STATE_KEYS = { + [DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.state.showSameBase', + [DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.state.showAll', +}; +const FILTER_TOOLTIP_KEYS = { + [DISPLAY_FILTER_MODES.SAME_BASE]: 'modals.model.versions.filters.tooltip.showAllVersions', + [DISPLAY_FILTER_MODES.ANY]: 'modals.model.versions.filters.tooltip.showSameBaseVersions', +}; + +function normalizeBaseModelName(value) { + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return trimmed.toLowerCase(); +} + +function getToggleLabelText() { + return translate(FILTER_LABEL_KEY, {}, 'Base filter'); +} + +function getToggleStateText(mode) { + const key = FILTER_STATE_KEYS[mode] || FILTER_STATE_KEYS[DISPLAY_FILTER_MODES.ANY]; + const fallback = + mode === DISPLAY_FILTER_MODES.SAME_BASE ? 'Same base' : 'All versions'; + return translate(key, {}, fallback); +} + +function getToggleTooltipText(mode) { + const key = + FILTER_TOOLTIP_KEYS[mode] || FILTER_TOOLTIP_KEYS[DISPLAY_FILTER_MODES.ANY]; + const fallback = + mode === DISPLAY_FILTER_MODES.SAME_BASE + ? 'Switch to showing all versions' + : 'Switch to showing only versions with the current base model'; + return translate(key, {}, fallback); +} + +function getDefaultDisplayMode() { + const strategy = state?.global?.settings?.update_flag_strategy; + return strategy === DISPLAY_FILTER_MODES.SAME_BASE + ? DISPLAY_FILTER_MODES.SAME_BASE + : DISPLAY_FILTER_MODES.ANY; +} + +function getCurrentVersionBaseModel(record, versionId) { + if (!record || typeof versionId !== 'number' || !Array.isArray(record.versions)) { + return { + normalized: null, + raw: null, + }; + } + const currentVersion = record.versions.find(v => v.versionId === versionId); + if (!currentVersion) { + return { + normalized: null, + raw: null, + }; + } + const baseModelRaw = currentVersion.baseModel ?? null; + return { + normalized: normalizeBaseModelName(baseModelRaw), + raw: baseModelRaw, + }; +} + function getAutoplaySetting() { try { return Boolean(state?.global?.settings?.autoplay_on_hover); @@ -314,7 +389,7 @@ function getLatestLibraryVersionId(record) { return Math.max(...record.inLibraryVersionIds); } -function renderToolbar(record) { +function renderToolbar(record, toolbarState = {}) { const ignoreText = record.shouldIgnore ? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model') : translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model'); @@ -325,10 +400,23 @@ function renderToolbar(record) { 'Track and manage every version of this model in one place.' ); + const displayMode = toolbarState.displayMode || DISPLAY_FILTER_MODES.ANY; + const toggleLabel = getToggleLabelText(); + const toggleState = getToggleStateText(displayMode); + const toggleTooltip = getToggleTooltipText(displayMode); + const filterActive = toolbarState.isFilteringActive ? 'true' : 'false'; + const screenReaderText = [toggleLabel, toggleState].filter(Boolean).join(': '); + return `
-

${translate('modals.model.versions.heading', {}, 'Model versions')}

+
+

${translate('modals.model.versions.heading', {}, 'Model versions')}

+ +

${escapeHtml(infoText)}

@@ -353,6 +441,20 @@ function renderEmptyState(container) { `; } +function renderFilteredEmptyState(baseModelLabel) { + const message = translate( + 'modals.model.versions.filters.empty', + { baseModel: baseModelLabel }, + 'No versions match the current base model filter.' + ); + return ` +
+ +

${escapeHtml(message)}

+
+ `; +} + function renderErrorState(container, message) { const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.'); container.innerHTML = ` @@ -391,6 +493,8 @@ export function initVersionsTab({ record: null, }; + let displayMode = getDefaultDisplayMode(); + let apiClient; function ensureClient() { @@ -414,55 +518,92 @@ export function initVersionsTab({ `; } - function render(record) { - controller.record = record; - controller.hasLoaded = true; +function render(record) { + controller.record = record; + controller.hasLoaded = true; - if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { - renderEmptyState(container); - return; - } - - const latestLibraryVersionId = getLatestLibraryVersionId(record); - let dividerInserted = false; - - const sortedVersions = [...record.versions].sort( - (a, b) => Number(b.versionId) - Number(a.versionId) - ); - - const rowsMarkup = sortedVersions - .map(version => { - const isNewer = - typeof latestLibraryVersionId === 'number' && - version.versionId > latestLibraryVersionId; - let markup = ''; - if ( - !dividerInserted && - typeof latestLibraryVersionId === 'number' && - !isNewer - ) { - dividerInserted = true; - markup += ''; - } - markup += renderRow(version, { - latestLibraryVersionId, - currentVersionId: normalizedCurrentVersionId, - modelId: record?.modelId ?? modelId, - }); - return markup; - }) - .join(''); - - container.innerHTML = ` - ${renderToolbar(record)} -
- ${rowsMarkup} -
- `; - - setupMediaHoverInteractions(container); + if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { + renderEmptyState(container); + return; } + const latestLibraryVersionId = getLatestLibraryVersionId(record); + const { normalized: currentBaseModelNormalized, raw: currentBaseModelLabel } = + getCurrentVersionBaseModel(record, normalizedCurrentVersionId); + const isFilteringActive = + displayMode === DISPLAY_FILTER_MODES.SAME_BASE && + Boolean(currentBaseModelNormalized); + + const sortedVersions = [...record.versions].sort( + (a, b) => Number(b.versionId) - Number(a.versionId) + ); + + const filteredVersions = sortedVersions.filter(version => { + if (!isFilteringActive) { + return true; + } + return normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized; + }); + + const dividerThresholdVersionId = (() => { + if (!isFilteringActive) { + return latestLibraryVersionId; + } + const baseLocalVersionIds = record.versions + .filter( + version => + version.isInLibrary && + normalizeBaseModelName(version.baseModel) === currentBaseModelNormalized && + typeof version.versionId === 'number' + ) + .map(version => version.versionId); + if (!baseLocalVersionIds.length) { + return null; + } + return Math.max(...baseLocalVersionIds); + })(); + + let dividerInserted = false; + + const rowsMarkup = filteredVersions + .map(version => { + const isNewer = + typeof latestLibraryVersionId === 'number' && + version.versionId > latestLibraryVersionId; + let markup = ''; + if ( + !dividerInserted && + typeof dividerThresholdVersionId === 'number' && + !(version.versionId > dividerThresholdVersionId) + ) { + dividerInserted = true; + markup += ''; + } + markup += renderRow(version, { + latestLibraryVersionId, + currentVersionId: normalizedCurrentVersionId, + modelId: record?.modelId ?? modelId, + }); + return markup; + }) + .join(''); + + const listContent = + rowsMarkup || renderFilteredEmptyState(currentBaseModelLabel); + + container.innerHTML = ` + ${renderToolbar(record, { + displayMode, + isFilteringActive, + })} +
+ ${listContent} +
+ `; + + setupMediaHoverInteractions(container); +} + async function loadVersions({ forceRefresh = false, eager = false } = {}) { if (controller.isLoading) { return; @@ -531,6 +672,17 @@ export function initVersionsTab({ } } + function handleToggleVersionDisplayMode() { + displayMode = + displayMode === DISPLAY_FILTER_MODES.SAME_BASE + ? DISPLAY_FILTER_MODES.ANY + : DISPLAY_FILTER_MODES.SAME_BASE; + if (!controller.record) { + return; + } + render(controller.record); + } + async function handleToggleVersionIgnore(button, versionId) { if (!controller.record) { return; @@ -799,9 +951,17 @@ export function initVersionsTab({ const toolbarAction = event.target.closest('[data-versions-action]'); if (toolbarAction) { const action = toolbarAction.dataset.versionsAction; - if (action === 'toggle-model-ignore') { - event.preventDefault(); - await handleToggleModelIgnore(toolbarAction); + switch (action) { + case 'toggle-model-ignore': + event.preventDefault(); + await handleToggleModelIgnore(toolbarAction); + break; + case 'toggle-version-display-mode': + event.preventDefault(); + handleToggleVersionDisplayMode(); + break; + default: + break; } return; } diff --git a/tests/frontend/components/modelVersionsTab.media.test.js b/tests/frontend/components/modelVersionsTab.media.test.js index b8b3a287..a4c6af7c 100644 --- a/tests/frontend/components/modelVersionsTab.media.test.js +++ b/tests/frontend/components/modelVersionsTab.media.test.js @@ -28,7 +28,14 @@ vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn(), })); -const stateMock = { global: { settings: { autoplay_on_hover: false } } }; +const stateMock = { + global: { + settings: { + autoplay_on_hover: false, + update_flag_strategy: 'any', + }, + }, +}; vi.mock(STATE_MODULE, () => ({ state: stateMock, })); @@ -59,6 +66,7 @@ describe('ModelVersionsTab media rendering', () => {
`; stateMock.global.settings.autoplay_on_hover = false; + stateMock.global.settings.update_flag_strategy = 'any'; ({ getModelApiClient } = await import(API_FACTORY_MODULE)); fetchModelUpdateVersions = vi.fn(); getModelApiClient.mockReturnValue({ @@ -146,4 +154,133 @@ describe('ModelVersionsTab media rendering', () => { expect(imageElement?.getAttribute('src')).toBe(previewUrl); expect(document.querySelector('.version-media video')).toBeFalsy(); }); + + it('shows a stable label with a short state indicator', async () => { + stateMock.global.settings.update_flag_strategy = 'any'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [5], + versions: [ + { + versionId: 5, + name: 'base', + baseModel: 'SDXL', + previewUrl: '/api/lm/previews/v-base.png', + sizeBytes: 1024, + isInLibrary: true, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 321, + currentVersionId: 5, + }); + + await controller.load(); + + const toggleText = document.querySelector('.versions-filter-toggle .sr-only'); + expect(toggleText?.textContent?.trim()).toBe('Base filter: All versions'); + }); + + it('filters versions to the current base model when strategy is same_base', async () => { + stateMock.global.settings.update_flag_strategy = 'same_base'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [10], + versions: [ + { + versionId: 10, + name: 'v1.0', + baseModel: 'SDXL', + previewUrl: '/api/lm/previews/v1.png', + sizeBytes: 1024, + isInLibrary: true, + shouldIgnore: false, + }, + { + versionId: 11, + name: 'v1.1', + baseModel: 'Realistic', + previewUrl: '/api/lm/previews/v1-1.png', + sizeBytes: 2048, + isInLibrary: false, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 789, + currentVersionId: 10, + }); + + await controller.load(); + + expect(document.querySelectorAll('.model-version-row').length).toBe(1); + }); + + it('toggle button can switch to display all versions', async () => { + stateMock.global.settings.update_flag_strategy = 'same_base'; + fetchModelUpdateVersions.mockResolvedValue({ + success: true, + record: { + shouldIgnore: false, + inLibraryVersionIds: [10], + versions: [ + { + versionId: 10, + name: 'v1.0', + baseModel: 'SDXL', + previewUrl: '/api/lm/previews/v1.png', + sizeBytes: 1024, + isInLibrary: true, + shouldIgnore: false, + }, + { + versionId: 11, + name: 'v1.1', + baseModel: 'Realistic', + previewUrl: '/api/lm/previews/v1-1.png', + sizeBytes: 2048, + isInLibrary: false, + shouldIgnore: false, + }, + ], + }, + }); + + const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE); + const controller = initVersionsTab({ + modalId: 'model-versions-modal', + modelType: 'loras', + modelId: 987, + currentVersionId: 10, + }); + + await controller.load(); + + expect(document.querySelectorAll('.model-version-row').length).toBe(1); + const toggleButton = document.querySelector('[data-versions-action="toggle-version-display-mode"]'); + expect(toggleButton).toBeTruthy(); + const toggleTextBefore = document.querySelector('.versions-filter-toggle .sr-only'); + expect(toggleTextBefore?.textContent?.trim()).toContain('Same base'); + toggleButton?.click(); + expect(document.querySelectorAll('.model-version-row').length).toBe(2); + const toggleTextAfter = document.querySelector('.versions-filter-toggle .sr-only'); + expect(toggleTextAfter?.textContent?.trim()).toContain('All versions'); + }); });