fix(recipe): show checkpoint-linked recipes in model modal (#851)

This commit is contained in:
Will Miao
2026-03-31 16:45:01 +08:00
parent 316f17dd46
commit 8dc2a2f76b
12 changed files with 393 additions and 51 deletions

View File

@@ -81,6 +81,7 @@ class RecipeHandlerSet:
"bulk_delete": self.management.bulk_delete,
"save_recipe_from_widget": self.management.save_recipe_from_widget,
"get_recipes_for_lora": self.query.get_recipes_for_lora,
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
"scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes,
@@ -218,6 +219,7 @@ class RecipeListingHandler:
filters["tags"] = tag_filters
lora_hash = request.query.get("lora_hash")
checkpoint_hash = request.query.get("checkpoint_hash")
result = await recipe_scanner.get_paginated_data(
page=page,
@@ -227,6 +229,7 @@ class RecipeListingHandler:
filters=filters,
search_options=search_options,
lora_hash=lora_hash,
checkpoint_hash=checkpoint_hash,
folder=folder,
recursive=recursive,
)
@@ -423,6 +426,28 @@ class RecipeQueryHandler:
self._logger.error("Error getting recipes for Lora: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipes_for_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")
checkpoint_hash = request.query.get("hash")
if not checkpoint_hash:
return web.json_response(
{"success": False, "error": "Checkpoint hash is required"},
status=400,
)
matching_recipes = await recipe_scanner.get_recipes_for_checkpoint(
checkpoint_hash
)
return web.json_response({"success": True, "recipes": matching_recipes})
except Exception as exc:
self._logger.error("Error getting recipes for checkpoint: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def scan_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()

View File

@@ -51,6 +51,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"
),
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition(
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
),
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"),

View File

@@ -1615,6 +1615,9 @@ class RecipeScanner:
) -> Optional[Dict[str, Any]]:
"""Coerce legacy or malformed checkpoint entries into a dict."""
if checkpoint_raw is None:
return None
if isinstance(checkpoint_raw, dict):
return dict(checkpoint_raw)
@@ -1632,9 +1635,6 @@ class RecipeScanner:
"file_name": file_name,
}
logger.warning(
"Unexpected checkpoint payload type %s", type(checkpoint_raw).__name__
)
return None
def _enrich_checkpoint_entry(self, checkpoint: Dict[str, Any]) -> Dict[str, Any]:
@@ -1790,6 +1790,7 @@ class RecipeScanner:
filters: dict = None,
search_options: dict = None,
lora_hash: str = None,
checkpoint_hash: str = None,
bypass_filters: bool = True,
folder: str | None = None,
recursive: bool = True,
@@ -1804,7 +1805,8 @@ class RecipeScanner:
filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
bypass_filters: If True, ignore other filters when a lora_hash is provided
checkpoint_hash: Optional SHA256 hash of a checkpoint to filter recipes by
bypass_filters: If True, ignore other filters when a hash filter is provided
folder: Optional folder filter relative to recipes directory
recursive: Whether to include recipes in subfolders of the selected folder
"""
@@ -1852,9 +1854,23 @@ class RecipeScanner:
# Skip other filters if bypass_filters is True
pass
# Otherwise continue with normal filtering after applying LoRA hash filter
elif checkpoint_hash:
normalized_checkpoint_hash = checkpoint_hash.lower()
filtered_data = [
item
for item in filtered_data
if isinstance(item.get("checkpoint"), dict)
and (item["checkpoint"].get("hash", "") or "").lower()
== normalized_checkpoint_hash
]
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
if not (lora_hash and bypass_filters):
if bypass_filters:
pass
has_hash_filter = bool(lora_hash or checkpoint_hash)
# Skip further filtering if we're only filtering by model hash with bypass enabled
if not (has_hash_filter and bypass_filters):
# Apply folder filter before other criteria
if folder is not None:
normalized_folder = folder.strip("/")
@@ -2334,6 +2350,38 @@ class RecipeScanner:
return matching_recipes
async def get_recipes_for_checkpoint(
self, checkpoint_hash: str
) -> List[Dict[str, Any]]:
"""Return recipes that reference a given checkpoint hash."""
if not checkpoint_hash:
return []
normalized_hash = checkpoint_hash.lower()
cache = await self.get_cached_data()
matching_recipes: List[Dict[str, Any]] = []
for recipe in cache.raw_data:
checkpoint = self._normalize_checkpoint_entry(recipe.get("checkpoint"))
if not checkpoint:
continue
enriched_checkpoint = self._enrich_checkpoint_entry(dict(checkpoint))
if (enriched_checkpoint.get("hash") or "").lower() != normalized_hash:
continue
recipe_copy = {**recipe}
recipe_copy["checkpoint"] = enriched_checkpoint
recipe_copy["loras"] = [
self._enrich_lora_entry(dict(entry))
for entry in recipe.get("loras", [])
]
recipe_copy["file_url"] = self._format_file_url(recipe.get("file_path"))
matching_recipes.append(recipe_copy)
return matching_recipes
async def get_recipe_syntax_tokens(self, recipe_id: str) -> List[str]:
"""Build LoRA syntax tokens for a recipe."""