feat(recipes): add reconnect remediation paths for missing recipe LoRAs

- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched
  entries can be undone via the existing restore flow
- Bulk missing-LoRA downloads mark unresolvable failures hash-invalid,
  flipping those entries from download to reconnect candidacy
- Recipe modal always offers a reconnect action next to download for
  missing LoRA entries
- Rematch runs collect an opt-in relaxed-matching choice (also reconnect
  missing models by file name) via a pre-run options dialog on the
  global, bulk and single-recipe entries
- L4 (filename-level) matches are listed in a results dialog with
  per-entry undo
This commit is contained in:
Will Miao
2026-09-09 06:59:54 +08:00
parent e747946f7a
commit 1b5cbbbaa0
33 changed files with 2103 additions and 76 deletions
+17
View File
@@ -1501,6 +1501,22 @@
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -2168,6 +2184,7 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"rematchOptions": {
"title": "Rematch Recipes",
"messageGlobal": "All recipes will be scanned against your local model library.",
"messageSingle": "This recipe will be scanned against your local model library.",
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "Also reconnect missing models by file name",
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
"confirmButton": "Rematch"
},
"rematchResults": {
"title": "Rematch Results — Filename Matches",
"message": "These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "Undo",
"undone": "Undone",
"undoFailed": "Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
@@ -2168,6 +2184,7 @@
"createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download",
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -2168,6 +2184,7 @@
"createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -2168,6 +2184,7 @@
"createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "Aucune Recipe précédente",
"noNextRecipe": "Aucune Recipe suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -2168,6 +2184,7 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -2168,6 +2184,7 @@
"createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -2168,6 +2184,7 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -2168,6 +2184,7 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -2168,6 +2184,7 @@
"createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"rematchOptions": {
"title": "[TODO: Translate] Rematch Recipes",
"messageGlobal": "[TODO: Translate] All recipes will be scanned against your local model library.",
"messageSingle": "[TODO: Translate] This recipe will be scanned against your local model library.",
"messageBulk": "[TODO: Translate] {count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "[TODO: Translate] Relaxed matching",
"relaxedDescription": "[TODO: Translate] Also try to reconnect models marked \"Not in Library\" by matching file names. May link a different version of a model — matches will be listed for review and can be undone.",
"confirmButton": "[TODO: Translate] Rematch"
},
"rematchResults": {
"title": "[TODO: Translate] Rematch Results — Filename Matches",
"message": "[TODO: Translate] These entries were reconnected by file name and may link a different version of a model. Review them and undo any that are wrong.",
"undo": "[TODO: Translate] Undo",
"undone": "[TODO: Translate] Undone",
"undoFailed": "[TODO: Translate] Failed to undo rematch: {message}"
},
"exampleAccess": {
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
@@ -2168,6 +2184,7 @@
"createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
+35 -3
View File
@@ -74,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
async def _parse_relaxed_flag(request: web.Request) -> bool:
"""Read the relaxed-rematch flag from the JSON body or query string.
The flag defaults to False (strict candidacy). A JSON body value wins;
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
"""
relaxed = False
if request.can_read_body:
try:
data = await request.json()
except Exception: # noqa: BLE001 - any parse failure means no flag
data = None
if isinstance(data, dict):
relaxed = bool(data.get("relaxed"))
if not relaxed:
relaxed = request.query.get("relaxed", "").lower() == "true"
return relaxed
@dataclass(frozen=True)
class RecipeHandlerSet:
"""Group of handlers providing recipe route implementations."""
@@ -812,6 +832,8 @@ class RecipeManagementHandler:
recipe_scanner.reset_cancellation()
relaxed = await _parse_relaxed_flag(request)
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data)
@@ -819,7 +841,8 @@ class RecipeManagementHandler:
async def run_rematch():
try:
await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback
progress_callback=progress_callback,
relaxed=relaxed,
)
except Exception as e:
self._logger.error(
@@ -892,7 +915,13 @@ class RecipeManagementHandler:
status=400,
)
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
relaxed = bool(data.get("relaxed")) or (
request.query.get("relaxed", "").lower() == "true"
)
result = await recipe_scanner.rematch_recipes_bulk(
recipe_ids, relaxed=relaxed
)
return web.json_response(result)
except Exception as exc:
self._logger.error(
@@ -921,7 +950,10 @@ class RecipeManagementHandler:
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
relaxed = await _parse_relaxed_flag(request)
result = await recipe_scanner.rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
+159 -31
View File
@@ -483,32 +483,43 @@ class RecipeScanner:
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
return suggestions[:limit]
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
def _is_rematch_candidate(
self, entry: dict[str, Any], relaxed: bool = False
) -> bool:
"""Return True when a recipe entry is eligible for local re-matching.
An entry counts as unresolved when its identity is known to be
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
identity fields (``hash``/``file_name``). A healthy entry whose
hash is simply not present in the local library is NOT a candidate:
it may be a recipe imported without downloading the model yet, and
its CivitAI-valid hash must never be overwritten by the imprecise
filename fallback.
hash is simply not present in the local library is NOT a candidate
in the default strict mode: it may be a recipe imported without
downloading the model yet, and its CivitAI-valid hash must never be
overwritten by the imprecise filename fallback.
With ``relaxed=True`` any entry carrying an identifier is a
candidate, including healthy ones the caller opted into trying to
reconnect "Not in Library" entries by file name. Entries without
any identifier are never candidates in either mode.
"""
if not isinstance(entry, dict):
return False
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
has_identifier = (
entry.get("hash")
or entry.get("modelVersionId")
or entry.get("id")
or entry.get("file_name")
)
return bool(unresolved and has_identifier)
if not has_identifier:
return False
if relaxed:
return True
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
return bool(unresolved)
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
"""Build a version-cached map of computed AutoV3 hashes to local items.
@@ -809,7 +820,9 @@ class RecipeScanner:
"""Check if cancellation has been requested."""
return self._cancel_requested
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
async def rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Logs one INFO summary line for this run and delegates the per-recipe
@@ -817,12 +830,14 @@ class RecipeScanner:
Args:
recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
Raises RecipeNotFoundError when the recipe is missing.
"""
result = await self._rematch_recipe_by_id(recipe_id)
result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
logger.info(
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
@@ -835,7 +850,9 @@ class RecipeScanner:
)
return result
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
async def _rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache, computed autov3 cache, filename
@@ -846,12 +863,16 @@ class RecipeScanner:
Args:
recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch result with unified counters
(matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy rematched/skipped/errors
fields) and a per-entry ``details`` report. The legacy ``skipped``
fields) and a per-entry ``details`` report plus a flattened
``l4_matches`` list (filename-level matches for review/undo,
consistent with the bulk/global paths). The legacy ``skipped``
field means "recipe not updated" and overlaps
``unresolved_recipes`` (a recipe with unmatched candidates counts
as both). Raises RecipeNotFoundError when the recipe is missing.
@@ -872,7 +893,8 @@ class RecipeScanner:
try:
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache
recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
)
except RecipePersistenceError as exc:
logger.error(
@@ -891,12 +913,16 @@ class RecipeScanner:
"unresolved_recipes": 0,
"unresolved_entries": 0,
"details": {"matched": [], "unresolved": []},
"l4_matches": [],
"recipe": recipe,
"error": str(exc),
}
unresolved_entries = len(details["unresolved"])
unresolved_recipes = 1 if unresolved_entries > 0 else 0
# Flattened L4 matches for the results modal, consistent with
# the bulk/global paths.
l4_matches = self._collect_l4_matches(recipe_id, details)
if rematched == 0:
return {
@@ -908,6 +934,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"details": details,
"l4_matches": l4_matches,
"recipe": recipe,
}
@@ -921,6 +948,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"details": details,
"l4_matches": l4_matches,
"recipe": await self.get_recipe_by_id(recipe_id),
}
@@ -930,6 +958,8 @@ class RecipeScanner:
local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]],
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
*,
relaxed: bool = False,
) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models.
@@ -945,16 +975,24 @@ class RecipeScanner:
autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback
relaxed: When True, healthy entries ("Not in Library") are also
rematch candidates. Anti-churn rule: an entry that is a
candidate ONLY because of relaxed mode is skipped when its
hash already resolves in the L1 ``local_cache`` it is
already correctly linked and rematching would only add noise
and a pointless snapshot.
Returns:
Tuple of (rematched_entries, errors, details). The errors element
is always 0 on a normal return a persistence failure RAISES
``RecipePersistenceError`` so callers can count it. ``details``
carries the per-entry outcome:
``{"matched": [{type, entry, file_name, match_level}],
``{"matched": [{type, entry, file_name, match_level, lora_index?}],
"unresolved": [{type, entry}]}`` where an unresolved entry is a
rematch candidate that found no local match an expected outcome
(the model may simply not exist locally), not an error.
``lora_index`` is only present for lora entries (the checkpoint
restore endpoint needs no index).
Raises:
RecipePersistenceError: when the recipe changed but
@@ -963,11 +1001,23 @@ class RecipeScanner:
rematched = 0
details: Dict[str, Any] = {"matched": [], "unresolved": []}
def is_actionable_candidate(entry: Dict[str, Any]) -> bool:
"""Apply candidacy plus the relaxed-mode anti-churn rule."""
if self._is_rematch_candidate(entry):
return True
if not relaxed or not self._is_rematch_candidate(entry, relaxed=True):
return False
# Relaxed-only candidate: skip when the stored hash already
# resolves in the L1 local cache — the entry is already correctly
# linked and rematching would just add noise and a snapshot.
entry_hash = (entry.get("hash") or "").lower()
return local_cache.get(entry_hash) is None
# Lora entries
loras = recipe.get("loras", [])
if isinstance(loras, list):
for entry in loras:
if not self._is_rematch_candidate(entry):
for lora_index, entry in enumerate(loras):
if not is_actionable_candidate(entry):
continue
item, level = await self._match_rematch_entry_with_level(
entry,
@@ -991,6 +1041,7 @@ class RecipeScanner:
"entry": self._entry_identifier(entry),
"file_name": item.get("file_name") or "",
"match_level": level,
"lora_index": lora_index,
}
)
self._write_rematch_lora_entry(entry, item)
@@ -1000,7 +1051,7 @@ class RecipeScanner:
# silently since ``entry.get`` on a str would raise AttributeError).
checkpoint = recipe.get("checkpoint")
if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint):
if is_actionable_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level(
checkpoint,
local_cache,
@@ -1065,8 +1116,36 @@ class RecipeScanner:
self._update_fts_index_for_recipe(recipe, "update")
return (rematched, 0, details)
@staticmethod
def _collect_l4_matches(
recipe_id: Any, details: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Flatten a recipe's L4 (filename-level) matches for review.
Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows
one per matched detail at level L4. ``lora_index`` is only present
for lora entries (checkpoint restore needs no index).
"""
rows: List[Dict[str, Any]] = []
for match in details.get("matched", []):
if match.get("match_level") != "L4":
continue
row: Dict[str, Any] = {
"recipe_id": recipe_id,
"type": match.get("type"),
"entry": match.get("entry"),
"file_name": match.get("file_name"),
}
if "lora_index" in match:
row["lora_index"] = match["lora_index"]
rows.append(row)
return rows
async def rematch_all_recipes(
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
self,
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
*,
relaxed: bool = False,
) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally.
@@ -1080,14 +1159,19 @@ class RecipeScanner:
Args:
progress_callback: Optional callback for progress updates
(started/processing/cancelled/completed events).
(started/processing/cancelled/completed events). The
completed/cancelled payloads carry ``l4_matches``, a
flattened list of filename-level matches for review/undo.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch run with unified counters
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
entries plus the legacy success/status/rematched/skipped/errors/
total fields). ``rematched`` (legacy) counts updated recipes
use ``matched_entries`` for the entry-level total.
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
updated recipes use ``matched_entries`` for the entry-level
total.
"""
start_time = time.perf_counter()
@@ -1109,6 +1193,7 @@ class RecipeScanner:
unresolved_entries = 0
skipped_count = 0
errors_count = 0
l4_matches: List[Dict[str, Any]] = []
for i, recipe in enumerate(all_recipes):
if self.is_cancelled():
@@ -1137,6 +1222,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
)
return {
@@ -1150,6 +1236,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
try:
@@ -1165,11 +1252,15 @@ class RecipeScanner:
)
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache
recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
)
if rematched > 0:
matched_recipes += 1
matched_entries += rematched
l4_matches.extend(
self._collect_l4_matches(recipe.get("id"), details)
)
else:
skipped_count += 1
@@ -1215,6 +1306,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
)
@@ -1228,9 +1320,12 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
async def rematch_recipes_bulk(
self, recipe_ids: List[str], *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a set of recipes by their IDs.
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
@@ -1241,14 +1336,18 @@ class RecipeScanner:
Args:
recipe_ids: List of recipe ids to rematch.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the bulk run with unified counters
(matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy total/rematched/skipped/errors
fields) and a per-recipe ``details`` list. The legacy ``rematched``
field is the total entry count (same as ``matched_entries``)
unlike ``rematch_all_recipes`` where it counts updated recipes.
fields), a per-recipe ``details`` list, and ``l4_matches`` a
flattened list of filename-level matches for review/undo. The
legacy ``rematched`` field is the total entry count (same as
``matched_entries``) unlike ``rematch_all_recipes`` where it
counts updated recipes.
"""
total = len(recipe_ids)
matched_recipes = 0
@@ -1259,10 +1358,13 @@ class RecipeScanner:
errors = 0
recipes: List[Dict[str, Any]] = []
details_list: List[Dict[str, Any]] = []
l4_matches: List[Dict[str, Any]] = []
for recipe_id in recipe_ids:
try:
result = await self._rematch_recipe_by_id(recipe_id)
result = await self._rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
if result.get("success"):
matched_recipes += result.get("matched_recipes", 0)
matched_entries += result.get("matched_entries", 0)
@@ -1275,6 +1377,9 @@ class RecipeScanner:
details_list.append(
{"recipe_id": recipe_id, **result["details"]}
)
l4_matches.extend(
self._collect_l4_matches(recipe_id, result["details"])
)
else:
errors += result.get("errors", 0)
except RecipeNotFoundError:
@@ -1309,12 +1414,22 @@ class RecipeScanner:
"unresolved_entries": unresolved_entries,
"recipes": recipes,
"details": details_list,
"l4_matches": l4_matches,
}
def _write_rematch_lora_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
"""Write back a matched local model to a lora recipe entry."""
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow in
# ``update_lora_entry``. Never nest snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False
entry["hashInvalid"] = False
@@ -1338,6 +1453,8 @@ class RecipeScanner:
if civitai.get("name"):
entry["modelVersionName"] = civitai["name"]
entry["reconnectSnapshot"] = snapshot
def _write_rematch_checkpoint_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
@@ -1349,6 +1466,15 @@ class RecipeScanner:
when they already exist on the entry (or written fresh for the
identifier key when neither identifier form exists).
"""
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow. Never nest
# snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False
entry["hashInvalid"] = False
@@ -1389,6 +1515,8 @@ class RecipeScanner:
else:
entry["modelVersionId"] = civ_id
entry["reconnectSnapshot"] = snapshot
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
"""Helper to save a recipe to both JSON and EXIF metadata."""
recipe_id = recipe.get("id")
+180
View File
@@ -592,3 +592,183 @@ button:disabled,
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch Options Modal */
#rematchOptionsModal .modal-body {
padding: var(--space-3);
}
#rematchOptionsModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
/* Selectable option card click anywhere toggles the checkbox (label wrap).
Checkmark follows the batch-import modal's custom checkbox pattern. */
#rematchOptionsModal .rematch-option-card {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
background: var(--surface-subtle);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
user-select: none;
transition: var(--transition-base);
}
#rematchOptionsModal .rematch-option-card:hover {
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
border-color: var(--lora-accent);
background: oklch(from var(--lora-accent) l c h / 0.08);
}
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
#rematchOptionsModal .rematch-option-checkmark {
width: 18px;
height: 18px;
margin-top: 1px;
flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
background: var(--bg-color);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
#rematchOptionsModal .rematch-option-text {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-color);
min-width: 0;
}
#rematchOptionsModal .rematch-option-title {
font-weight: 600;
font-size: 0.95em;
}
#rematchOptionsModal .rematch-option-caveat {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: 0.85em;
line-height: 1.4;
color: var(--text-muted);
}
#rematchOptionsModal .rematch-option-caveat i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch L4 Results Modal */
#rematchResultsModal .modal-body {
padding: var(--space-3);
}
#rematchResultsModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
#rematchResultsModal .rematch-results-preview {
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2) var(--space-3);
}
#rematchResultsModal .rematch-results-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 320px;
overflow-y: auto;
}
#rematchResultsModal .rematch-results-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
#rematchResultsModal .rematch-results-row:last-child {
border-bottom: none;
}
#rematchResultsModal .rematch-results-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
#rematchResultsModal .rematch-results-entry {
font-weight: 500;
color: var(--text-color);
overflow-wrap: anywhere;
}
#rematchResultsModal .rematch-results-file {
color: var(--text-muted);
overflow-wrap: anywhere;
}
#rematchResultsModal .rematch-results-recipe {
font-size: 0.85em;
opacity: 0.7;
color: var(--text-muted);
overflow-wrap: anywhere;
}
#rematchResultsModal .rematch-results-row.undone .rematch-results-info {
text-decoration: line-through;
opacity: 0.6;
}
#rematchResultsModal .rematch-results-undo {
flex-shrink: 0;
}
+9 -4
View File
@@ -677,7 +677,7 @@ export class RecipeSidebarApiClient {
};
}
async rematchBulkModels(filePaths) {
async rematchBulkModels(filePaths, options = {}) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
@@ -690,14 +690,19 @@ export class RecipeSidebarApiClient {
throw new Error('No recipe IDs could be derived from file paths');
}
const body = { recipe_ids: recipeIds };
// Only sent when opted in — the strict body stays exactly
// {recipe_ids} for backward compatibility.
if (options.relaxed === true) {
body.relaxed = true;
}
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
body: JSON.stringify(body),
});
const result = await response.json();
@@ -4,6 +4,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
export class GlobalContextMenu extends BaseContextMenu {
constructor() {
@@ -368,6 +369,18 @@ export class GlobalContextMenu extends BaseContextMenu {
return;
}
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
});
}
async _startRematch(menuItem, relaxed = false) {
if (this._rematchInProgress) {
return;
}
this._rematchInProgress = true;
menuItem?.classList.add('disabled');
@@ -384,6 +397,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const response = await fetch('/api/lm/recipes/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
@@ -458,6 +472,11 @@ export class GlobalContextMenu extends BaseContextMenu {
if (window.recipesPage) {
window.recipesPage.refresh();
}
// Filename-level (L4) matches are imprecise —
// always surface them for review/undo.
if (Array.isArray(p.l4_matches) && p.l4_matches.length > 0) {
rematchModalManager.showResultsModal(p.l4_matches);
}
} else if (p.status === 'error') {
throw new Error(p.error || 'Rematch failed');
} else if (p.status === 'cancelled') {
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu {
@@ -303,11 +304,22 @@ export class RecipeContextMenu extends BaseContextMenu {
// Capture before any await: the menu's click handler nulls currentCard
const filePath = this.currentCard?.dataset?.filepath;
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
scope: 'single',
onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed),
});
}
async _startRematchRecipe(recipeId, filePath, relaxed = false) {
try {
showToast('Rematching recipe to local models...', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
@@ -330,6 +342,11 @@ export class RecipeContextMenu extends BaseContextMenu {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
// Filename-level (L4) matches are imprecise — always
// surface them for review/undo.
if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) {
rematchModalManager.showResultsModal(result.l4_matches);
}
} else if (result.unresolved_entries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
+20 -21
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -1078,8 +1078,9 @@ class RecipeModal {
// Mirror the checkpoint "broken" rule: deleted, an
// unresolvable hash, or a name-only remnant with no CivitAI
// identifiers at all cannot be fixed by downloading
// reconnecting a local LoRA is the only remediation.
// identifiers at all cannot be fixed by downloading, so no
// download button is offered. Reconnect is always available
// for missing entries (see renderLoraItemActions).
const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
@@ -1180,7 +1181,7 @@ class RecipeModal {
</div>
${actionsRow}
</div>
${needsReconnect ? `
${!existsLocally ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
@@ -2853,11 +2854,7 @@ class RecipeModal {
* the model cannot be resolved never for transient transport errors.
*/
_isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
return isUnresolvableDownloadError(message);
}
getResourceCivitaiUrl(resource) {
@@ -2915,19 +2912,9 @@ class RecipeModal {
}
const controls = [];
if (needsReconnect) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
} else {
if (!needsReconnect) {
// needsReconnect already implies canDownloadLora() here, so the
// download action is unconditional.
// download action is unconditional in this branch.
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(`
@@ -2938,6 +2925,18 @@ class RecipeModal {
</button>
`);
}
// Reconnect is always offered for missing entries — when the LoRA
// already exists locally under a different hash, downloading first
// just to flip the button would be a waste.
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
}
+2
View File
@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js';
import { rematchModalManager } from './managers/RematchModalManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
@@ -68,6 +69,7 @@ export class AppCore {
window.doctorManager = doctorManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;
window.rematchModalManager = rematchModalManager;
// Initialize UI components
window.headerManager = new HeaderManager();
+17 -1
View File
@@ -3,6 +3,7 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { rematchModalManager } from './RematchModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
@@ -978,6 +979,15 @@ export class BulkManager {
return;
}
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
recipeCount: state.selectedModels.size,
onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed),
});
}
async _startRematchSelectedRecipes(relaxed = false) {
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
@@ -989,7 +999,7 @@ export class BulkManager {
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
const result = await apiClient.rematchBulkModels(filePaths);
const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
if (result.success) {
const total = result.total || filePaths.length;
@@ -1050,6 +1060,12 @@ export class BulkManager {
}
if (state.bulkMode) this.toggleBulkMode();
// Filename-level (L4) matches are imprecise — always surface
// them for review/undo.
if (Array.isArray(result.l4_matches) && result.l4_matches.length > 0) {
rematchModalManager.showResultsModal(result.l4_matches);
}
} else {
throw new Error(result.error || 'Bulk rematch failed');
}
@@ -1,7 +1,9 @@
import { showToast } from '../utils/uiHelpers.js';
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { extractRecipeId } from '../api/recipeApi.js';
import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js';
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = [];
this.pendingRecipes = [];
this.pendingMissingByRecipe = null;
}
/**
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
// Execute download
await this.executeDownload(this.pendingLoras);
this.pendingLoras = [];
this.pendingMissingByRecipe = null;
}
/**
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
// Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes);
// Kept so executeDownload can mark unresolvable failures back onto
// every recipe occurrence (hashInvalid → reconnect candidacy).
this.pendingMissingByRecipe = stats.missingLorasByRecipe;
if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let markedInvalidCount = 0;
let currentLoraProgress = 0;
let cancelled = false;
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
// An unresolvable failure (model gone on CivitAI) flips
// every recipe occurrence to reconnect candidacy — same
// rule as the single-LoRA download in RecipeModal.
if (isUnresolvableDownloadError(response.error)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
if (isUnresolvableDownloadError(error?.message)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
}
}
}
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
}, 'warning');
}
// Unresolvable failures were marked hash-invalid during the loop;
// tell the user those entries now offer reconnect instead of download.
if (markedInvalidCount > 0) {
showToast('toast.recipes.unresolvableMarkedForReconnect', {
count: markedInvalidCount
}, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`);
}
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
if (state.virtualScroller) {
const { extractRecipeId } = await import('../api/recipeApi.js');
for (const recipe of this.pendingRecipes) {
const recipeId = extractRecipeId(recipe.file_path);
if (!recipeId) continue;
@@ -354,6 +378,59 @@ export class BulkMissingLoraDownloadManager {
}
}
/**
* Mark every recipe occurrence of a failed LoRA as hash-invalid.
*
* Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag
* makes each occurrence an unresolved rematch candidate and swaps its
* action from download to reconnect. Only called for unresolvable
* failures transient errors leave entries untouched.
*
* @param {Object} failedLora - The deduplicated LoRA that failed
* @returns {Promise<number>} - How many recipe entries were marked
*/
async markLoraHashInvalidInRecipes(failedLora) {
const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId;
if (!failedKey || !this.pendingMissingByRecipe) {
return 0;
}
let marked = 0;
for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) {
const recipeId = extractRecipeId(recipe.file_path) || recipe.id;
if (!recipeId || !Array.isArray(recipe.loras)) {
continue;
}
for (const entry of missingLoras) {
const entryKey = entry.hash || entry.id || entry.modelVersionId;
if (entryKey !== failedKey) {
continue;
}
const loraIndex = recipe.loras.indexOf(entry);
if (loraIndex < 0) {
continue;
}
try {
const response = await fetch('/api/lm/recipe/lora/mark-hash-invalid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipe_id: recipeId,
lora_index: loraIndex,
}),
});
if (response.ok) {
entry.hashInvalid = true;
marked++;
}
} catch (error) {
console.warn('Failed to mark LoRA hash invalid:', error);
}
}
}
return marked;
}
/**
* Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null
+26
View File
@@ -347,6 +347,32 @@ export class ModalManager {
});
}
// Register rematchOptionsModal
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
if (rematchOptionsModal) {
this.registerModal('rematchOptionsModal', {
element: rematchOptionsModal,
onClose: () => {
this.getModal('rematchOptionsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Register rematchResultsModal
const rematchResultsModal = document.getElementById('rematchResultsModal');
if (rematchResultsModal) {
this.registerModal('rematchResultsModal', {
element: rematchResultsModal,
onClose: () => {
this.getModal('rematchResultsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}
+171
View File
@@ -0,0 +1,171 @@
import { modalManager } from './ModalManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Owns the two recipe-rematch modals:
*
* - rematchOptionsModal shown BEFORE a global/bulk/single rematch run;
* collects the "relaxed matching" opt-in and only then invokes the run
* callback.
* - rematchResultsModal shown AFTER a run that produced L4 (filename
* level) matches; lists them for review with a per-row Undo that calls
* the existing restore endpoints.
*/
export class RematchModalManager {
constructor() {
this._optionsConfirmCallback = null;
this._resultsMatches = [];
}
/**
* Open the options modal. `onConfirm({ relaxed })` fires only when the
* user clicks Rematch Cancel/X runs nothing.
*
* @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options
*/
showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) {
const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global');
const message = document.getElementById('rematchOptionsMessage');
if (message) {
if (resolvedScope === 'bulk') {
message.textContent = translate(
'modals.rematchOptions.messageBulk',
{ count: recipeCount },
`${recipeCount} selected recipe(s) will be scanned against your local model library.`
);
} else if (resolvedScope === 'single') {
message.textContent = translate(
'modals.rematchOptions.messageSingle',
{},
'This recipe will be scanned against your local model library.'
);
} else {
message.textContent = translate(
'modals.rematchOptions.messageGlobal',
{},
'All recipes will be scanned against your local model library.'
);
}
}
const checkbox = document.getElementById('rematchOptionsRelaxed');
if (checkbox) {
checkbox.checked = false;
}
this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null;
modalManager.showModal('rematchOptionsModal');
}
confirmOptions() {
const checkbox = document.getElementById('rematchOptionsRelaxed');
const relaxed = checkbox ? !!checkbox.checked : false;
const callback = this._optionsConfirmCallback;
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
if (callback) {
// Returned so callers (and tests) can await the started run.
return callback({ relaxed });
}
return undefined;
}
cancelOptions() {
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
}
/**
* Open the results modal listing L4 (filename-level) matches.
*
* @param {Array<{recipe_id: string, type: string, entry: string, file_name: string, lora_index?: number}>} l4Matches
*/
showResultsModal(l4Matches) {
if (!Array.isArray(l4Matches) || l4Matches.length === 0) {
return;
}
const list = document.getElementById('rematchResultsList');
if (!list) {
return;
}
this._resultsMatches = l4Matches;
list.innerHTML = '';
l4Matches.forEach((match, index) => {
const row = document.createElement('li');
row.className = 'rematch-results-row';
const info = document.createElement('div');
info.className = 'rematch-results-info';
const entryName = document.createElement('span');
entryName.className = 'rematch-results-entry';
entryName.textContent = match.entry || '';
const matchedFile = document.createElement('span');
matchedFile.className = 'rematch-results-file';
matchedFile.textContent = `${match.file_name || ''}`;
const recipeRef = document.createElement('span');
recipeRef.className = 'rematch-results-recipe';
recipeRef.textContent = match.recipe_id || '';
info.appendChild(entryName);
info.appendChild(matchedFile);
info.appendChild(recipeRef);
const undoButton = document.createElement('button');
undoButton.className = 'secondary-btn rematch-results-undo';
undoButton.textContent = translate('modals.rematchResults.undo', {}, 'Undo');
undoButton.addEventListener('click', () => this.undoMatch(index, row, undoButton));
row.appendChild(info);
row.appendChild(undoButton);
list.appendChild(row);
});
modalManager.showModal('rematchResultsModal');
}
/**
* Undo a single L4 match via the existing restore endpoints. On success
* the row is struck through and its button disabled.
*/
async undoMatch(index, row, button) {
const match = this._resultsMatches[index];
if (!match || button.disabled) {
return;
}
try {
const isCheckpoint = match.type === 'checkpoint';
const body = isCheckpoint
? { recipe_id: match.recipe_id }
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
const response = await fetch(
isCheckpoint
? '/api/lm/recipe/checkpoint/restore'
: '/api/lm/recipe/lora/restore',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Restore failed');
}
row.classList.add('undone');
button.disabled = true;
button.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
} catch (error) {
console.error('Failed to undo rematch match:', error);
showToast(
'modals.rematchResults.undoFailed',
{ message: error.message },
'error'
);
}
}
}
export const rematchModalManager = new RematchModalManager();
+17
View File
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
* Decide whether a download failure means the model is unrecoverable.
*
* The hash-invalid flag (and the resulting rematch/reconnect candidacy) is
* only set when CivitAI explicitly says the model cannot be resolved never
* for transient transport errors (network, 5xx).
* @param {*} message - The error message carried by the failed download
* @returns {boolean}
*/
export function isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
@@ -125,4 +125,54 @@
</button>
</div>
</div>
</div>
<!-- Recipe Rematch Options Modal -->
<div id="rematchOptionsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.rematchOptions.title') }}</h2>
<span class="close" onclick="rematchModalManager.cancelOptions()">&times;</span>
</div>
<div class="modal-body">
<p class="confirmation-message" id="rematchOptionsMessage"></p>
<label class="rematch-option-card" for="rematchOptionsRelaxed">
<input type="checkbox" id="rematchOptionsRelaxed">
<span class="rematch-option-checkmark" aria-hidden="true"></span>
<span class="rematch-option-text">
<span class="rematch-option-title">{{ t('modals.rematchOptions.relaxedLabel') }}</span>
<span class="rematch-option-caveat">
<i class="fas fa-info-circle" aria-hidden="true"></i>
{{ t('modals.rematchOptions.relaxedDescription') }}
</span>
</span>
</label>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="rematchModalManager.cancelOptions()">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="rematchOptionsConfirmBtn" onclick="rematchModalManager.confirmOptions()">
<i class="fas fa-sync-alt"></i>
{{ t('modals.rematchOptions.confirmButton') }}
</button>
</div>
</div>
</div>
<!-- Recipe Rematch L4 Results Modal -->
<div id="rematchResultsModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.rematchResults.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('rematchResultsModal')">&times;</span>
</div>
<div class="modal-body">
<p class="confirmation-message">{{ t('modals.rematchResults.message') }}</p>
<div class="rematch-results-preview">
<ul class="rematch-results-list" id="rematchResultsList"></ul>
</div>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('rematchResultsModal')">{{ t('common.actions.close') }}</button>
</div>
</div>
</div>
+15
View File
@@ -309,6 +309,21 @@ describe('RecipeSidebarApiClient bulk operations', () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it('includes relaxed in the bulk rematch body only when opted in', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true, total: 1, rematched: 1, skipped: 0, errors: 0, recipes: [] }),
});
await api.rematchBulkModels(['/recipes/a.webp'], { relaxed: true });
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
recipe_ids: ['a'],
relaxed: true,
});
});
it('throws the backend error when bulk rematch fails', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
@@ -143,6 +143,16 @@ async function flushAsyncTasks() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
// The real RematchModalManager runs against the mocked modalManager; the
// global rematch menu action now opens the options dialog first and only
// starts once confirmOptions() is invoked (the user clicking Rematch).
async function getRematchModalManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
function createDeferred() {
let resolve;
let reject;
@@ -2266,15 +2276,23 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
// The click only opens the options dialog — nothing starts yet.
expect(global.fetch).not.toHaveBeenCalled();
expect(rematchItem.classList.contains('disabled')).toBe(false);
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
expect(rematchItem.classList.contains('disabled')).toBe(true);
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
});
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
expect(global.fetch).toHaveBeenCalledTimes(2);
@@ -2331,10 +2349,15 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
expect(showToastMock).toHaveBeenCalledWith(
@@ -2384,10 +2407,15 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
expect(showToastMock).toHaveBeenCalledWith(
@@ -2437,10 +2465,15 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
expect(showToastMock).toHaveBeenCalledWith(
@@ -2489,10 +2522,15 @@ describe('Interaction-level regression coverage', () => {
});
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(global.fetch).not.toHaveBeenCalled();
const rematchModalManager = await getRematchModalManager();
const runPromise = rematchModalManager.confirmOptions();
for (let i = 0; i < 5; i++) {
await flushAsyncTasks();
}
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
expect(showToastMock).toHaveBeenCalledWith(
@@ -44,6 +44,29 @@ const flushAsyncTasks = async (rounds = 5) => {
}
};
// The single-recipe rematch now opens the options dialog first and only
// starts once confirmOptions() is invoked (the user clicking Rematch).
async function confirmRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager.confirmOptions();
}
async function cancelRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
rematchModalManager.cancelOptions();
}
async function getRematchModalManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
describe('RecipeContextMenu.rematchRecipe', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -91,8 +114,16 @@ describe('RecipeContextMenu.rematchRecipe', () => {
await flushAsyncTasks();
// The click only opened the options dialog — nothing started yet.
expect(global.fetch).not.toHaveBeenCalled();
await confirmRematchOptions();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
});
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
@@ -126,6 +157,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchUnmatched',
@@ -155,6 +188,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
@@ -186,6 +221,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -207,6 +244,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -214,4 +253,88 @@ describe('RecipeContextMenu.rematchRecipe', () => {
'error'
);
});
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
document.body.insertAdjacentHTML(
'beforeend',
'<input type="checkbox" id="rematchOptionsRelaxed">'
);
global.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 0, skipped: 1 }),
});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
// The dialog resets the checkbox to unchecked on open; the user opts in.
document.getElementById('rematchOptionsRelaxed').checked = true;
await confirmRematchOptions();
await flushAsyncTasks();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: true }),
});
});
it('starts nothing when the options dialog is cancelled', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await cancelRematchOptions();
await flushAsyncTasks();
expect(global.fetch).not.toHaveBeenCalled();
});
it('shows the results modal when the result carries l4_matches', async () => {
const menu = await createMenu();
const card = document.getElementById('card');
menu.showMenu(100, 100, card);
const l4Matches = [
{ recipe_id: 'recipe-1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
];
global.fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true, rematched: 1, matched_entries: 1, l4_matches: l4Matches }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
});
const rematchModalManager = await getRematchModalManager();
const showResultsSpy = vi
.spyOn(rematchModalManager, 'showResultsModal')
.mockImplementation(() => {});
document
.querySelector('[data-action="rematch"]')
.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
showResultsSpy.mockRestore();
});
});
@@ -51,6 +51,10 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(),
// Keep the real predicate: the download-failure tests assert on its
// unresolvable-error classification.
isUnresolvableDownloadError: (message) =>
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
@@ -292,7 +296,7 @@ describe('RecipeModal resource item interactions', () => {
);
});
it('renders a download action (not reconnect) for a version-only LoRA', async () => {
it('renders a download action alongside reconnect for a version-only LoRA', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
@@ -301,10 +305,12 @@ describe('RecipeModal resource item interactions', () => {
expect(item).not.toBeNull();
expect(item.classList.contains('missing-locally')).toBe(true);
// Missing from the local library (badge) but still downloadable by its
// exact CivitAI version id, so the row offers Download, not Reconnect.
// exact CivitAI version id, so the row offers Download as the primary
// action; Reconnect stays available for entries the user already has
// locally under a different hash.
expect(item.querySelector('.missing-badge')).not.toBeNull();
expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).toBeNull();
expect(item.querySelector('.lora-reconnect')).not.toBeNull();
});
it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => {
@@ -588,10 +594,12 @@ describe('RecipeModal resource item interactions', () => {
await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
// The entry keeps the download action and never flips to reconnect
// The entry keeps the download action and never flips to hash-invalid
// (reconnect is always present for missing entries now; the signal here
// is that the download action survives and no invalid badge appears)
const item = document.querySelector('[data-lora-index="1"]');
expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).toBeNull();
expect(item.querySelector('.invalid-hash-badge')).toBeNull();
});
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
@@ -73,6 +73,22 @@ vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
getNsfwLevelSelector: vi.fn(),
}));
// The real RematchModalManager runs against the mocked modalManager; confirm
// is invoked explicitly, mirroring the user clicking Rematch in the dialog.
async function confirmRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager.confirmOptions();
}
async function cancelRematchOptions() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
rematchModalManager.cancelOptions();
}
describe('BulkManager.rematchSelectedRecipes', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -114,12 +130,16 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith([
'/recipes/a.webp',
'/recipes/b.webp',
'/recipes/c.webp',
]);
expect(rematchBulkModelsMock).toHaveBeenCalledWith(
[
'/recipes/a.webp',
'/recipes/b.webp',
'/recipes/c.webp',
],
{ relaxed: false }
);
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
@@ -155,6 +175,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchCompleteErrors',
@@ -182,6 +203,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchAllFailed',
@@ -215,6 +237,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchUnmatched',
@@ -243,6 +266,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped',
@@ -268,6 +292,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -284,6 +309,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed',
@@ -319,4 +345,100 @@ describe('BulkManager.rematchSelectedRecipes', () => {
);
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
});
it('does not start the rematch until the options dialog is confirmed', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
await bulk.rematchSelectedRecipes();
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 1,
skipped: 0,
errors: 0,
matched_recipes: 1,
matched_entries: 1,
recipes: [],
});
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: false });
});
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
document.body.innerHTML = '<input type="checkbox" id="rematchOptionsRelaxed">';
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 0,
skipped: 1,
errors: 0,
recipes: [],
});
await bulk.rematchSelectedRecipes();
// The dialog resets the checkbox to unchecked on open; the user opts in.
document.getElementById('rematchOptionsRelaxed').checked = true;
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: true });
document.body.innerHTML = '';
});
it('starts nothing when the options dialog is cancelled', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
await bulk.rematchSelectedRecipes();
await cancelRematchOptions();
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.rematchComplete',
expect.anything(),
expect.anything()
);
});
it('shows the L4 results modal when the bulk result carries l4_matches', async () => {
const bulk = await createBulkManager();
stateStub.selectedModels.add('/recipes/a.webp');
const l4Matches = [
{ recipe_id: 'a', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
];
rematchBulkModelsMock.mockResolvedValue({
success: true,
total: 1,
rematched: 1,
skipped: 0,
errors: 0,
matched_recipes: 1,
matched_entries: 1,
recipes: [],
l4_matches: l4Matches,
});
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
const showResultsSpy = vi
.spyOn(rematchModalManager, 'showResultsModal')
.mockImplementation(() => {});
await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showResultsSpy).toHaveBeenCalledWith(l4Matches);
showResultsSpy.mockRestore();
});
});
@@ -0,0 +1,192 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const MODULE = '../../../static/js/managers/BulkMissingLoraDownloadManager.js';
const showToastMock = vi.fn();
const updateProgressMock = vi.fn();
const updateSingleItemMock = vi.fn();
const mockApiClient = {
downloadModel: vi.fn(),
cancelDownload: vi.fn(),
fetchModelRoots: vi.fn(() => Promise.resolve({ roots: ['/models/loras'] })),
};
const loadingManagerStub = {
showDownloadProgress: vi.fn(() => updateProgressMock),
setStatus: vi.fn(),
showCancelButton: vi.fn(),
hide: vi.fn(),
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
// Keep the real predicate: these tests assert on its classification.
isUnresolvableDownloadError: (message) =>
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => mockApiClient),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
extractRecipeId: (filePath) => {
if (!filePath) return null;
const basename = filePath.split('/').pop().split('\\').pop();
const dotIndex = basename.lastIndexOf('.');
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
},
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: {
loadingManager: loadingManagerStub,
virtualScroller: { updateSingleItem: updateSingleItemMock },
global: { settings: {} },
},
}));
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
}));
/** Mirrors the FakeWebSocket pattern from downloadManager.batchSummary.test.js. */
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
queueMicrotask(() => {
if (this.onopen) this.onopen();
});
}
}
const makeRecipe = (filePath, loras) => ({ file_path: filePath, loras });
describe('BulkMissingLoraDownloadManager unresolvable-failure write-back', () => {
let manager;
let fetchMock;
let requests;
beforeEach(async () => {
FakeWebSocket.instances = [];
vi.clearAllMocks();
loadingManagerStub.showDownloadProgress.mockReturnValue(updateProgressMock);
requests = [];
fetchMock = vi.fn((url, options) => {
requests.push({ url, options });
if (url === '/api/lm/recipe/lora/mark-hash-invalid') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
// Recipe detail refresh after the download loop
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'refreshed' }) });
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.resetModules();
({ bulkMissingLoraDownloadManager: manager } = await import(MODULE));
manager.pendingLoras = [];
manager.pendingRecipes = [];
manager.pendingMissingByRecipe = null;
});
afterEach(() => {
vi.unstubAllGlobals();
});
const primePending = (recipes) => {
const stats = manager.collectMissingLoras(recipes);
manager.pendingRecipes = recipes;
manager.pendingMissingByRecipe = stats.missingLorasByRecipe;
return stats.uniqueLoras;
};
it('marks every recipe occurrence hash-invalid when the failure is unresolvable', async () => {
const entryA = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const entryB = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe1 = makeRecipe('/recipes/r1.json', [entryA]);
const recipe2 = makeRecipe('/recipes/r2.json', [{ hash: 'x', file_name: 'keep.safetensors', inLibrary: true }, entryB]);
const uniqueLoras = primePending([recipe1, recipe2]);
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Model not found' });
await manager.executeDownload(uniqueLoras);
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
expect(markCalls).toHaveLength(2);
const payloads = markCalls.map(r => JSON.parse(r.options.body));
expect(payloads).toContainEqual({ recipe_id: 'r1', lora_index: 0 });
expect(payloads).toContainEqual({ recipe_id: 'r2', lora_index: 1 });
expect(entryA.hashInvalid).toBe(true);
expect(entryB.hashInvalid).toBe(true);
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.unresolvableMarkedForReconnect',
{ count: 2 },
'info',
expect.any(String),
);
});
it('leaves entries untouched when the failure is transient', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Connection timed out' });
await manager.executeDownload(uniqueLoras);
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
expect(entry.hashInvalid).toBeUndefined();
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.recipes.unresolvableMarkedForReconnect',
expect.anything(),
expect.anything(),
expect.anything(),
);
});
it('marks hash-invalid when the download request itself throws an unresolvable error', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockRejectedValue(new Error('410 Gone'));
await manager.executeDownload(uniqueLoras);
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
expect(markCalls).toHaveLength(1);
expect(entry.hashInvalid).toBe(true);
});
it('does not mark entries whose download succeeds', async () => {
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
const recipe = makeRecipe('/recipes/r1.json', [entry]);
const uniqueLoras = primePending([recipe]);
mockApiClient.downloadModel.mockResolvedValue({ success: true });
await manager.executeDownload(uniqueLoras);
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
});
});
@@ -0,0 +1,226 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const modalManagerMock = {
showModal: vi.fn(),
closeModal: vi.fn(),
};
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: modalManagerMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
async function getManager() {
const { rematchModalManager } = await import(
'../../../static/js/managers/RematchModalManager.js'
);
return rematchModalManager;
}
describe('RematchModalManager options dialog', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<p id="rematchOptionsMessage"></p>
<input type="checkbox" id="rematchOptionsRelaxed">
`;
});
afterEach(() => {
document.body.innerHTML = '';
});
it('does not invoke the callback until confirmOptions is called', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ recipeCount: 3, onConfirm });
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchOptionsModal');
expect(onConfirm).not.toHaveBeenCalled();
// Bulk message mentions the selection size.
expect(document.getElementById('rematchOptionsMessage').textContent).toContain('3');
// The checkbox always starts unchecked.
expect(document.getElementById('rematchOptionsRelaxed').checked).toBe(false);
manager.confirmOptions();
expect(onConfirm).toHaveBeenCalledWith({ relaxed: false });
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
});
it('uses the generic message when no recipe count is given', async () => {
const manager = await getManager();
manager.showOptionsModal({ onConfirm: vi.fn() });
expect(translateMock).toHaveBeenCalledWith(
'modals.rematchOptions.messageGlobal',
{},
'All recipes will be scanned against your local model library.'
);
});
it('uses the single-recipe message for scope: single', async () => {
const manager = await getManager();
manager.showOptionsModal({ scope: 'single', onConfirm: vi.fn() });
expect(translateMock).toHaveBeenCalledWith(
'modals.rematchOptions.messageSingle',
{},
'This recipe will be scanned against your local model library.'
);
});
it('passes relaxed: true when the checkbox is checked', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ onConfirm });
document.getElementById('rematchOptionsRelaxed').checked = true;
manager.confirmOptions();
expect(onConfirm).toHaveBeenCalledWith({ relaxed: true });
});
it('resets the checkbox to unchecked each time the dialog opens', async () => {
const manager = await getManager();
const checkbox = document.getElementById('rematchOptionsRelaxed');
checkbox.checked = true;
manager.showOptionsModal({ onConfirm: vi.fn() });
expect(checkbox.checked).toBe(false);
});
it('cancelOptions runs nothing and clears the callback', async () => {
const manager = await getManager();
const onConfirm = vi.fn();
manager.showOptionsModal({ onConfirm });
manager.cancelOptions();
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
// A later confirm must not fire the cancelled callback.
manager.confirmOptions();
expect(onConfirm).not.toHaveBeenCalled();
});
});
describe('RematchModalManager results modal', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '<ul id="rematchResultsList"></ul>';
global.fetch = vi.fn();
});
afterEach(() => {
document.body.innerHTML = '';
delete global.fetch;
});
it('does nothing for an empty match list', async () => {
const manager = await getManager();
manager.showResultsModal([]);
expect(modalManagerMock.showModal).not.toHaveBeenCalled();
});
it('renders one row per L4 match with entry, file name and recipe', async () => {
const manager = await getManager();
manager.showResultsModal([
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 },
{ recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' },
]);
const rows = document.querySelectorAll('#rematchResultsList .rematch-results-row');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('old.safetensors');
expect(rows[0].textContent).toContain('new.safetensors');
expect(rows[0].textContent).toContain('r1');
expect(rows[1].textContent).toContain('cp-new.safetensors');
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchResultsModal');
});
it('undo posts to the lora restore endpoint and disables the row', async () => {
const manager = await getManager();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
manager.showResultsModal([
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 },
]);
const row = document.querySelector('.rematch-results-row');
const button = row.querySelector('.rematch-results-undo');
button.click();
await vi.waitFor(() => expect(button.disabled).toBe(true));
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
});
expect(row.classList.contains('undone')).toBe(true);
});
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
const manager = await getManager();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
manager.showResultsModal([
{ recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' },
]);
const button = document.querySelector('.rematch-results-undo');
button.click();
await vi.waitFor(() => expect(button.disabled).toBe(true));
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipe_id: 'r2' }),
});
});
it('keeps the row actionable and toasts when undo fails', async () => {
const manager = await getManager();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: false, error: 'no snapshot' }),
});
manager.showResultsModal([
{ recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
]);
const row = document.querySelector('.rematch-results-row');
const button = row.querySelector('.rematch-results-undo');
button.click();
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
expect(button.disabled).toBe(false);
expect(row.classList.contains('undone')).toBe(false);
expect(showToastMock).toHaveBeenCalledWith(
'modals.rematchResults.undoFailed',
{ message: 'no snapshot' },
'error'
);
});
});
+89 -3
View File
@@ -60,6 +60,9 @@ class StubRecipeScanner:
self.rematch_all_calls: List[Any] = []
self.rematch_by_id_calls: List[str] = []
self.rematch_bulk_calls: List[List[str]] = []
self.rematch_all_relaxed: List[bool] = []
self.rematch_by_id_relaxed: List[bool] = []
self.rematch_bulk_relaxed: List[bool] = []
self.rematch_results: Dict[str, Dict[str, Any]] = {}
async def _noop_get_cached_data(force_refresh: bool = False) -> None: # noqa: ARG001 - signature mirrors real scanner
@@ -131,7 +134,7 @@ class StubRecipeScanner:
def reset_cancellation(self) -> None:
self.reset_calls += 1
async def rematch_all_recipes(self, progress_callback=None):
async def rematch_all_recipes(self, progress_callback=None, *, relaxed: bool = False):
"""Run a canned rematch-all run, mirroring the real progress events."""
if progress_callback:
await progress_callback({"status": "started"})
@@ -142,6 +145,7 @@ class StubRecipeScanner:
{"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1}
)
self.rematch_all_calls.append(progress_callback)
self.rematch_all_relaxed.append(relaxed)
return {
"success": True,
"status": "completed",
@@ -151,14 +155,20 @@ class StubRecipeScanner:
"total": 1,
}
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
async def rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
self.rematch_by_id_calls.append(recipe_id)
self.rematch_by_id_relaxed.append(relaxed)
if recipe_id not in self.rematch_results:
raise RecipeNotFoundError(f"Recipe not found: {recipe_id}")
return self.rematch_results[recipe_id]
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
async def rematch_recipes_bulk(
self, recipe_ids: List[str], *, relaxed: bool = False
) -> Dict[str, Any]:
self.rematch_bulk_calls.append(list(recipe_ids))
self.rematch_bulk_relaxed.append(relaxed)
total = len(recipe_ids)
rematched = 0
skipped = 0
@@ -1992,6 +2002,82 @@ async def test_rematch_recipe_maps_not_found_to_404(monkeypatch, tmp_path: Path)
assert harness.scanner.rematch_by_id_calls == ["ghost"]
async def test_rematch_recipes_passes_relaxed_flag_from_body(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/rematch", json={"relaxed": True}
)
payload = await response.json()
assert response.status == 200, payload
await asyncio.sleep(0.1)
assert harness.scanner.rematch_all_relaxed == [True]
async def test_rematch_recipes_relaxed_defaults_to_false(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipes/rematch")
payload = await response.json()
assert response.status == 200, payload
await asyncio.sleep(0.1)
assert harness.scanner.rematch_all_relaxed == [False]
async def test_rematch_recipes_relaxed_query_param_fallback(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post("/api/lm/recipes/rematch?relaxed=true")
payload = await response.json()
assert response.status == 200, payload
await asyncio.sleep(0.1)
assert harness.scanner.rematch_all_relaxed == [True]
async def test_rematch_recipes_bulk_passes_relaxed_flag_from_body(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/rematch-bulk",
json={"recipe_ids": ["r1"], "relaxed": True},
)
payload = await response.json()
assert response.status == 200, payload
assert harness.scanner.rematch_bulk_relaxed == [True]
async def test_rematch_recipes_bulk_relaxed_query_param_fallback(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipes/rematch-bulk?relaxed=true",
json={"recipe_ids": ["r1"]},
)
payload = await response.json()
assert response.status == 200, payload
assert harness.scanner.rematch_bulk_relaxed == [True]
async def test_rematch_recipe_passes_relaxed_flag_from_query(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.rematch_results = {
"abc123": {"success": True, "rematched": 1},
}
response = await harness.client.post(
"/api/lm/recipe/abc123/rematch?relaxed=true"
)
payload = await response.json()
assert response.status == 200, payload
assert harness.scanner.rematch_by_id_relaxed == [True]
async def test_get_rematch_progress_404_when_no_progress(
monkeypatch, tmp_path: Path
) -> None:
+309 -1
View File
@@ -4012,6 +4012,7 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
"entry": "old.safetensors",
"file_name": "m.safetensors",
"match_level": "L1",
"lora_index": 0,
}
]
assert result["recipe"] is enriched
@@ -4032,6 +4033,79 @@ async def test_rematch_recipe_by_id_lora_l1_write_back(tmp_path: Path, monkeypat
assert resort_calls == [] # Metis F1 — hoisted to public entry points
# Rematch write-back must snapshot the pre-match state (undo affordance)
async def test_write_rematch_lora_entry_snapshots_pre_match_state(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
original_entry = {
"isDeleted": True,
"hashInvalid": False,
"hash": "oldhash",
"file_name": "old.safetensors",
"modelVersionId": 0,
"modelName": "Old Name",
}
entry = dict(original_entry)
item = _civitai_lora_item(
sha256="b" * 64,
version_id=222,
name="v2.0",
model_name="New Model",
file_name="new.safetensors",
)
scanner._write_rematch_lora_entry(entry, item)
assert entry["hash"] == "b" * 64
assert entry["file_name"] == "new.safetensors"
assert entry["reconnectSnapshot"] == original_entry
async def test_write_rematch_lora_entry_snapshot_never_nests(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
entry = {
"isDeleted": True,
"hash": "oldhash",
"file_name": "old.safetensors",
"reconnectSnapshot": {"file_name": "even-older.safetensors"},
}
item = _civitai_lora_item(sha256="c" * 64, file_name="new.safetensors")
scanner._write_rematch_lora_entry(entry, item)
snapshot = entry["reconnectSnapshot"]
assert snapshot["file_name"] == "old.safetensors"
assert "reconnectSnapshot" not in snapshot
async def test_write_rematch_checkpoint_entry_snapshots_pre_match_state(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
original_entry = {
"isDeleted": True,
"hashInvalid": True,
"hash": "oldhash",
"file_name": "old.safetensors",
"name": "Old CP",
"modelVersionId": 0,
}
entry = dict(original_entry)
item = _civitai_checkpoint_item(
sha256="d" * 64,
version_id=333,
name="cp-v1",
model_name="New CP",
file_name="new-cp.safetensors",
)
scanner._write_rematch_checkpoint_entry(entry, item)
assert entry["hash"] == "d" * 64
assert entry["file_name"] == "new-cp.safetensors"
assert entry["reconnectSnapshot"] == original_entry
assert "reconnectSnapshot" not in entry["reconnectSnapshot"]
# Acceptance criterion (2): checkpoint entry rematched via L2 — parser style
@@ -4659,6 +4733,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
local_cache: dict[str, Any],
autov3_cache: dict[str, Any],
filename_cache=None,
**_kwargs: Any,
) -> tuple[int, int, dict[str, Any]]:
if recipe.get("id") == "boom":
raise RuntimeError("kaboom")
@@ -4717,12 +4792,15 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
local_cache: dict[str, Any],
autov3_cache: dict[str, Any],
filename_cache=None,
**kwargs: Any,
) -> tuple[int, int, dict[str, Any]]:
nonlocal entered
if recipe.get("id") == "r0":
entered = True
await release.wait()
return await original(recipe, local_cache, autov3_cache, filename_cache)
return await original(
recipe, local_cache, autov3_cache, filename_cache, **kwargs
)
monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
@@ -4899,6 +4977,236 @@ async def test_rematch_all_autov3_cache_reuse_across_calls(
assert len(called) == 1
# ---------------------------------------------------------------------------
# Relaxed rematch candidacy (Feature 3)
# ---------------------------------------------------------------------------
async def test_is_rematch_candidate_relaxed_accepts_healthy_entry(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
healthy = {"hash": "abc", "file_name": "m.safetensors"}
assert scanner._is_rematch_candidate(healthy, relaxed=True)
# Default strict behavior is unchanged.
assert not scanner._is_rematch_candidate(healthy)
assert not scanner._is_rematch_candidate(healthy, relaxed=False)
async def test_is_rematch_candidate_relaxed_still_requires_identifier(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
assert not scanner._is_rematch_candidate({}, relaxed=True)
assert not scanner._is_rematch_candidate({"isDeleted": True}, relaxed=True)
assert not scanner._is_rematch_candidate("garbage", relaxed=True)
async def test_rematch_relaxed_skips_healthy_entry_with_local_hash(
tmp_path: Path, monkeypatch
):
# Anti-churn: a relaxed-only candidate whose hash already resolves in the
# L1 local cache is already correctly linked — no write-back, no
# snapshot, and it counts as neither matched nor unresolved.
sha256 = ("A1" * 32).lower()
item = _civitai_lora_item(sha256=sha256, file_name="m.safetensors")
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"loras": [{"hash": sha256, "file_name": "m.safetensors"}],
}
_set_recipe_cache(scanner, [recipe])
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
assert result["success"] is True
assert result["matched_entries"] == 0
assert result["unresolved_entries"] == 0
assert result["details"] == {"matched": [], "unresolved": []}
assert saved == []
assert "reconnectSnapshot" not in recipe["loras"][0]
async def test_rematch_relaxed_matches_healthy_missing_entry_via_l4(
tmp_path: Path, monkeypatch
):
# A healthy entry whose hash is NOT in the local library becomes an L4
# filename match under relaxed mode when the base models agree.
sha256 = ("B2" * 32).lower()
item = _rematch_item(
sha256=sha256,
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [
{
"hash": "f" * 64, # not present locally
"file_name": "detail.safetensors",
}
],
}
_set_recipe_cache(scanner, [recipe])
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
# Strict mode never touches the healthy entry.
strict = await scanner.rematch_recipe_by_id("r1")
assert strict["matched_entries"] == 0
assert saved == []
result = await scanner.rematch_recipe_by_id("r1", relaxed=True)
assert result["matched_entries"] == 1
assert result["details"]["matched"] == [
{
"type": "lora",
"entry": "detail.safetensors",
"file_name": "detail.safetensors",
"match_level": "L4",
"lora_index": 0,
}
]
entry = recipe["loras"][0]
assert entry["hash"] == sha256
assert entry["reconnectSnapshot"]["hash"] == "f" * 64
assert saved == [recipe]
async def test_rematch_matched_details_carry_lora_index_and_bulk_flattens_l4(
tmp_path: Path, monkeypatch
):
sha256_l1 = ("C3" * 32).lower()
l1_item = _civitai_lora_item(sha256=sha256_l1, file_name="l1.safetensors")
l4_item = _rematch_item(
sha256=("D4" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
recipes: list[Dict[str, Any]] = [
{
"id": "r0",
"base_model": "SD 1.5",
"loras": [
# index 0: not a candidate at all (healthy, strict run)
{"hash": "zzz", "file_name": "other.safetensors"},
# index 1: L4 filename match
{"isDeleted": True, "file_name": "detail.safetensors"},
# index 2: L1 hash match
{
"isDeleted": True,
"hash": sha256_l1,
"file_name": "old.safetensors",
},
],
},
{"id": "r1", "loras": []},
]
_set_recipe_cache(scanner, recipes)
await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_resort(scanner, monkeypatch)
result = await scanner.rematch_recipes_bulk(["r0", "r1"])
assert result["matched_entries"] == 2
matched = result["details"][0]["matched"]
assert matched[0]["lora_index"] == 1
assert matched[0]["match_level"] == "L4"
assert matched[1]["lora_index"] == 2
assert matched[1]["match_level"] == "L1"
# Only the L4 match is flattened for review; L1 matches need none.
assert result["l4_matches"] == [
{
"recipe_id": "r0",
"type": "lora",
"entry": "detail.safetensors",
"file_name": "detail.safetensors",
"lora_index": 1,
}
]
async def test_rematch_recipe_by_id_returns_flattened_l4_matches(
tmp_path: Path, monkeypatch
):
# The single-recipe return carries the same flattened l4_matches shape
# as the bulk/global paths so the frontend results modal works for all
# three entry points.
l4_item = _rematch_item(
sha256=("F6" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([l4_item], [], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [{"isDeleted": True, "file_name": "detail.safetensors"}],
}
_set_recipe_cache(scanner, [recipe])
await _spy_rematch_persistence(scanner, monkeypatch)
result = await scanner.rematch_recipe_by_id("r1")
assert result["l4_matches"] == [
{
"recipe_id": "r1",
"type": "lora",
"entry": "detail.safetensors",
"file_name": "detail.safetensors",
"lora_index": 0,
}
]
async def test_rematch_all_recipes_reports_l4_matches_in_completed_payload(
tmp_path: Path, monkeypatch
):
l4_item = _rematch_item(
sha256=("E5" * 32).lower(),
sub_type="checkpoint",
base_model="SDXL",
file_name="realistic.safetensors",
)
scanner, _, _ = _make_rematch_scanner([], [l4_item], tmp_path)
recipe: Dict[str, Any] = {
"id": "r1",
"loras": [],
"checkpoint": {
"isDeleted": True,
"file_name": "realistic.safetensors",
"baseModel": "SDXL",
},
}
_set_recipe_cache(scanner, [recipe])
await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_resort(scanner, monkeypatch)
events: list[Dict[str, Any]] = []
async def cb(ev: Dict[str, Any]) -> None:
events.append(ev)
result = await scanner.rematch_all_recipes(progress_callback=cb)
expected_l4 = [
{
"recipe_id": "r1",
"type": "checkpoint",
"entry": "realistic.safetensors",
"file_name": "realistic.safetensors",
}
]
# Checkpoint matches carry no lora_index (the checkpoint restore
# endpoint only needs recipe_id).
assert result["l4_matches"] == expected_l4
completed = [e for e in events if e["status"] == "completed"]
assert completed and completed[0]["l4_matches"] == expected_l4
async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner