feat(recipes): add manual checkpoint reconnect for broken recipe entries

Checkpoint entries that cannot be restored by download (deleted,
unresolvable hash, or name-only remnants with no CivitAI identifiers)
now get the same remediation chain LoRAs already had:

- scanner: parameterized reconnect-suggestion ranking, update/restore/
  set-hash-invalid for the checkpoint entry, and clear hashInvalid on
  rematch write-back (was only done for LoRAs)
- persistence/handlers/routes: reconnect/restore/reconnect-suggestions/
  mark-hash-invalid endpoints under /api/lm/recipe/checkpoint/*
- modal: checkpoint reconnect UI (deleted/hash-invalid badges, inline
  form with suggestions, undo for reconnected entries); download
  failures mark the hash invalid only on explicit unresolvable signals
  (not found/deleted/404/410), matching the LoRA rule
- css: checkpoint undo button shares the LoRA undo styles
- i18n: the 14 new keys translated in all 9 locales
This commit is contained in:
Will Miao
2026-08-30 18:02:15 +08:00
parent bce7d1d30c
commit c8b9db5bf4
21 changed files with 2031 additions and 38 deletions
+114
View File
@@ -116,6 +116,10 @@ class RecipeHandlerSet:
"restore_lora": self.management.restore_lora,
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
"reconnect_checkpoint": self.management.reconnect_checkpoint,
"restore_checkpoint": self.management.restore_checkpoint,
"get_checkpoint_reconnect_suggestions": self.management.get_checkpoint_reconnect_suggestions,
"mark_checkpoint_hash_invalid": self.management.mark_checkpoint_hash_invalid,
"find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
"bulk_delete": self.management.bulk_delete,
@@ -1683,6 +1687,116 @@ class RecipeManagementHandler:
)
return web.json_response({"error": str(exc)}, status=500)
async def reconnect_checkpoint(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
for field in ("recipe_id", "target_name"):
if field not in data:
raise RecipeValidationError(f"Missing required field: {field}")
result = await self._persistence_service.reconnect_checkpoint(
recipe_scanner=recipe_scanner,
recipe_id=data["recipe_id"],
target_name=data["target_name"],
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error(
"Error reconnecting checkpoint: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def restore_checkpoint(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
if "recipe_id" not in data:
raise RecipeValidationError("Missing required field: recipe_id")
result = await self._persistence_service.restore_checkpoint(
recipe_scanner=recipe_scanner,
recipe_id=data["recipe_id"],
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error restoring checkpoint: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def get_checkpoint_reconnect_suggestions(
self, request: web.Request
) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
recipe_id = request.match_info.get("recipe_id")
if not recipe_id:
raise RecipeValidationError("recipe_id is required")
result = await self._persistence_service.get_checkpoint_reconnect_suggestions(
recipe_scanner=recipe_scanner,
recipe_id=recipe_id,
query=request.query.get("query") or None,
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error(
"Error suggesting checkpoint reconnect candidates: %s",
exc,
exc_info=True,
)
return web.json_response({"error": str(exc)}, status=500)
async def mark_checkpoint_hash_invalid(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
if "recipe_id" not in data:
raise RecipeValidationError("Missing required field: recipe_id")
result = await self._persistence_service.mark_checkpoint_hash_invalid(
recipe_scanner=recipe_scanner,
recipe_id=data["recipe_id"],
hash_invalid=bool(data.get("hash_invalid", True)),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error(
"Error marking checkpoint hash invalid: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def bulk_delete(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()