From ab85ba54a9eaad3a5a7a8a9fce99371924540b63 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 2 Jan 2026 20:03:27 +0800 Subject: [PATCH] feat: Implement recipe repair cancellation with UI support and refactor LoadingManager to a singleton. --- locales/de.json | 3 +- locales/en.json | 1 + locales/es.json | 3 +- locales/fr.json | 3 +- locales/he.json | 3 +- locales/ja.json | 3 +- locales/ko.json | 3 +- locales/ru.json | 3 +- locales/zh-CN.json | 3 +- locales/zh-TW.json | 3 +- py/routes/handlers/recipe_handlers.py | 20 +++++++++- py/routes/recipe_route_registrar.py | 1 + py/services/recipe_scanner.py | 37 +++++++++++++++++++ py/services/websocket_manager.py | 12 +++++- .../ContextMenu/GlobalContextMenu.js | 19 ++++++++++ static/js/managers/LoadingManager.js | 6 +++ 16 files changed, 111 insertions(+), 12 deletions(-) diff --git a/locales/de.json b/locales/de.json index fa0f047d..9f85f5db 100644 --- a/locales/de.json +++ b/locales/de.json @@ -167,6 +167,7 @@ "label": "Recipe-Daten reparieren", "loading": "Recipe-Daten werden repariert...", "success": "{count} Rezepte erfolgreich repariert.", + "cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.", "error": "Recipe-Reparatur fehlgeschlagen: {message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/en.json b/locales/en.json index 1d2a68a6..94f155ae 100644 --- a/locales/en.json +++ b/locales/en.json @@ -167,6 +167,7 @@ "label": "Repair recipes data", "loading": "Repairing recipe data...", "success": "Successfully repaired {count} recipes.", + "cancelled": "Repair cancelled. {count} recipes were repaired.", "error": "Recipe repair failed: {message}" } }, diff --git a/locales/es.json b/locales/es.json index dfea64e9..c3056334 100644 --- a/locales/es.json +++ b/locales/es.json @@ -167,6 +167,7 @@ "label": "Reparar datos de recetas", "loading": "Reparando datos de recetas...", "success": "Se repararon con éxito {count} recetas.", + "cancelled": "Reparación cancelada. {count} recetas fueron reparadas.", "error": "Error al reparar recetas: {message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/fr.json b/locales/fr.json index e6202179..874d6b58 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -167,6 +167,7 @@ "label": "Réparer les données de recettes", "loading": "Réparation des données de recettes...", "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}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/he.json b/locales/he.json index 43956612..a5a87d13 100644 --- a/locales/he.json +++ b/locales/he.json @@ -167,6 +167,7 @@ "label": "תיקון נתוני מתכונים", "loading": "מתקן נתוני מתכונים...", "success": "תוקנו בהצלחה {count} מתכונים.", + "cancelled": "תיקון בוטל. {count} מתכונים תוקנו.", "error": "תיקון המתכונים נכשל: {message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/ja.json b/locales/ja.json index b0f585c4..5688e40c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -167,6 +167,7 @@ "label": "レシピデータの修復", "loading": "レシピデータを修復中...", "success": "{count} 件のレシピを正常に修復しました。", + "cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。", "error": "レシピの修復に失敗しました: {message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/ko.json b/locales/ko.json index ac33bc82..6cf1b0e4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -167,6 +167,7 @@ "label": "레시피 데이터 복구", "loading": "레시피 데이터 복구 중...", "success": "{count}개의 레시피가 성공적으로 복구되었습니다.", + "cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.", "error": "레시피 복구 실패: {message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/ru.json b/locales/ru.json index 9abb63ec..c0259d9a 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -167,6 +167,7 @@ "label": "Восстановить данные рецептов", "loading": "Восстановление данных рецептов...", "success": "Успешно восстановлено {count} рецептов.", + "cancelled": "Восстановление отменено. {count} рецептов было восстановлено.", "error": "Ошибка восстановления рецептов: {message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 02888e88..305f2076 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -167,6 +167,7 @@ "label": "修复配方数据", "loading": "正在修复配方数据...", "success": "成功修复了 {count} 个配方。", + "cancelled": "修复已取消。已修复 {count} 个配方。", "error": "配方修复失败:{message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "浏览器插件教程" } } -} \ No newline at end of file +} diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 3e934d2a..78820d90 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -167,6 +167,7 @@ "label": "修復配方資料", "loading": "正在修復配方資料...", "success": "成功修復 {count} 個配方。", + "cancelled": "修復已取消。已修復 {count} 個配方。", "error": "配方修復失敗:{message}" } }, @@ -1528,4 +1529,4 @@ "learnMore": "LM Civitai Extension Tutorial" } } -} \ No newline at end of file +} diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index cb7e6f2c..227a5c65 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -78,6 +78,7 @@ class RecipeHandlerSet: "scan_recipes": self.query.scan_recipes, "move_recipe": self.management.move_recipe, "repair_recipes": self.management.repair_recipes, + "cancel_repair": self.management.cancel_repair, "repair_recipe": self.management.repair_recipe, "get_repair_progress": self.management.get_repair_progress, } @@ -530,9 +531,11 @@ class RecipeManagementHandler: return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503) # Check if already running - if self._ws_manager.get_recipe_repair_progress(): + if self._ws_manager.is_recipe_repair_running(): return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409) + recipe_scanner.reset_cancellation() + async def progress_callback(data): await self._ws_manager.broadcast_recipe_repair_progress(data) @@ -551,6 +554,8 @@ class RecipeManagementHandler: finally: # Keep the final status for a while so the UI can see it await asyncio.sleep(5) + # Don't cleanup if it was cancelled, let the UI see the cancelled state for a bit? + # Actually cleanup_recipe_repair_progress is fine as long as we waited enough. self._ws_manager.cleanup_recipe_repair_progress() asyncio.create_task(run_repair()) @@ -560,6 +565,19 @@ class RecipeManagementHandler: self._logger.error("Error starting recipe repair: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def cancel_repair(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503) + + recipe_scanner.cancel_task() + return web.json_response({"success": True, "message": "Cancellation requested"}) + except Exception as exc: + self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def repair_recipe(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 6e1db587..819b4331 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -44,6 +44,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"), + RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"), RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"), RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"), ) diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index da96696e..64325206 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -73,6 +73,7 @@ class RecipeScanner: self._mutation_lock = asyncio.Lock() self._post_scan_task: Optional[asyncio.Task] = None self._resort_tasks: Set[asyncio.Task] = set() + self._cancel_requested = False if lora_scanner: self._lora_scanner = lora_scanner if checkpoint_scanner: @@ -114,6 +115,19 @@ class RecipeScanner: self._civitai_client = await ServiceRegistry.get_civitai_client() return self._civitai_client + def cancel_task(self) -> None: + """Request cancellation of the current long-running task.""" + self._cancel_requested = True + logger.info("Recipe Scanner: Cancellation requested") + + def reset_cancellation(self) -> None: + """Reset the cancellation flag.""" + self._cancel_requested = False + + def is_cancelled(self) -> bool: + """Check if cancellation has been requested.""" + return self._cancel_requested + async def repair_all_recipes( self, progress_callback: Optional[Callable[[Dict], Any]] = None @@ -127,6 +141,8 @@ class RecipeScanner: Returns: Dict summary of repair results """ + if progress_callback: + await progress_callback({"status": "started"}) async with self._mutation_lock: cache = await self.get_cached_data() all_recipes = list(cache.raw_data) @@ -136,8 +152,29 @@ class RecipeScanner: errors_count = 0 civitai_client = await self._get_civitai_client() + self.reset_cancellation() for i, recipe in enumerate(all_recipes): + if self.is_cancelled(): + logger.info("Recipe repair cancelled by user") + if progress_callback: + await progress_callback({ + "status": "cancelled", + "current": i, + "total": total, + "repaired": repaired_count, + "skipped": skipped_count, + "errors": errors_count + }) + return { + "success": False, + "status": "cancelled", + "repaired": repaired_count, + "skipped": skipped_count, + "errors": errors_count, + "total": total + } + try: # Report progress if progress_callback: diff --git a/py/services/websocket_manager.py b/py/services/websocket_manager.py index 1108f72c..47a46a0a 100644 --- a/py/services/websocket_manager.py +++ b/py/services/websocket_manager.py @@ -212,8 +212,16 @@ class WebSocketManager: return self._recipe_repair_progress def cleanup_recipe_repair_progress(self): - """Clear recipe repair progress data""" - self._recipe_repair_progress = None + """Clear recipe repair progress data if it is in a finished state""" + if self._recipe_repair_progress and self._recipe_repair_progress.get('status') in ['completed', 'cancelled', 'error']: + self._recipe_repair_progress = None + + def is_recipe_repair_running(self) -> bool: + """Check if recipe repair is currently running""" + if not self._recipe_repair_progress: + return False + status = self._recipe_repair_progress.get('status') + return status in ['started', 'processing'] def is_auto_organize_running(self) -> bool: """Check if auto-organize is currently running""" diff --git a/static/js/components/ContextMenu/GlobalContextMenu.js b/static/js/components/ContextMenu/GlobalContextMenu.js index 1102ea83..3cc7699a 100644 --- a/static/js/components/ContextMenu/GlobalContextMenu.js +++ b/static/js/components/ContextMenu/GlobalContextMenu.js @@ -279,6 +279,7 @@ export class GlobalContextMenu extends BaseContextMenu { ); const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage); + progressUI?.showCancelButton(() => this.cancelRepair()); try { const response = await fetch('/api/lm/recipes/repair', { @@ -316,6 +317,14 @@ export class GlobalContextMenu extends BaseContextMenu { } } else if (p.status === 'error') { throw new Error(p.error || 'Repair failed'); + } else if (p.status === 'cancelled') { + isComplete = true; + progressUI?.complete(translate( + 'globalContextMenu.repairRecipes.cancelled', + { count: p.repaired }, + `Repair cancelled. ${p.repaired} recipes were repaired.` + )); + showToast('globalContextMenu.repairRecipes.cancelled', { count: p.repaired }, 'info'); } } else if (progressResponse.status === 404) { // Progress might have finished quickly and been cleaned up @@ -337,4 +346,14 @@ export class GlobalContextMenu extends BaseContextMenu { menuItem?.classList.remove('disabled'); } } + + async cancelRepair() { + try { + await fetch('/api/lm/recipes/cancel-repair', { + method: 'POST', + }); + } catch (error) { + console.error('Failed to cancel recipe repair:', error); + } + } } diff --git a/static/js/managers/LoadingManager.js b/static/js/managers/LoadingManager.js index 731100ec..ce111c1a 100644 --- a/static/js/managers/LoadingManager.js +++ b/static/js/managers/LoadingManager.js @@ -4,6 +4,10 @@ import { formatFileSize } from '../utils/formatters.js'; // Loading management export class LoadingManager { constructor() { + if (LoadingManager.instance) { + return LoadingManager.instance; + } + this.overlay = document.getElementById('loading-overlay'); if (!this.overlay) { @@ -59,6 +63,8 @@ export class LoadingManager { }; this.detailsContainer = null; // Will be created when needed + + LoadingManager.instance = this; } show(message = 'Loading...', progress = 0) {