feat: Implement recipe repair cancellation with UI support and refactor LoadingManager to a singleton.

This commit is contained in:
Will Miao
2026-01-02 20:03:27 +08:00
parent 837c32c42f
commit ab85ba54a9
16 changed files with 111 additions and 12 deletions

View File

@@ -167,6 +167,7 @@
"label": "Recipe-Daten reparieren", "label": "Recipe-Daten reparieren",
"loading": "Recipe-Daten werden repariert...", "loading": "Recipe-Daten werden repariert...",
"success": "{count} Rezepte erfolgreich repariert.", "success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}" "error": "Recipe-Reparatur fehlgeschlagen: {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "Repair recipes data", "label": "Repair recipes data",
"loading": "Repairing recipe data...", "loading": "Repairing recipe data...",
"success": "Successfully repaired {count} recipes.", "success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}" "error": "Recipe repair failed: {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "Reparar datos de recetas", "label": "Reparar datos de recetas",
"loading": "Reparando datos de recetas...", "loading": "Reparando datos de recetas...",
"success": "Se repararon con éxito {count} recetas.", "success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}" "error": "Error al reparar recetas: {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "Réparer les données de recettes", "label": "Réparer les données de recettes",
"loading": "Réparation des données de recettes...", "loading": "Réparation des données de recettes...",
"success": "{count} recettes réparées avec succès.", "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}" "error": "Échec de la réparation des recettes : {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "תיקון נתוני מתכונים", "label": "תיקון נתוני מתכונים",
"loading": "מתקן נתוני מתכונים...", "loading": "מתקן נתוני מתכונים...",
"success": "תוקנו בהצלחה {count} מתכונים.", "success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}" "error": "תיקון המתכונים נכשל: {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "レシピデータの修復", "label": "レシピデータの修復",
"loading": "レシピデータを修復中...", "loading": "レシピデータを修復中...",
"success": "{count} 件のレシピを正常に修復しました。", "success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}" "error": "レシピの修復に失敗しました: {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "레시피 데이터 복구", "label": "레시피 데이터 복구",
"loading": "레시피 데이터 복구 중...", "loading": "레시피 데이터 복구 중...",
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.", "success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}" "error": "레시피 복구 실패: {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "Восстановить данные рецептов", "label": "Восстановить данные рецептов",
"loading": "Восстановление данных рецептов...", "loading": "Восстановление данных рецептов...",
"success": "Успешно восстановлено {count} рецептов.", "success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}" "error": "Ошибка восстановления рецептов: {message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "修复配方数据", "label": "修复配方数据",
"loading": "正在修复配方数据...", "loading": "正在修复配方数据...",
"success": "成功修复了 {count} 个配方。", "success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}" "error": "配方修复失败:{message}"
} }
}, },

View File

@@ -167,6 +167,7 @@
"label": "修復配方資料", "label": "修復配方資料",
"loading": "正在修復配方資料...", "loading": "正在修復配方資料...",
"success": "成功修復 {count} 個配方。", "success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}" "error": "配方修復失敗:{message}"
} }
}, },

View File

@@ -78,6 +78,7 @@ class RecipeHandlerSet:
"scan_recipes": self.query.scan_recipes, "scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe, "move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes, "repair_recipes": self.management.repair_recipes,
"cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe, "repair_recipe": self.management.repair_recipe,
"get_repair_progress": self.management.get_repair_progress, "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) return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
# Check if already running # 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) return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
recipe_scanner.reset_cancellation()
async def progress_callback(data): async def progress_callback(data):
await self._ws_manager.broadcast_recipe_repair_progress(data) await self._ws_manager.broadcast_recipe_repair_progress(data)
@@ -551,6 +554,8 @@ class RecipeManagementHandler:
finally: finally:
# Keep the final status for a while so the UI can see it # Keep the final status for a while so the UI can see it
await asyncio.sleep(5) 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() self._ws_manager.cleanup_recipe_repair_progress()
asyncio.create_task(run_repair()) asyncio.create_task(run_repair())
@@ -560,6 +565,19 @@ class RecipeManagementHandler:
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True) self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) 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: async def repair_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()

View File

@@ -44,6 +44,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_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("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"), RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
) )

View File

@@ -73,6 +73,7 @@ class RecipeScanner:
self._mutation_lock = asyncio.Lock() self._mutation_lock = asyncio.Lock()
self._post_scan_task: Optional[asyncio.Task] = None self._post_scan_task: Optional[asyncio.Task] = None
self._resort_tasks: Set[asyncio.Task] = set() self._resort_tasks: Set[asyncio.Task] = set()
self._cancel_requested = False
if lora_scanner: if lora_scanner:
self._lora_scanner = lora_scanner self._lora_scanner = lora_scanner
if checkpoint_scanner: if checkpoint_scanner:
@@ -114,6 +115,19 @@ class RecipeScanner:
self._civitai_client = await ServiceRegistry.get_civitai_client() self._civitai_client = await ServiceRegistry.get_civitai_client()
return self._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( async def repair_all_recipes(
self, self,
progress_callback: Optional[Callable[[Dict], Any]] = None progress_callback: Optional[Callable[[Dict], Any]] = None
@@ -127,6 +141,8 @@ class RecipeScanner:
Returns: Returns:
Dict summary of repair results Dict summary of repair results
""" """
if progress_callback:
await progress_callback({"status": "started"})
async with self._mutation_lock: async with self._mutation_lock:
cache = await self.get_cached_data() cache = await self.get_cached_data()
all_recipes = list(cache.raw_data) all_recipes = list(cache.raw_data)
@@ -136,8 +152,29 @@ class RecipeScanner:
errors_count = 0 errors_count = 0
civitai_client = await self._get_civitai_client() civitai_client = await self._get_civitai_client()
self.reset_cancellation()
for i, recipe in enumerate(all_recipes): 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: try:
# Report progress # Report progress
if progress_callback: if progress_callback:

View File

@@ -212,9 +212,17 @@ class WebSocketManager:
return self._recipe_repair_progress return self._recipe_repair_progress
def cleanup_recipe_repair_progress(self): def cleanup_recipe_repair_progress(self):
"""Clear recipe repair progress data""" """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 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: def is_auto_organize_running(self) -> bool:
"""Check if auto-organize is currently running""" """Check if auto-organize is currently running"""
if not self._auto_organize_progress: if not self._auto_organize_progress:

View File

@@ -279,6 +279,7 @@ export class GlobalContextMenu extends BaseContextMenu {
); );
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage); const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
progressUI?.showCancelButton(() => this.cancelRepair());
try { try {
const response = await fetch('/api/lm/recipes/repair', { const response = await fetch('/api/lm/recipes/repair', {
@@ -316,6 +317,14 @@ export class GlobalContextMenu extends BaseContextMenu {
} }
} else if (p.status === 'error') { } else if (p.status === 'error') {
throw new Error(p.error || 'Repair failed'); 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) { } else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up // Progress might have finished quickly and been cleaned up
@@ -337,4 +346,14 @@ export class GlobalContextMenu extends BaseContextMenu {
menuItem?.classList.remove('disabled'); 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);
}
}
} }

View File

@@ -4,6 +4,10 @@ import { formatFileSize } from '../utils/formatters.js';
// Loading management // Loading management
export class LoadingManager { export class LoadingManager {
constructor() { constructor() {
if (LoadingManager.instance) {
return LoadingManager.instance;
}
this.overlay = document.getElementById('loading-overlay'); this.overlay = document.getElementById('loading-overlay');
if (!this.overlay) { if (!this.overlay) {
@@ -59,6 +63,8 @@ export class LoadingManager {
}; };
this.detailsContainer = null; // Will be created when needed this.detailsContainer = null; // Will be created when needed
LoadingManager.instance = this;
} }
show(message = 'Loading...', progress = 0) { show(message = 'Loading...', progress = 0) {