diff --git a/locales/de.json b/locales/de.json index 2bf65cbf..79b2de11 100644 --- a/locales/de.json +++ b/locales/de.json @@ -175,6 +175,9 @@ "success": "{count} Rezepte erfolgreich repariert.", "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "error": "Recipe-Reparatur fehlgeschlagen: {message}" + }, + "manageExcludedModels": { + "label": "Ausgeschlossene Modelle verwalten" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "In Ordner verschieben", "repairMetadata": "Metadaten reparieren", "excludeModel": "Modell ausschließen", + "restoreModel": "Modell wiederherstellen", "deleteModel": "Modell löschen", "shareRecipe": "Rezept teilen", "viewAllLoras": "Alle LoRAs anzeigen", @@ -1803,6 +1807,8 @@ "deleteFailed": "Fehler beim Löschen von {type}: {message}", "excludeSuccess": "{type} erfolgreich ausgeschlossen", "excludeFailed": "Fehler beim Ausschließen von {type}: {message}", + "restoreSuccess": "{type} erfolgreich wiederhergestellt", + "restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}", "fileNameUpdated": "Dateiname erfolgreich aktualisiert", "fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}", "previewUpdated": "Vorschau erfolgreich aktualisiert", diff --git a/locales/en.json b/locales/en.json index debda27b..c2382e5a 100644 --- a/locales/en.json +++ b/locales/en.json @@ -175,6 +175,9 @@ "success": "Successfully repaired {count} recipes.", "cancelled": "Repair cancelled. {count} recipes were repaired.", "error": "Recipe repair failed: {message}" + }, + "manageExcludedModels": { + "label": "Manage Excluded Models" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "Move to Folder", "repairMetadata": "Repair metadata", "excludeModel": "Exclude Model", + "restoreModel": "Restore Model", "deleteModel": "Delete Model", "shareRecipe": "Share Recipe", "viewAllLoras": "View All LoRAs", @@ -1803,6 +1807,8 @@ "deleteFailed": "Failed to delete {type}: {message}", "excludeSuccess": "{type} excluded successfully", "excludeFailed": "Failed to exclude {type}: {message}", + "restoreSuccess": "{type} restored successfully", + "restoreFailed": "Failed to restore {type}: {message}", "fileNameUpdated": "File name updated successfully", "fileRenameFailed": "Failed to rename file: {error}", "previewUpdated": "Preview updated successfully", diff --git a/locales/es.json b/locales/es.json index b54412a0..a49b704b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -175,6 +175,9 @@ "success": "Se repararon con éxito {count} recetas.", "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "error": "Error al reparar recetas: {message}" + }, + "manageExcludedModels": { + "label": "Gestionar modelos excluidos" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "Mover a carpeta", "repairMetadata": "Reparar metadatos", "excludeModel": "Excluir modelo", + "restoreModel": "Restaurar modelo", "deleteModel": "Eliminar modelo", "shareRecipe": "Compartir receta", "viewAllLoras": "Ver todos los LoRAs", @@ -1803,6 +1807,8 @@ "deleteFailed": "Error al eliminar {type}: {message}", "excludeSuccess": "{type} excluido exitosamente", "excludeFailed": "Error al excluir {type}: {message}", + "restoreSuccess": "{type} restaurado correctamente", + "restoreFailed": "No se pudo restaurar {type}: {message}", "fileNameUpdated": "Nombre de archivo actualizado exitosamente", "fileRenameFailed": "Error al renombrar archivo: {error}", "previewUpdated": "Vista previa actualizada exitosamente", diff --git a/locales/fr.json b/locales/fr.json index 12d800fb..d809f4bb 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -175,6 +175,9 @@ "success": "{count} recettes réparées avec succès.", "cancelled": "Réparation annulée. {count} recettes ont été réparées.", "error": "Échec de la réparation des recettes : {message}" + }, + "manageExcludedModels": { + "label": "Gérer les modèles exclus" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "Déplacer vers un dossier", "repairMetadata": "Réparer les métadonnées", "excludeModel": "Exclure le modèle", + "restoreModel": "Restaurer le modèle", "deleteModel": "Supprimer le modèle", "shareRecipe": "Partager la recipe", "viewAllLoras": "Voir tous les LoRAs", @@ -1803,6 +1807,8 @@ "deleteFailed": "Échec de la suppression de {type} : {message}", "excludeSuccess": "{type} exclu avec succès", "excludeFailed": "Échec de l'exclusion de {type} : {message}", + "restoreSuccess": "{type} restauré avec succès", + "restoreFailed": "Échec de la restauration de {type} : {message}", "fileNameUpdated": "Nom de fichier mis à jour avec succès", "fileRenameFailed": "Échec du renommage du fichier : {error}", "previewUpdated": "Aperçu mis à jour avec succès", diff --git a/locales/he.json b/locales/he.json index 8aab896d..94a7131f 100644 --- a/locales/he.json +++ b/locales/he.json @@ -175,6 +175,9 @@ "success": "תוקנו בהצלחה {count} מתכונים.", "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "error": "תיקון המתכונים נכשל: {message}" + }, + "manageExcludedModels": { + "label": "ניהול מודלים מוחרגים" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "העבר לתיקייה", "repairMetadata": "תיקון מטא-דאטה", "excludeModel": "החרג מודל", + "restoreModel": "שחזור מודל", "deleteModel": "מחק מודל", "shareRecipe": "שתף מתכון", "viewAllLoras": "הצג את כל ה-LoRAs", @@ -1803,6 +1807,8 @@ "deleteFailed": "מחיקת {type} נכשלה: {message}", "excludeSuccess": "{type} הוחרג בהצלחה", "excludeFailed": "החרגת {type} נכשלה: {message}", + "restoreSuccess": "{type} שוחזר בהצלחה", + "restoreFailed": "שחזור {type} נכשל: {message}", "fileNameUpdated": "שם הקובץ עודכן בהצלחה", "fileRenameFailed": "שינוי שם הקובץ נכשל: {error}", "previewUpdated": "התצוגה המקדימה עודכנה בהצלחה", diff --git a/locales/ja.json b/locales/ja.json index 6b84ed0b..d5a04e21 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -175,6 +175,9 @@ "success": "{count} 件のレシピを正常に修復しました。", "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "error": "レシピの修復に失敗しました: {message}" + }, + "manageExcludedModels": { + "label": "除外モデルを管理" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "フォルダに移動", "repairMetadata": "メタデータを修復", "excludeModel": "モデルを除外", + "restoreModel": "モデルを復元", "deleteModel": "モデルを削除", "shareRecipe": "レシピを共有", "viewAllLoras": "すべてのLoRAを表示", @@ -1803,6 +1807,8 @@ "deleteFailed": "{type}の削除に失敗しました:{message}", "excludeSuccess": "{type}が正常に除外されました", "excludeFailed": "{type}の除外に失敗しました:{message}", + "restoreSuccess": "{type}を復元しました", + "restoreFailed": "{type}の復元に失敗しました: {message}", "fileNameUpdated": "ファイル名が正常に更新されました", "fileRenameFailed": "ファイル名の変更に失敗しました:{error}", "previewUpdated": "プレビューが正常に更新されました", diff --git a/locales/ko.json b/locales/ko.json index a3d8569c..15bfda7b 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -175,6 +175,9 @@ "success": "{count}개의 레시피가 성공적으로 복구되었습니다.", "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "error": "레시피 복구 실패: {message}" + }, + "manageExcludedModels": { + "label": "제외된 모델 관리" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "폴더로 이동", "repairMetadata": "메타데이터 복구", "excludeModel": "모델 제외", + "restoreModel": "모델 복원", "deleteModel": "모델 삭제", "shareRecipe": "레시피 공유", "viewAllLoras": "모든 LoRA 보기", @@ -1803,6 +1807,8 @@ "deleteFailed": "{type} 삭제 실패: {message}", "excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다", "excludeFailed": "{type} 제외 실패: {message}", + "restoreSuccess": "{type} 복원 완료", + "restoreFailed": "{type} 복원 실패: {message}", "fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다", "fileRenameFailed": "파일 이름 변경 실패: {error}", "previewUpdated": "미리보기가 성공적으로 업데이트되었습니다", diff --git a/locales/ru.json b/locales/ru.json index d21ca52a..966993d8 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -175,6 +175,9 @@ "success": "Успешно восстановлено {count} рецептов.", "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "error": "Ошибка восстановления рецептов: {message}" + }, + "manageExcludedModels": { + "label": "Управление исключёнными моделями" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "Переместить в папку", "repairMetadata": "Восстановить метаданные", "excludeModel": "Исключить модель", + "restoreModel": "Восстановить модель", "deleteModel": "Удалить модель", "shareRecipe": "Поделиться рецептом", "viewAllLoras": "Посмотреть все LoRAs", @@ -1803,6 +1807,8 @@ "deleteFailed": "Не удалось удалить {type}: {message}", "excludeSuccess": "{type} успешно исключен", "excludeFailed": "Не удалось исключить {type}: {message}", + "restoreSuccess": "{type} успешно восстановлен", + "restoreFailed": "Не удалось восстановить {type}: {message}", "fileNameUpdated": "Имя файла успешно обновлено", "fileRenameFailed": "Не удалось переименовать файл: {error}", "previewUpdated": "Превью успешно обновлено", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 52979a9a..b8a79922 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -175,6 +175,9 @@ "success": "成功修复了 {count} 个配方。", "cancelled": "修复已取消。已修复 {count} 个配方。", "error": "配方修复失败:{message}" + }, + "manageExcludedModels": { + "label": "管理已排除的模型" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "移动到文件夹", "repairMetadata": "修复元数据", "excludeModel": "排除模型", + "restoreModel": "恢复模型", "deleteModel": "删除模型", "shareRecipe": "分享配方", "viewAllLoras": "查看所有 LoRA", @@ -1803,6 +1807,8 @@ "deleteFailed": "删除 {type} 失败:{message}", "excludeSuccess": "{type} 排除成功", "excludeFailed": "排除 {type} 失败:{message}", + "restoreSuccess": "{type} 已成功恢复", + "restoreFailed": "恢复 {type} 失败:{message}", "fileNameUpdated": "文件名更新成功", "fileRenameFailed": "重命名文件失败:{error}", "previewUpdated": "预览图片更新成功", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 908b4ac5..b75e68d1 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -175,6 +175,9 @@ "success": "成功修復 {count} 個配方。", "cancelled": "修復已取消。已修復 {count} 個配方。", "error": "配方修復失敗:{message}" + }, + "manageExcludedModels": { + "label": "管理已排除的模型" } }, "header": { @@ -680,6 +683,7 @@ "moveToFolder": "移動到資料夾", "repairMetadata": "修復元數據", "excludeModel": "排除模型", + "restoreModel": "還原模型", "deleteModel": "刪除模型", "shareRecipe": "分享配方", "viewAllLoras": "檢視全部 LoRA", @@ -1803,6 +1807,8 @@ "deleteFailed": "刪除 {type} 失敗:{message}", "excludeSuccess": "{type} 已成功排除", "excludeFailed": "排除 {type} 失敗:{message}", + "restoreSuccess": "{type} 已成功還原", + "restoreFailed": "還原 {type} 失敗:{message}", "fileNameUpdated": "檔案名稱已成功更新", "fileRenameFailed": "重新命名檔案失敗:{error}", "previewUpdated": "預覽圖片已成功更新", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 8f91b5de..c5b2babd 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -224,6 +224,42 @@ class ModelListingHandler: ) return web.json_response({"error": str(exc)}, status=500) + async def get_excluded_models(self, request: web.Request) -> web.Response: + start_time = time.perf_counter() + try: + params = self._parse_common_params(request) + result = await self._service.get_excluded_paginated_data(**params) + + format_start = time.perf_counter() + formatted_result = { + "items": [ + await self._service.format_response(item) + for item in result["items"] + ], + "total": result["total"], + "page": result["page"], + "page_size": result["page_size"], + "total_pages": result["total_pages"], + } + format_duration = time.perf_counter() - format_start + + duration = time.perf_counter() - start_time + self._logger.debug( + "Request for %s/excluded took %.3fs (formatting: %.3fs)", + self._service.model_type, + duration, + format_duration, + ) + return web.json_response(formatted_result) + except Exception as exc: + self._logger.error( + "Error retrieving excluded %ss: %s", + self._service.model_type, + exc, + exc_info=True, + ) + return web.json_response({"error": str(exc)}, status=500) + def _parse_common_params(self, request: web.Request) -> Dict: page = int(request.query.get("page", "1")) page_size = min(int(request.query.get("page_size", "20")), 100) @@ -392,6 +428,21 @@ class ModelManagementHandler: self._logger.error("Error excluding model: %s", exc, exc_info=True) return web.Response(text=str(exc), status=500) + async def unexclude_model(self, request: web.Request) -> web.Response: + try: + data = await request.json() + file_path = data.get("file_path") + if not file_path: + return web.Response(text="Model path is required", status=400) + + result = await self._lifecycle_service.unexclude_model(file_path) + return web.json_response(result) + except ValueError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=400) + except Exception as exc: + self._logger.error("Error restoring model: %s", exc, exc_info=True) + return web.Response(text=str(exc), status=500) + async def fetch_civitai(self, request: web.Request) -> web.Response: try: data = await request.json() @@ -2437,8 +2488,10 @@ class ModelHandlerSet: return { "handle_models_page": self.page_view.handle, "get_models": self.listing.get_models, + "get_excluded_models": self.listing.get_excluded_models, "delete_model": self.management.delete_model, "exclude_model": self.management.exclude_model, + "unexclude_model": self.management.unexclude_model, "fetch_civitai": self.management.fetch_civitai, "fetch_all_civitai": self.civitai.fetch_all_civitai, "relink_civitai": self.management.relink_civitai, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index d3212cdc..cd0f208a 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -22,8 +22,10 @@ class RouteDefinition: COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"), + RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"), RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"), RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"), + RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"), RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"), diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 2eb88138..dad5ad4e 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -179,6 +179,57 @@ class BaseModelService(ABC): ) return paginated + async def get_excluded_paginated_data( + self, + page: int, + page_size: int, + sort_by: str = "name", + search: str = None, + fuzzy_search: bool = False, + search_options: dict = None, + **kwargs, + ) -> Dict: + """Get paginated excluded model data.""" + excluded_paths = list(self.scanner.get_excluded_models()) + excluded_entries: List[Dict[str, Any]] = [] + stale_paths: List[str] = [] + + for file_path in excluded_paths: + if not file_path or not os.path.exists(file_path): + stale_paths.append(file_path) + continue + + entry = await self._build_excluded_entry(file_path) + if entry: + excluded_entries.append(entry) + else: + stale_paths.append(file_path) + + if stale_paths: + current_excluded = getattr(self.scanner, "_excluded_models", None) + if isinstance(current_excluded, list): + stale_set = set(stale_paths) + self.scanner._excluded_models = [ + path for path in current_excluded if path not in stale_set + ] + persist_current_cache = getattr(self.scanner, "_persist_current_cache", None) + if callable(persist_current_cache): + await persist_current_cache() + + excluded_entries = self._sort_entries(excluded_entries, sort_by) + + if search: + excluded_entries = await self._apply_search_filters( + excluded_entries, + search, + fuzzy_search, + search_options, + ) + + paginated = self._paginate(excluded_entries, page, page_size) + paginated["items"] = await self._annotate_update_flags(paginated["items"]) + return paginated + async def _fetch_with_usage_sort(self, sort_params): """Fetch data sorted by usage count (desc/asc).""" cache = await self.cache_repository.get_cache() @@ -218,6 +269,62 @@ class BaseModelService(ABC): ) return annotated + def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]: + sort_params = self.cache_repository.parse_sort(sort_by) + key_name = sort_params.key + + if key_name == "date": + key_fn = lambda item: ( + float(item.get("modified", 0.0) or 0.0), + (item.get("model_name") or item.get("file_name") or "").lower(), + item.get("file_path", "").lower(), + ) + elif key_name == "size": + key_fn = lambda item: ( + int(item.get("size", 0) or 0), + (item.get("model_name") or item.get("file_name") or "").lower(), + item.get("file_path", "").lower(), + ) + elif key_name == "usage": + key_fn = lambda item: ( + int(item.get("usage_count", 0) or 0), + (item.get("model_name") or item.get("file_name") or "").lower(), + item.get("file_path", "").lower(), + ) + else: + key_fn = lambda item: ( + (item.get("model_name") or item.get("file_name") or "").lower(), + item.get("file_path", "").lower(), + ) + + return sorted(data, key=key_fn, reverse=sort_params.order == "desc") + + async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]: + root_path = self.scanner._find_root_for_file(file_path) + if not root_path: + return None + + metadata, should_skip = await MetadataManager.load_metadata( + file_path, + self.metadata_class, + ) + if should_skip: + return None + + if metadata is None: + metadata = await self.scanner._create_default_metadata(file_path) + if metadata is None: + return None + + metadata = self.scanner.adjust_metadata(metadata, file_path, root_path) + folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace( + os.path.sep, "/" + ) + entry = self.scanner._build_cache_entry(metadata, folder=folder) + entry = self.scanner.adjust_cached_entry(entry) + entry["exclude"] = True + return entry + async def _apply_hash_filters( self, data: List[Dict], hash_filters: Dict ) -> List[Dict]: diff --git a/py/services/checkpoint_service.py b/py/services/checkpoint_service.py index 64b0e1a9..bc454b7e 100644 --- a/py/services/checkpoint_service.py +++ b/py/services/checkpoint_service.py @@ -42,6 +42,7 @@ class CheckpointService(BaseModelService): "notes": checkpoint_data.get("notes", ""), "sub_type": sub_type, "favorite": checkpoint_data.get("favorite", False), + "exclude": bool(checkpoint_data.get("exclude", False)), "update_available": bool(checkpoint_data.get("update_available", False)), "skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)), "civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True) diff --git a/py/services/embedding_service.py b/py/services/embedding_service.py index b0959fd9..71ad259a 100644 --- a/py/services/embedding_service.py +++ b/py/services/embedding_service.py @@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService): "notes": embedding_data.get("notes", ""), "sub_type": sub_type, "favorite": embedding_data.get("favorite", False), + "exclude": bool(embedding_data.get("exclude", False)), "update_available": bool(embedding_data.get("update_available", False)), "skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)), "civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True) diff --git a/py/services/lora_service.py b/py/services/lora_service.py index dc3407af..d0d6b769 100644 --- a/py/services/lora_service.py +++ b/py/services/lora_service.py @@ -48,6 +48,7 @@ class LoraService(BaseModelService): "usage_tips": lora_data.get("usage_tips", ""), "notes": lora_data.get("notes", ""), "favorite": lora_data.get("favorite", False), + "exclude": bool(lora_data.get("exclude", False)), "update_available": bool(lora_data.get("update_available", False)), "skip_metadata_refresh": bool( lora_data.get("skip_metadata_refresh", False) diff --git a/py/services/model_lifecycle_service.py b/py/services/model_lifecycle_service.py index 0e0a2def..001752d9 100644 --- a/py/services/model_lifecycle_service.py +++ b/py/services/model_lifecycle_service.py @@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti from ..services.service_registry import ServiceRegistry from ..utils.constants import PREVIEW_EXTENSIONS +from ..utils.metadata_manager import MetadataManager logger = logging.getLogger(__name__) @@ -207,11 +208,56 @@ class ModelLifecycleService: excluded = getattr(self._scanner, "_excluded_models", None) if isinstance(excluded, list): - excluded.append(file_path) + if file_path not in excluded: + excluded.append(file_path) + + persist_current_cache = getattr(self._scanner, "_persist_current_cache", None) + if callable(persist_current_cache): + await persist_current_cache() message = f"Model {os.path.basename(file_path)} excluded" return {"success": True, "message": message} + async def unexclude_model(self, file_path: str) -> Dict[str, object]: + """Restore a previously excluded model to the active cache.""" + + if not file_path: + raise ValueError("Model path is required") + + if not os.path.exists(file_path): + raise ValueError("Model file does not exist") + + metadata_path = os.path.splitext(file_path)[0] + ".metadata.json" + metadata_payload = await self._metadata_loader(metadata_path) + metadata_payload["exclude"] = False + + await self._metadata_manager.save_metadata(file_path, metadata_payload) + + metadata, should_skip = await MetadataManager.load_metadata( + file_path, + self._scanner.model_class, + ) + if should_skip: + metadata = None + if metadata is None: + metadata = metadata_payload + + excluded = getattr(self._scanner, "_excluded_models", None) + if isinstance(excluded, list): + self._scanner._excluded_models = [ + path for path in excluded if path != file_path + ] + + await self._scanner.update_single_model_cache( + file_path, + file_path, + metadata, + recalculate_type=True, + ) + + message = f"Model {os.path.basename(file_path)} restored" + return {"success": True, "message": message} + async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]: """Delete a collection of models via the scanner bulk operation.""" diff --git a/static/css/components/banner.css b/static/css/components/banner.css index b19a597f..cb0a6ea9 100644 --- a/static/css/components/banner.css +++ b/static/css/components/banner.css @@ -243,3 +243,58 @@ -ms-user-select: none; user-select: none; } + +.excluded-view-banner { + margin-bottom: var(--space-2); + padding: 12px 16px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + background: linear-gradient( + 135deg, + oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08), + var(--card-bg) + ); +} + +.excluded-view-banner__content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.excluded-view-banner__title { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 600; + color: var(--text-color); +} + +.excluded-view-banner__back { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border-color); + background: var(--card-bg); + color: var(--text-color); + border-radius: var(--border-radius-xs); + padding: 8px 12px; + cursor: pointer; +} + +.excluded-view-banner__back:hover { + border-color: var(--lora-accent); + color: var(--lora-accent); +} + +@media (max-width: 768px) { + .excluded-view-banner__content { + flex-direction: column; + align-items: stretch; + } + + .excluded-view-banner__back { + justify-content: center; + } +} diff --git a/static/css/components/card.css b/static/css/components/card.css index 9f0871b4..26d03e5c 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -680,3 +680,22 @@ margin-left: 0; line-height: 1; } + +.excluded-model { + border-style: dashed; +} + +.model-excluded-badge { + width: 16px; + height: 16px; + padding: 0; + border-radius: 3px; + background: color-mix(in oklab, var(--warning-color, #d97706) 85%, white 15%); + color: white; + font-size: 0.65rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.9; +} diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 4969a8ea..4e7f1e56 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) { return { // Base CRUD operations list: `/api/lm/${modelType}/list`, + excluded: `/api/lm/${modelType}/excluded`, delete: `/api/lm/${modelType}/delete`, exclude: `/api/lm/${modelType}/exclude`, + unexclude: `/api/lm/${modelType}/unexclude`, rename: `/api/lm/${modelType}/rename`, save: `/api/lm/${modelType}/save-metadata`, cancelTask: `/api/lm/${modelType}/cancel-task`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 44d604e3..c22c8b39 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -51,6 +51,7 @@ export class BaseModelApiClient { async fetchModelsPage(page = 1, pageSize = null) { const pageState = this.getPageState(); const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; + const isExcludedView = pageState.viewMode === 'excluded'; try { const params = this._buildQueryParams({ @@ -71,7 +72,10 @@ export class BaseModelApiClient { }; } - const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`); + const endpoint = isExcludedView + ? this.apiConfig.endpoints.excluded + : this.apiConfig.endpoints.list; + const response = await fetch(`${endpoint}?${params}`); if (!response.ok) { throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); } @@ -84,7 +88,7 @@ export class BaseModelApiClient { totalPages: data.total_pages, currentPage: page, hasMore: page < data.total_pages, - folders: data.folders + folders: data.folders || [] }; } catch (error) { @@ -212,6 +216,50 @@ export class BaseModelApiClient { } } + async unexcludeModel(filePath) { + try { + state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`); + + const response = await fetch(this.apiConfig.endpoints.unexclude, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: filePath }) + }); + + if (!response.ok) { + throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + if (state.virtualScroller) { + state.virtualScroller.removeItemByFilePath(filePath); + } + showToast( + 'toast.api.restoreSuccess', + { type: this.apiConfig.config.displayName }, + 'success', + `Restored ${this.apiConfig.config.displayName}` + ); + return true; + } + + throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`); + } catch (error) { + console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error); + showToast( + 'toast.api.restoreFailed', + { type: this.apiConfig.config.singularName, message: error.message }, + 'error', + `Failed to restore ${this.apiConfig.config.singularName}: ${error.message}` + ); + return false; + } finally { + state.loadingManager.hide(); + } + } + async renameModelFile(filePath, newFileName) { try { state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); @@ -883,20 +931,21 @@ export class BaseModelApiClient { _buildQueryParams(baseParams, pageState) { const params = new URLSearchParams(baseParams); + const isExcludedView = pageState.viewMode === 'excluded'; - if (pageState.activeFolder !== null) { + if (!isExcludedView && pageState.activeFolder !== null) { params.append('folder', pageState.activeFolder); } - if (pageState.showFavoritesOnly) { + if (!isExcludedView && pageState.showFavoritesOnly) { params.append('favorites_only', 'true'); } - if (pageState.showUpdateAvailableOnly) { + if (!isExcludedView && pageState.showUpdateAvailableOnly) { params.append('update_available_only', 'true'); } - if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { + if (!isExcludedView && this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) { params.append('first_letter', pageState.activeLetterFilter); } @@ -918,7 +967,7 @@ export class BaseModelApiClient { params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); - if (pageState.filters) { + if (!isExcludedView && pageState.filters) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { Object.entries(pageState.filters.tags).forEach(([tag, state]) => { if (state === 'include') { @@ -981,7 +1030,9 @@ export class BaseModelApiClient { } } - this._addModelSpecificParams(params, pageState); + if (!isExcludedView) { + this._addModelSpecificParams(params, pageState); + } return params; } diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 58b615b7..0d06f618 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu { showMenu(x, y, card) { super.showMenu(x, y, card); + this.updateExcludeMenuItem(); // Update the "Move to other root" label based on current model type const moveOtherItem = this.menu.querySelector('[data-action="move-other"]'); @@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu { case 'exclude': showExcludeModal(this.currentCard.dataset.filepath); break; + case 'restore': + this.restoreExcludedModel(this.currentCard.dataset.filepath); + break; } } diff --git a/static/js/components/ContextMenu/EmbeddingContextMenu.js b/static/js/components/ContextMenu/EmbeddingContextMenu.js index 24440b28..1998eacd 100644 --- a/static/js/components/ContextMenu/EmbeddingContextMenu.js +++ b/static/js/components/ContextMenu/EmbeddingContextMenu.js @@ -18,6 +18,11 @@ export class EmbeddingContextMenu extends BaseContextMenu { async saveModelMetadata(filePath, data) { return getModelApiClient().saveModelMetadata(filePath, data); } + + showMenu(x, y, card) { + super.showMenu(x, y, card); + this.updateExcludeMenuItem(); + } handleMenuAction(action) { // First try to handle with common actions @@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu { case 'exclude': showExcludeModal(this.currentCard.dataset.filepath); break; + case 'restore': + this.restoreExcludedModel(this.currentCard.dataset.filepath); + break; } } } diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index 7bc61be5..7a0a2979 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu { const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]'); const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]'); const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]'); + const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]'); const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]'); if (isRecipesPage) { @@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu { licenseRefreshItem?.classList.add('hidden'); downloadExamplesItem?.classList.add('hidden'); cleanupExamplesItem?.classList.add('hidden'); + excludedModelsItem?.classList.add('hidden'); repairRecipesItem?.classList.remove('hidden'); } else { modelUpdateItem?.classList.remove('hidden'); licenseRefreshItem?.classList.remove('hidden'); downloadExamplesItem?.classList.remove('hidden'); cleanupExamplesItem?.classList.remove('hidden'); + excludedModelsItem?.classList.remove('hidden'); repairRecipesItem?.classList.add('hidden'); } @@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu { console.error('Failed to repair recipes:', error); }); break; + case 'manage-excluded-models': + this.manageExcludedModels(); + break; default: console.warn(`Unhandled global context menu action: ${action}`); break; } } + manageExcludedModels() { + window.pageControls?.enterExcludedView?.().catch((error) => { + console.error('Failed to open excluded models view:', error); + }); + } + async downloadExampleImages(menuItem) { const downloadPath = state?.global?.settings?.example_images_path; if (!downloadPath) { diff --git a/static/js/components/ContextMenu/LoraContextMenu.js b/static/js/components/ContextMenu/LoraContextMenu.js index 0dbaa7b7..605c2faf 100644 --- a/static/js/components/ContextMenu/LoraContextMenu.js +++ b/static/js/components/ContextMenu/LoraContextMenu.js @@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu { return getModelApiClient().saveModelMetadata(filePath, data); } + showMenu(x, y, card) { + super.showMenu(x, y, card); + this.updateExcludeMenuItem(); + } + handleMenuAction(action, menuItem) { // First try to handle with common actions if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { @@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu { case 'exclude': showExcludeModal(this.currentCard.dataset.filepath); break; + case 'restore': + this.restoreExcludedModel(this.currentCard.dataset.filepath); + break; } } diff --git a/static/js/components/ContextMenu/ModelContextMenuMixin.js b/static/js/components/ContextMenu/ModelContextMenuMixin.js index 66e810b9..77dfa8d8 100644 --- a/static/js/components/ContextMenu/ModelContextMenuMixin.js +++ b/static/js/components/ContextMenu/ModelContextMenuMixin.js @@ -10,6 +10,39 @@ import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js'; // Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu export const ModelContextMenuMixin = { + isExcludedView() { + return state?.pages?.[state.currentPageType]?.viewMode === 'excluded'; + }, + + updateExcludeMenuItem() { + const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]'); + if (!excludeItem) { + return; + } + + const isExcludedView = this.isExcludedView(); + excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude'; + excludeItem.innerHTML = isExcludedView + ? ` ${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}` + : ` ${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}`; + }, + + async restoreExcludedModel(filePath) { + const restored = await getModelApiClient().unexcludeModel(filePath); + if (!restored) { + return; + } + + if (window.pageControls?.exitExcludedView) { + await window.pageControls.exitExcludedView(); + } else { + const resetFn = this.resetAndReload || resetAndReload; + if (typeof resetFn === 'function') { + await resetFn(true); + } + } + }, + // NSFW Selector methods initNSFWSelector() { if (this._nsfwSelectorInitialized) { diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index f9e08deb..e6fd62d7 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -38,8 +38,12 @@ export class PageControls { // Initialize favorites filter button state this.initFavoritesFilter(); + + this.initExcludedViewControls(); + this.syncExcludedViewState(); console.log(`PageControls initialized for ${pageType} page`); + window.pageControls = this; } /** @@ -56,6 +60,19 @@ export class PageControls { // Load sort preference this.loadSortPreference(); + + if (!this.pageState.viewMode) { + this.pageState.viewMode = 'active'; + } + if (!this.pageState.excludedViewState) { + this.pageState.excludedViewState = { + sortBy: 'name:asc', + search: '', + }; + } + if (!this.pageState.filters?.search) { + this.pageState.filters.search = ''; + } } /** @@ -116,6 +133,15 @@ export class PageControls { // Page-specific event listeners this.initPageSpecificListeners(); } + + initExcludedViewControls() { + const backButton = document.getElementById('excludedViewBackBtn'); + if (backButton) { + backButton.addEventListener('click', async () => { + await this.exitExcludedView(); + }); + } + } /** * Initialize dropdown functionality @@ -334,6 +360,13 @@ export class PageControls { * @param {string} sortValue - The sort value to save */ saveSortPreference(sortValue) { + if (this.pageState.viewMode === 'excluded') { + this.pageState.excludedViewState = { + ...(this.pageState.excludedViewState || {}), + sortBy: sortValue, + }; + return; + } setStorageItem(`${this.pageType}_sort`, sortValue); } @@ -473,6 +506,8 @@ export class PageControls { // Update app state this.pageState.showFavoritesOnly = showFavoritesOnly; } + + this.updateActionButtonStates(); } /** @@ -489,12 +524,17 @@ export class PageControls { if (updateFilterBtn) { updateFilterBtn.classList.toggle('active', showUpdatesOnly); } + + this.updateActionButtonStates(); } /** * Toggle favorites-only filter and reload models */ async toggleFavoritesOnly() { + if (this.pageState.viewMode === 'excluded') { + return; + } const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); // Toggle the filter state in storage @@ -521,6 +561,9 @@ export class PageControls { * Toggle update-available-only filter and reload models */ async toggleUpdateAvailableOnly() { + if (this.pageState.viewMode === 'excluded') { + return; + } const updateFilterBtn = document.getElementById('updateFilterBtn'); const storageKey = `show_update_available_only_${this.pageType}`; const newState = !this.pageState.showUpdateAvailableOnly; @@ -535,6 +578,234 @@ export class PageControls { await this.resetAndReload(true); } + + cloneFilters(filters = this.pageState.filters) { + return JSON.parse(JSON.stringify(filters || {})); + } + + buildExcludedFilters(search = '') { + return { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [], + search, + tagLogic: 'any', + }; + } + + applyFilterState(filters) { + this.pageState.filters = filters; + + if (window.filterManager) { + window.filterManager.filters = window.filterManager.initializeFilters(filters); + window.filterManager.updateActiveFiltersCount(); + if (typeof window.filterManager.updateSelections === 'function') { + window.filterManager.updateSelections(); + } + window.filterManager.closeFilterPanel(); + } + } + + updateActionButtonStates() { + const favoriteFilterBtn = document.getElementById('favoriteFilterBtn'); + if (favoriteFilterBtn) { + favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly)); + } + + const updateFilterBtn = document.getElementById('updateFilterBtn'); + if (updateFilterBtn) { + updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly)); + } + } + + syncExcludedViewState() { + const isExcludedView = this.pageState.viewMode === 'excluded'; + const sortSelect = document.getElementById('sortSelect'); + const searchInput = document.getElementById('searchInput'); + const excludedBanner = document.getElementById('excludedViewBanner'); + const filterButton = document.getElementById('filterButton'); + const breadcrumbContainer = document.getElementById('breadcrumbContainer'); + const duplicatesBanner = document.getElementById('duplicatesBanner'); + const alphabetBarContainer = document.querySelector('.alphabet-bar-container'); + const hiddenSelectors = [ + '[data-action="fetch"]', + '[data-action="download"]', + '[data-action="bulk"]', + '[data-action="find-duplicates"]', + '#favoriteFilterBtn', + '.update-filter-group', + ]; + const customFilterIndicator = document.getElementById('customFilterIndicator'); + + document.body.classList.toggle('excluded-view-active', isExcludedView); + excludedBanner?.classList.toggle('hidden', !isExcludedView); + breadcrumbContainer?.classList.toggle('hidden', isExcludedView); + alphabetBarContainer?.classList.toggle('hidden', isExcludedView); + + if (duplicatesBanner && isExcludedView) { + duplicatesBanner.style.display = 'none'; + } + + hiddenSelectors.forEach((selector) => { + document.querySelectorAll(selector).forEach((element) => { + element.classList.toggle('hidden', isExcludedView); + }); + }); + + if (customFilterIndicator && isExcludedView) { + customFilterIndicator.classList.add('hidden'); + } + + if (filterButton) { + filterButton.disabled = isExcludedView; + filterButton.classList.toggle('hidden', isExcludedView); + } + + const activeFiltersCount = document.getElementById('activeFiltersCount'); + if (activeFiltersCount && isExcludedView) { + activeFiltersCount.style.display = 'none'; + } + + if (sortSelect) { + sortSelect.value = this.pageState.sortBy; + } + if (searchInput) { + searchInput.value = this.pageState.filters?.search || ''; + } + + this.updateActionButtonStates(); + + if (this.sidebarManager) { + const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false; + this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => { + console.error('Failed to update sidebar visibility:', error); + }); + } + } + + suspendInteractiveModes() { + const snapshot = { + bulkMode: Boolean(state.bulkMode), + duplicatesMode: Boolean(this.pageState.duplicatesMode), + }; + + if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) { + window.bulkManager.toggleBulkMode(); + } + + if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) { + window.modelDuplicatesManager.exitDuplicateMode(); + } + + return snapshot; + } + + async restoreInteractiveModes(snapshot = {}) { + if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) { + window.bulkManager.toggleBulkMode(); + } + + if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) { + return; + } + + const duplicatesManager = window.modelDuplicatesManager; + if (!duplicatesManager) { + return; + } + + if (typeof duplicatesManager.enterDuplicateMode === 'function' && + Array.isArray(duplicatesManager.duplicateGroups) && + duplicatesManager.duplicateGroups.length > 0) { + duplicatesManager.enterDuplicateMode(); + return; + } + + if (typeof duplicatesManager.findDuplicates === 'function') { + await duplicatesManager.findDuplicates(); + } + } + + syncCustomFilterIndicator() { + const indicator = document.getElementById('customFilterIndicator'); + if (!indicator) { + return; + } + + if (this.pageState.viewMode === 'excluded') { + indicator.classList.add('hidden'); + return; + } + + if (typeof this.checkCustomFilters === 'function') { + this.checkCustomFilters(); + } + } + + async enterExcludedView() { + if (this.pageState.viewMode === 'excluded') { + return; + } + + const interactionSnapshot = this.suspendInteractiveModes(); + + this.pageState.activeViewSnapshot = { + sortBy: this.pageState.sortBy, + activeFolder: this.pageState.activeFolder, + activeLetterFilter: this.pageState.activeLetterFilter ?? null, + showFavoritesOnly: this.pageState.showFavoritesOnly, + showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly, + bulkMode: interactionSnapshot.bulkMode, + duplicatesMode: interactionSnapshot.duplicatesMode, + filters: this.cloneFilters(), + }; + + const excludedState = this.pageState.excludedViewState || { + sortBy: 'name:asc', + search: '', + }; + + this.pageState.viewMode = 'excluded'; + this.pageState.sortBy = excludedState.sortBy || 'name:asc'; + this.pageState.currentPage = 1; + this.pageState.activeFolder = null; + this.pageState.activeLetterFilter = null; + this.pageState.showFavoritesOnly = false; + this.pageState.showUpdateAvailableOnly = false; + + this.applyFilterState(this.buildExcludedFilters(excludedState.search || '')); + this.syncExcludedViewState(); + await this.resetAndReload(false); + } + + async exitExcludedView() { + if (this.pageState.viewMode !== 'excluded') { + return; + } + + this.pageState.excludedViewState = { + ...(this.pageState.excludedViewState || {}), + sortBy: this.pageState.sortBy, + search: this.pageState.filters?.search || '', + }; + + const snapshot = this.pageState.activeViewSnapshot || {}; + this.pageState.viewMode = 'active'; + this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc'); + this.pageState.currentPage = 1; + this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`); + this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null; + this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly); + this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly); + this.applyFilterState(snapshot.filters || this.buildExcludedFilters('')); + this.pageState.activeViewSnapshot = null; + + this.syncExcludedViewState(); + await this.resetAndReload(true); + this.syncCustomFilterIndicator(); + await this.restoreInteractiveModes(snapshot); + } /** * Find duplicate models diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index 717c5243..1c7fe2af 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -433,10 +433,11 @@ export function createModelCard(model, modelType) { card.dataset.usage_count = String(model.usage_count); card.dataset.notes = model.notes || ''; card.dataset.base_model = model.base_model || 'Unknown'; - card.dataset.favorite = model.favorite ? 'true' : 'false'; - const hasUpdateAvailable = Boolean(model.update_available); - card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false'; - card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false'; + card.dataset.favorite = model.favorite ? 'true' : 'false'; + card.dataset.exclude = model.exclude ? 'true' : 'false'; + const hasUpdateAvailable = Boolean(model.update_available); + card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false'; + card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false'; // To only show usage_count when sorting by usage. const pageState = getCurrentPageState(); @@ -487,6 +488,9 @@ export function createModelCard(model, modelType) { if (model.skip_metadata_refresh) { card.classList.add('skip-refresh'); } + if (model.exclude) { + card.classList.add('excluded-model'); + } // Apply selection state if in bulk mode and this card is in the selected set (LoRA only) if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) { @@ -619,6 +623,11 @@ export function createModelCard(model, modelType) { ` : ''} + ${model.exclude ? ` + + + + ` : ''}
${actionIcons} diff --git a/static/js/state/index.js b/static/js/state/index.js index 3cd2e4d2..4a55ab26 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -90,7 +90,9 @@ export const state = { baseModel: [], tags: {}, license: {}, - modelTypes: [] + modelTypes: [], + search: '', + tagLogic: 'any', }, bulkMode: false, selectedLoras: new Set(), @@ -98,6 +100,12 @@ export const state = { showFavoritesOnly: false, showUpdateAvailableOnly: false, duplicatesMode: false, + viewMode: 'active', + excludedViewState: { + sortBy: 'name:asc', + search: '', + }, + activeViewSnapshot: null, }, recipes: { @@ -147,7 +155,9 @@ export const state = { baseModel: [], tags: {}, license: {}, - modelTypes: [] + modelTypes: [], + search: '', + tagLogic: 'any', }, modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model' bulkMode: false, @@ -156,6 +166,12 @@ export const state = { showFavoritesOnly: false, showUpdateAvailableOnly: false, duplicatesMode: false, + viewMode: 'active', + excludedViewState: { + sortBy: 'name:asc', + search: '', + }, + activeViewSnapshot: null, }, [MODEL_TYPES.EMBEDDING]: { @@ -178,7 +194,9 @@ export const state = { baseModel: [], tags: {}, license: {}, - modelTypes: [] + modelTypes: [], + search: '', + tagLogic: 'any', }, bulkMode: false, selectedModels: new Set(), @@ -186,6 +204,12 @@ export const state = { showFavoritesOnly: false, showUpdateAvailableOnly: false, duplicatesMode: false, + viewMode: 'active', + excludedViewState: { + sortBy: 'name:asc', + search: '', + }, + activeViewSnapshot: null, } }, diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 66fffd5b..679acff7 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -111,6 +111,9 @@
{{ t('globalContextMenu.cleanupExampleImages.label') }}
+
+ {{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }} +
{{ t('globalContextMenu.repairRecipes.label') }}
@@ -136,4 +139,4 @@
-
\ No newline at end of file +
diff --git a/templates/components/controls.html b/templates/components/controls.html index eade20c7..a2ef9856 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -1,4 +1,16 @@
+
diff --git a/tests/frontend/components/contextMenu.interactions.test.js b/tests/frontend/components/contextMenu.interactions.test.js index dcc4f2fa..fb0f6a55 100644 --- a/tests/frontend/components/contextMenu.interactions.test.js +++ b/tests/frontend/components/contextMenu.interactions.test.js @@ -2102,6 +2102,7 @@ describe('Interaction-level regression coverage', () => {
+
`; @@ -2120,6 +2121,9 @@ describe('Interaction-level regression coverage', () => { startProgressUpdates: vi.fn(), updateDownloadButtonText: vi.fn(), }; + window.pageControls = { + enterExcludedView: vi.fn().mockResolvedValue(undefined), + }; global.fetch = vi.fn() .mockResolvedValueOnce({ @@ -2224,5 +2228,10 @@ describe('Interaction-level regression coverage', () => { ); expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...'); expect(fetchMissingItem.classList.contains('disabled')).toBe(false); + + menu.showMenu(560, 600); + const excludedItem = document.querySelector('[data-action="manage-excluded-models"]'); + excludedItem.dispatchEvent(new Event('click', { bubbles: true })); + expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/frontend/components/pageControls.filtering.test.js b/tests/frontend/components/pageControls.filtering.test.js index 6763496e..71eeda63 100644 --- a/tests/frontend/components/pageControls.filtering.test.js +++ b/tests/frontend/components/pageControls.filtering.test.js @@ -86,6 +86,7 @@ beforeEach(() => { }); afterEach(() => { + delete window.bulkManager; delete window.modelDuplicatesManager; delete global.fetch; vi.useRealTimers(); @@ -114,6 +115,9 @@ function renderControlsDom(pageKey) {
+
@@ -172,6 +176,9 @@ function renderControlsDom(pageKey) {
+ + +
`; } @@ -576,4 +583,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => { duplicateButton.click(); expect(toggleDuplicateMode).toHaveBeenCalledTimes(1); }); + + it.each([ + ['loras', 'LorasControls'], + ['checkpoints', 'CheckpointsControls'], + ['embeddings', 'EmbeddingsControls'], + ])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => { + renderControlsDom(pageKey); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState(pageKey); + const pageState = stateModule.getCurrentPageState(); + pageState.filters.search = 'active-search'; + pageState.showFavoritesOnly = true; + pageState.showUpdateAvailableOnly = true; + + const controlsModule = await import('../../../static/js/components/controls/index.js'); + const ControlsClass = controlsModule[exportName]; + const controls = new ControlsClass(); + + await controls.enterExcludedView(); + + expect(pageState.viewMode).toBe('excluded'); + expect(pageState.filters.search).toBe(''); + expect(resetAndReloadMock).toHaveBeenLastCalledWith(false); + expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false); + expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true); + expect(document.getElementById('filterButton').disabled).toBe(true); + + pageState.filters.search = 'excluded-search'; + await controls.exitExcludedView(); + + expect(pageState.viewMode).toBe('active'); + expect(pageState.filters.search).toBe('active-search'); + expect(pageState.excludedViewState.search).toBe('excluded-search'); + expect(resetAndReloadMock).toHaveBeenLastCalledWith(true); + expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true); + expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false); + expect(document.getElementById('filterButton').disabled).toBe(false); + }); + + it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => { + renderControlsDom('loras'); + const stateModule = await import('../../../static/js/state/index.js'); + stateModule.initPageState('loras'); + const pageState = stateModule.getCurrentPageState(); + stateModule.state.bulkMode = true; + pageState.duplicatesMode = true; + + sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1'); + sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter'); + + const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js'); + + const toggleBulkMode = vi.fn(() => { + stateModule.state.bulkMode = !stateModule.state.bulkMode; + }); + const exitDuplicateMode = vi.fn(() => { + pageState.duplicatesMode = false; + }); + const enterDuplicateMode = vi.fn(() => { + pageState.duplicatesMode = true; + }); + + window.bulkManager = { toggleBulkMode }; + window.modelDuplicatesManager = { + duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }], + exitDuplicateMode, + enterDuplicateMode, + }; + + const controls = new LorasControls(); + const indicator = document.getElementById('customFilterIndicator'); + expect(indicator.classList.contains('hidden')).toBe(false); + + await controls.enterExcludedView(); + + expect(toggleBulkMode).toHaveBeenCalledTimes(1); + expect(exitDuplicateMode).toHaveBeenCalledTimes(1); + expect(stateModule.state.bulkMode).toBe(false); + expect(pageState.duplicatesMode).toBe(false); + expect(indicator.classList.contains('hidden')).toBe(true); + + await controls.exitExcludedView(); + + expect(indicator.classList.contains('hidden')).toBe(false); + expect(toggleBulkMode).toHaveBeenCalledTimes(2); + expect(enterDuplicateMode).toHaveBeenCalledTimes(1); + expect(stateModule.state.bulkMode).toBe(true); + expect(pageState.duplicatesMode).toBe(true); + }); }); diff --git a/tests/services/test_model_lifecycle_service.py b/tests/services/test_model_lifecycle_service.py index 652c07e8..f221d8a7 100644 --- a/tests/services/test_model_lifecycle_service.py +++ b/tests/services/test_model_lifecycle_service.py @@ -5,6 +5,7 @@ import pytest from py.services.model_lifecycle_service import ModelLifecycleService from py.utils.metadata_manager import MetadataManager +from py.utils.models import LoraMetadata class DummyCache: @@ -445,6 +446,63 @@ async def test_exclude_model_empty_path_raises_error(): await service.exclude_model("") +@pytest.mark.asyncio +async def test_unexclude_model_restores_cache_entry(tmp_path: Path): + """Verify unexclude_model clears exclude metadata and restores cache entry.""" + model_path = tmp_path / "restored_model.safetensors" + model_path.write_bytes(b"content") + + metadata_payload = { + "file_name": "restored_model", + "model_name": "restored_model", + "file_path": str(model_path), + "sha256": "abc123", + "exclude": True, + "tags": ["tag1"], + } + metadata_path = tmp_path / "restored_model.metadata.json" + metadata_path.write_text(json.dumps(metadata_payload)) + + class RestoreScanner: + def __init__(self): + self.model_type = "lora" + self.model_class = LoraMetadata + self._excluded_models = [str(model_path)] + self.updated = [] + + async def update_single_model_cache(self, old_path, new_path, metadata, recalculate_type=False): + exclude_value = metadata.get("exclude") if isinstance(metadata, dict) else metadata.exclude + self.updated.append((old_path, new_path, exclude_value, recalculate_type)) + + saved_metadata = [] + + class SavingMetadataManager: + async def save_metadata(self, path: str, metadata: dict): + saved_metadata.append((path, metadata.copy())) + await MetadataManager.save_metadata(path, metadata) + + async def metadata_loader(path: str): + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + scanner = RestoreScanner() + service = ModelLifecycleService( + scanner=scanner, + metadata_manager=SavingMetadataManager(), + metadata_loader=metadata_loader, + ) + + result = await service.unexclude_model(str(model_path)) + + assert result["success"] is True + assert "restored" in result["message"].lower() + assert scanner._excluded_models == [] + assert saved_metadata[0][1]["exclude"] is False + assert scanner.updated == [ + (str(model_path), str(model_path), False, True) + ] + + # ============================================================================= # Tests for bulk_delete_models functionality # =============================================================================