feat(recipes): reconnect suggestions, undo, and base-model family tolerance

Enhance the deleted-LoRA reconnect flow in the recipe modal:

- Suggest local reconnect candidates when the panel opens, ranked by
  identity (same hash / same CivitAI version) then filename/name
  similarity, with a hard filter on confident base-model mismatches;
  the input gets a Combobox backed by the same endpoint as you type.
- Snapshot the pre-reconnect entry and offer a permanent restore:
  reconnected entries show an undo icon at the right end of the info
  row, with the original filename in the tooltip.
- Relax the manual reconnect base-model guard to a three-tier check:
  exact/unknown labels pass silently, same-architecture families
  (e.g. Pony <-> Illustrious) pass with a warning toast, and only
  cross-architecture mismatches stay hard-rejected.
This commit is contained in:
Will Miao
2026-08-30 08:17:35 +08:00
parent 6e31da7a70
commit 838a374a56
23 changed files with 1893 additions and 45 deletions
+61
View File
@@ -113,6 +113,8 @@ class RecipeHandlerSet:
"update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora,
"restore_lora": self.management.restore_lora,
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
"find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
@@ -1593,6 +1595,65 @@ class RecipeManagementHandler:
self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def restore_lora(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", "lora_index"):
if field not in data:
raise RecipeValidationError(f"Missing required field: {field}")
result = await self._persistence_service.restore_lora(
recipe_scanner=recipe_scanner,
recipe_id=data["recipe_id"],
lora_index=int(data["lora_index"]),
)
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 LoRA: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def get_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")
lora_index_raw = request.match_info.get("lora_index")
if not recipe_id or lora_index_raw is None:
raise RecipeValidationError("recipe_id and lora_index are required")
try:
lora_index = int(lora_index_raw)
except (TypeError, ValueError):
raise RecipeValidationError("lora_index must be an integer")
result = await self._persistence_service.get_reconnect_suggestions(
recipe_scanner=recipe_scanner,
recipe_id=recipe_id,
lora_index=lora_index,
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 reconnect candidates: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def mark_lora_hash_invalid(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
+6
View File
@@ -49,6 +49,12 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("POST", "/api/lm/recipe/lora/restore", "restore_lora"),
RouteDefinition(
"GET",
"/api/lm/recipe/{recipe_id}/lora/{lora_index}/reconnect-suggestions",
"get_reconnect_suggestions",
),
RouteDefinition(
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
),
+255
View File
@@ -5,6 +5,8 @@
from __future__ import annotations
import asyncio
import copy
import difflib
import json
import logging
import os
@@ -244,6 +246,188 @@ class RecipeScanner:
self._local_filename_cache_versions = versions
return cache
@staticmethod
def _strip_weight_extension(name: str) -> str:
"""Strip a known weight-file extension, preserving the original case."""
lower = name.lower()
for ext in sorted(WEIGHT_FILE_EXTENSIONS, key=len, reverse=True):
if lower.endswith(ext):
return name[: -len(ext)]
return name
async def suggest_reconnect_candidates(
self,
*,
entry: dict[str, Any],
recipe_base_model: Optional[str],
query: Optional[str] = None,
limit: int = 5,
) -> list[dict[str, Any]]:
"""Rank local LoRAs as reconnect candidates for a broken recipe entry.
Identity signals (same hash / same CivitAI model version) outrank
similarity signals (filename / model name fuzzy match). A confident
base-model mismatch (both sides known and different) is a hard
rejection here. This is deliberately stricter than reconnect itself,
which tolerates same-architecture-family labels (Pony ↔ Illustrious):
suggestions trade recall for a noise-free list, and the input box
remains available for deliberate cross-family picks. Unknown on
either side stays eligible, matching ``find_matching_models``.
When ``query`` is given
(search-as-you-type), identity signals are skipped and both
similarity signals score against the query, with a substring hit
(query of 3+ chars) flooring that signal's ratio at 0.8.
The name-similarity threshold (0.65) is stricter than the filename
one (0.55): long generic names share tokens like "style"/"pony" and
score deceptively high (measured 0.638 for unrelated models), while
filenames are the authoritative match key and get more slack.
"""
if limit <= 0 or not isinstance(entry, dict):
return []
lora_scanner = self._lora_scanner
if lora_scanner is None:
return []
data = await lora_scanner.get_cached_data()
recipe_bm = (recipe_base_model or "").strip().casefold()
def _base_model_known_mismatch(item: dict[str, Any]) -> bool:
"""Confident mismatch only — unknown on either side stays eligible."""
if not recipe_bm or recipe_bm == "unknown":
return False
item_bm = (item.get("base_model") or "").strip().casefold()
return bool(item_bm) and item_bm != "unknown" and item_bm != recipe_bm
def _base_model_adjustment(item: dict[str, Any]) -> float:
# Mismatches are already filtered out; this only boosts known-equal.
if not recipe_bm or recipe_bm == "unknown":
return 0.0
item_bm = (item.get("base_model") or "").strip().casefold()
return 0.1 if item_bm == recipe_bm else 0.0
pool: list[dict[str, Any]] = []
for item in getattr(data, "raw_data", None) or []:
if not isinstance(item, dict):
continue
# Items without a sha256 (pending/failed downloads) leave the
# entry without a usable hash — same rule as the filename cache.
if not (item.get("sha256") or "").strip():
continue
if not self._is_type_compatible(item, is_checkpoint=False):
continue
if _base_model_known_mismatch(item):
continue
pool.append(item)
if not pool:
return []
# Basename collision counts decide whether target_name needs the
# folder-relative path to resolve uniquely in find_matching_models.
basename_counts: dict[str, int] = {}
for item in pool:
key = self._normalize_filename_key(item.get("file_name") or "")
if key:
basename_counts[key] = basename_counts.get(key, 0) + 1
best: dict[str, dict[str, Any]] = {}
def _consider(item: dict[str, Any], score: float, reason: str) -> None:
key = item.get("file_path") or item.get("file_name") or ""
if not key:
return
current = best.get(key)
if current is None or score > current["score"]:
best[key] = {"item": item, "score": score, "reason": reason}
query_text = (query or "").strip()
if not query_text:
entry_hash = (entry.get("hash") or "").lower()
if entry_hash:
hash_cache = await self.build_local_hash_cache()
hit = hash_cache.get(entry_hash)
if (
isinstance(hit, dict)
and (hit.get("sha256") or "").strip()
and self._is_type_compatible(hit, is_checkpoint=False)
and not _base_model_known_mismatch(hit)
):
_consider(hit, 1.0 + _base_model_adjustment(hit), "same_hash")
version_id = entry.get("modelVersionId") or entry.get("id")
if version_id is not None:
hit = self._get_lora_from_version_index(str(version_id))
if (
isinstance(hit, dict)
and (hit.get("sha256") or "").strip()
and not _base_model_known_mismatch(hit)
):
_consider(hit, 0.95 + _base_model_adjustment(hit), "same_version")
filename_source = query_text or (entry.get("file_name") or "")
name_source = query_text or (entry.get("modelName") or "")
norm_filename_source = self._normalize_filename_key(filename_source)
name_source_cf = name_source.casefold()
# Substring hits floor the similarity ratio, but only for meaningful
# queries — a 1-2 character query is a substring of nearly every
# filename and would flood the suggestions with noise.
substring_floor = len(query_text) >= 3
for item in pool:
adjustment = _base_model_adjustment(item)
item_filename = self._normalize_filename_key(item.get("file_name") or "")
if norm_filename_source and item_filename:
ratio = difflib.SequenceMatcher(
None, norm_filename_source, item_filename
).ratio()
if substring_floor and norm_filename_source in item_filename:
ratio = max(ratio, 0.8)
if ratio >= 0.55:
_consider(
item, 0.5 + 0.4 * ratio + adjustment, "similar_filename"
)
item_name = (item.get("model_name") or "").casefold()
if name_source_cf and item_name:
ratio = difflib.SequenceMatcher(
None, name_source_cf, item_name
).ratio()
if substring_floor and name_source_cf in item_name:
ratio = max(ratio, 0.8)
if ratio >= 0.65:
_consider(item, 0.4 + 0.35 * ratio + adjustment, "similar_name")
suggestions = []
for record in best.values():
item = record["item"]
file_name = item.get("file_name") or ""
stem = self._strip_weight_extension(file_name)
folder = (item.get("folder") or "").replace("\\", "/").strip("/")
norm_key = self._normalize_filename_key(file_name)
if norm_key and basename_counts.get(norm_key, 0) > 1 and folder:
target_name = f"{folder}/{stem}"
else:
target_name = stem
suggestions.append(
{
"file_name": file_name,
"file_path": item.get("file_path") or "",
"model_name": item.get("model_name") or "",
"base_model": item.get("base_model") or "",
"preview_url": item.get("preview_url") or "",
"hash": (item.get("sha256") or "").lower(),
"score": round(record["score"], 3),
"match_reason": record["reason"],
"target_name": target_name,
}
)
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
return suggestions[:limit]
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
"""Return True when a recipe entry is eligible for local re-matching.
@@ -3677,6 +3861,13 @@ class RecipeScanner:
raise RecipeNotFoundError("LoRA index out of range in recipe")
lora_entry = loras[lora_index]
# Snapshot the pre-update state so the association can be restored
# later (undo reconnect). Never nest snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in lora_entry.items()
if key != "reconnectSnapshot"
}
lora_entry["isDeleted"] = False
lora_entry["hashInvalid"] = False
lora_entry["exclude"] = False
@@ -3695,6 +3886,8 @@ class RecipeScanner:
lora_entry["modelVersionName"] = civitai_info.get("name", "")
lora_entry["modelVersionId"] = civitai_info.get("id")
lora_entry["reconnectSnapshot"] = snapshot
from ..utils.utils import calculate_recipe_fingerprint
recipe_data["fingerprint"] = calculate_recipe_fingerprint(
@@ -3730,6 +3923,68 @@ class RecipeScanner:
updated_lora = self._enrich_lora_entry(updated_lora)
return recipe_data, updated_lora
async def restore_lora_entry(
self,
recipe_id: str,
lora_index: int,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Restore a LoRA entry to its pre-reconnect snapshot.
Reverses :meth:`update_lora_entry`: the entry saved under
``reconnectSnapshot`` becomes the entry again and the snapshot is
dropped. Returns the updated recipe data and the restored LoRA
metadata.
"""
recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found")
async with self._mutation_lock:
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
recipe_data = json.load(file_obj)
loras = recipe_data.get("loras", [])
if lora_index < 0 or lora_index >= len(loras):
raise RecipeNotFoundError("LoRA index out of range in recipe")
snapshot = loras[lora_index].get("reconnectSnapshot")
if not isinstance(snapshot, dict):
raise RecipeValidationError(
"LoRA entry has no reconnect snapshot to restore"
)
restored_entry = copy.deepcopy(snapshot)
restored_entry.pop("reconnectSnapshot", None)
loras[lora_index] = restored_entry
from ..utils.utils import calculate_recipe_fingerprint
recipe_data["fingerprint"] = calculate_recipe_fingerprint(
recipe_data.get("loras", [])
)
recipe_data["modified"] = time.time()
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
cache = await self.get_cached_data()
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
if not replaced:
await cache.add_recipe(recipe_data, resort=False)
self._schedule_resort()
# Update FTS index
self._update_fts_index_for_recipe(recipe_data, "update")
# Update persistent SQLite cache
if self._persistent_cache:
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
self._json_path_map[recipe_id] = recipe_json_path
restored_lora = self._enrich_lora_entry(dict(restored_entry))
return recipe_data, restored_lora
async def set_lora_entry_hash_invalid(
self,
recipe_id: str,
+94 -12
View File
@@ -13,6 +13,11 @@ from typing import Any, Awaitable, Dict, Iterable, Optional, cast
from ...config import config
from ...recipes.constants import GEN_PARAM_KEYS
from ...utils.base_model import (
RELATION_COMPATIBLE,
RELATION_INCOMPATIBLE,
base_model_relation,
)
from ...utils.utils import calculate_recipe_fingerprint
from ..pending_delete_service import get_pending_delete_service
from .errors import RecipeNotFoundError, RecipeValidationError
@@ -430,20 +435,31 @@ class RecipePersistenceService:
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_base_model = json.load(file_obj).get("base_model", "")
target_lora = await recipe_scanner.get_local_lora(target_name, recipe_base_model)
if not target_lora:
matches = await recipe_scanner.find_local_loras_by_name(target_name)
if len(matches) > 1:
raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate"
)
if len(matches) == 1:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
matches = await recipe_scanner.find_local_loras_by_name(target_name)
if not matches:
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
# Three-tier base-model guard: exact/unknown labels pass silently;
# labels from the same architecture family (e.g. Pony ↔ Illustrious)
# pass but are reported so the UI can warn; confident architecture
# mismatches stay hard-rejected because they can never load.
eligible: list[tuple[dict, str]] = []
for match in matches:
relation = base_model_relation(recipe_base_model, match.get("base_model"))
if relation != RELATION_INCOMPATIBLE:
eligible.append((match, relation))
if not eligible:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
if len(eligible) > 1:
raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate"
)
target_lora, target_relation = eligible[0]
recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
recipe_id,
lora_index,
@@ -451,6 +467,43 @@ class RecipePersistenceService:
target_lora=target_lora,
)
image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
matching_recipes = []
if "fingerprint" in recipe_data:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(recipe_data["fingerprint"])
if recipe_id in matching_recipes:
matching_recipes.remove(recipe_id)
payload: dict[str, Any] = {
"success": True,
"recipe_id": recipe_id,
"updated_lora": updated_lora,
"matching_recipes": matching_recipes,
}
if target_relation == RELATION_COMPATIBLE:
# Structured data, not prose — the frontend localizes the warning.
payload["base_model_mismatch"] = {
"recipe_base_model": recipe_base_model,
"lora_base_model": target_lora.get("base_model") or "",
}
return PersistenceResult(payload)
async def restore_lora(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
) -> PersistenceResult:
"""Restore a LoRA entry to the state captured before its reconnect."""
recipe_data, updated_lora = await recipe_scanner.restore_lora_entry(
recipe_id, lora_index
)
image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
@@ -470,6 +523,35 @@ class RecipePersistenceService:
}
)
async def get_reconnect_suggestions(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
query: str | None = None,
) -> PersistenceResult:
"""Return ranked local LoRA candidates for reconnecting a recipe entry."""
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found")
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_data = json.load(file_obj)
loras = recipe_data.get("loras") or []
if lora_index < 0 or lora_index >= len(loras):
raise RecipeValidationError(f"Invalid lora_index: {lora_index}")
suggestions = await recipe_scanner.suggest_reconnect_candidates(
entry=loras[lora_index],
recipe_base_model=recipe_data.get("base_model"),
query=query,
)
return PersistenceResult({"success": True, "suggestions": suggestions})
async def mark_lora_hash_invalid(
self,
*,
+79
View File
@@ -0,0 +1,79 @@
"""Base-model architecture families and compatibility relations.
CivitAI base-model labels describe fine-tune lineages, not architectures.
A LoRA physically loads on any checkpoint sharing its tensor architecture,
so e.g. Pony / Illustrious / NoobAI / SDXL 1.0 LoRAs are interchangeable
(quality varies, but nothing breaks). Different architectures (SD 1.5 vs
SDXL vs Flux) are guaranteed failures and must stay hard-rejected.
Only families with high-confidence architecture equivalence are listed.
Anything not in the table is treated as its own family, i.e. only an exact
label match is accepted unknown new labels never get wrongly waved through.
"""
from __future__ import annotations
from typing import Optional
# Normalized (casefolded, stripped) base-model label -> architecture family.
_BASE_MODEL_FAMILIES = {
# SD 1.x — all share the original 512px latent UNet.
"sd 1.4": "sd1",
"sd 1.5": "sd1",
"sd 1.5 lcm": "sd1",
"sd 1.5 hyper": "sd1",
# SDXL lineage — Pony / Illustrious / NoobAI are SDXL fine-tunes.
# Note: Pony V7 is AuraFlow-based, NOT SDXL, so it is deliberately absent.
"sdxl 1.0": "sdxl",
"sdxl lightning": "sdxl",
"sdxl hyper": "sdxl",
"pony": "sdxl",
"pony diffusion": "sdxl",
"pony diffusion v6 xl": "sdxl",
"illustrious": "sdxl",
"illustrious 0.1": "sdxl",
"illustrious 1.0": "sdxl",
"illustrious 1.1": "sdxl",
"noobai": "sdxl",
# Flux.1 — dev/schnell/Krea share the 12B rectified-flow transformer.
"flux.1 d": "flux1",
"flux.1 s": "flux1",
"flux.1 krea": "flux1",
# SD 3.5 Large and its Turbo distill share the 8B MMDiT. SD 3 (2B) and
# SD 3.5 Medium (2.5B) have different shapes and stay unlisted.
"sd 3.5 large": "sd35-large",
"sd 3.5 large turbo": "sd35-large",
}
_UNKNOWN_TOKENS = {"", "unknown", "other", "none", "null"}
# Relation constants returned by base_model_relation().
RELATION_UNKNOWN = "unknown" # at least one side has no usable label
RELATION_SAME = "same" # identical labels
RELATION_COMPATIBLE = "compatible" # different labels, same architecture family
RELATION_INCOMPATIBLE = "incompatible" # different labels, different/unknown family
def _normalize(label: Optional[str]) -> str:
return (label or "").strip().casefold()
def base_model_relation(a: Optional[str], b: Optional[str]) -> str:
"""Classify how two base-model labels relate for reconnect purposes.
``RELATION_UNKNOWN`` when either side has no usable label (callers treat
it as lenient-allow), ``RELATION_SAME`` for identical labels,
``RELATION_COMPATIBLE`` when both labels map to the same architecture
family, and ``RELATION_INCOMPATIBLE`` otherwise including when a label
is missing from the family table (conservative fallback).
"""
na, nb = _normalize(a), _normalize(b)
if na in _UNKNOWN_TOKENS or nb in _UNKNOWN_TOKENS:
return RELATION_UNKNOWN
if na == nb:
return RELATION_SAME
fa = _BASE_MODEL_FAMILIES.get(na)
fb = _BASE_MODEL_FAMILIES.get(nb)
if fa is not None and fa == fb:
return RELATION_COMPATIBLE
return RELATION_INCOMPATIBLE