mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
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:
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user