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.", "note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen" "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": { "exampleAccess": {
"title": "Lokale Beispielbilder", "title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:", "message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -2168,6 +2184,7 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen", "createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt", "created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "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", "noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar", "noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "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.", "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)" "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": { "exampleAccess": {
"title": "Local Example Images", "title": "Local Example Images",
"message": "No local example images found for this model. View options:", "message": "No local example images found for this model. View options:",
@@ -2168,6 +2184,7 @@
"createMissingData": "Missing required data to create recipe", "createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully", "created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download", "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", "noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available", "noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "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.", "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)" "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": { "exampleAccess": {
"title": "Imágenes de ejemplo locales", "title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:", "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", "createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente", "created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "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", "noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible", "noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "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.", "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)" "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": { "exampleAccess": {
"title": "Images d'exemple locales", "title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :", "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", "createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès", "created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "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", "noPreviousRecipe": "Aucune Recipe précédente",
"noNextRecipe": "Aucune Recipe suivante", "noNextRecipe": "Aucune Recipe suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.", "note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)" "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": { "exampleAccess": {
"title": "תמונות דוגמה מקומיות", "title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:", "message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -2168,6 +2184,7 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון", "createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה", "created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "אין מתכון קודם זמין", "noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין", "noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。", "note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の 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": { "exampleAccess": {
"title": "ローカル例画像", "title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:", "message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -2168,6 +2184,7 @@
"createMissingData": "レシピ作成に必要なデータが不足しています", "createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました", "created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "前のレシピがありません", "noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません", "noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.", "note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 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": { "exampleAccess": {
"title": "로컬 예시 이미지", "title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:", "message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -2168,6 +2184,7 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다", "createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다", "created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "이전 레시피가 없습니다", "noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다", "noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.", "note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)" "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": { "exampleAccess": {
"title": "Локальные примеры изображений", "title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:", "message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -2168,6 +2184,7 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта", "createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан", "created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "Предыдущий рецепт отсутствует", "noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует", "noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。", "note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)" "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": { "exampleAccess": {
"title": "本地示例图片", "title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:", "message": "未找到此模型的本地示例图片。可选操作:",
@@ -2168,6 +2184,7 @@
"createMissingData": "缺少创建配方所需的数据", "createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功", "created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "没有上一个配方", "noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方", "noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
+17
View File
@@ -1501,6 +1501,22 @@
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。", "note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)" "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": { "exampleAccess": {
"title": "本機範例圖片", "title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:", "message": "此模型未找到本機範例圖片。可選擇:",
@@ -2168,6 +2184,7 @@
"createMissingData": "缺少建立配方所需的資料", "createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功", "created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"unresolvableMarkedForReconnect": "[TODO: Translate] {count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "沒有上一個配方", "noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方", "noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "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) 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) @dataclass(frozen=True)
class RecipeHandlerSet: class RecipeHandlerSet:
"""Group of handlers providing recipe route implementations.""" """Group of handlers providing recipe route implementations."""
@@ -812,6 +832,8 @@ class RecipeManagementHandler:
recipe_scanner.reset_cancellation() recipe_scanner.reset_cancellation()
relaxed = await _parse_relaxed_flag(request)
async def progress_callback(data): async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data) await self._ws_manager.broadcast_recipe_rematch_progress(data)
@@ -819,7 +841,8 @@ class RecipeManagementHandler:
async def run_rematch(): async def run_rematch():
try: try:
await recipe_scanner.rematch_all_recipes( await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback progress_callback=progress_callback,
relaxed=relaxed,
) )
except Exception as e: except Exception as e:
self._logger.error( self._logger.error(
@@ -892,7 +915,13 @@ class RecipeManagementHandler:
status=400, 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) return web.json_response(result)
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error(
@@ -921,7 +950,10 @@ class RecipeManagementHandler:
) )
recipe_id = request.match_info["recipe_id"] 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) return web.json_response(result)
except RecipeNotFoundError as exc: except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404) 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())) suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
return suggestions[:limit] 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. """Return True when a recipe entry is eligible for local re-matching.
An entry counts as unresolved when its identity is known to be An entry counts as unresolved when its identity is known to be
broken (``isDeleted`` or ``hashInvalid``) or when it is missing broken (``isDeleted`` or ``hashInvalid``) or when it is missing
identity fields (``hash``/``file_name``). A healthy entry whose identity fields (``hash``/``file_name``). A healthy entry whose
hash is simply not present in the local library is NOT a candidate: 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 in the default strict mode: it may be a recipe imported without
its CivitAI-valid hash must never be overwritten by the imprecise downloading the model yet, and its CivitAI-valid hash must never be
filename fallback. 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): if not isinstance(entry, dict):
return False return False
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
has_identifier = ( has_identifier = (
entry.get("hash") entry.get("hash")
or entry.get("modelVersionId") or entry.get("modelVersionId")
or entry.get("id") or entry.get("id")
or entry.get("file_name") 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]]: async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
"""Build a version-cached map of computed AutoV3 hashes to local items. """Build a version-cached map of computed AutoV3 hashes to local items.
@@ -809,7 +820,9 @@ class RecipeScanner:
"""Check if cancellation has been requested.""" """Check if cancellation has been requested."""
return self._cancel_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. """Rematch a single recipe's deleted lora/checkpoint entries locally.
Logs one INFO summary line for this run and delegates the per-recipe Logs one INFO summary line for this run and delegates the per-recipe
@@ -817,12 +830,14 @@ class RecipeScanner:
Args: Args:
recipe_id: ID of the recipe to rematch recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns: Returns:
Dict summary of the rematch result (see ``_rematch_recipe_by_id``). Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
Raises RecipeNotFoundError when the recipe is missing. 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 recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
logger.info( logger.info(
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors", "Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
@@ -835,7 +850,9 @@ class RecipeScanner:
) )
return result 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. """Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache, computed autov3 cache, filename Match snapshots (local hash cache, computed autov3 cache, filename
@@ -846,12 +863,16 @@ class RecipeScanner:
Args: Args:
recipe_id: ID of the recipe to rematch recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns: Returns:
Dict summary of the rematch result with unified counters Dict summary of the rematch result with unified counters
(matched_recipes, matched_entries, unresolved_recipes, (matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy rematched/skipped/errors 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 field means "recipe not updated" and overlaps
``unresolved_recipes`` (a recipe with unmatched candidates counts ``unresolved_recipes`` (a recipe with unmatched candidates counts
as both). Raises RecipeNotFoundError when the recipe is missing. as both). Raises RecipeNotFoundError when the recipe is missing.
@@ -872,7 +893,8 @@ class RecipeScanner:
try: try:
rematched, _errors, details = await self._rematch_single_recipe( 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: except RecipePersistenceError as exc:
logger.error( logger.error(
@@ -891,12 +913,16 @@ class RecipeScanner:
"unresolved_recipes": 0, "unresolved_recipes": 0,
"unresolved_entries": 0, "unresolved_entries": 0,
"details": {"matched": [], "unresolved": []}, "details": {"matched": [], "unresolved": []},
"l4_matches": [],
"recipe": recipe, "recipe": recipe,
"error": str(exc), "error": str(exc),
} }
unresolved_entries = len(details["unresolved"]) unresolved_entries = len(details["unresolved"])
unresolved_recipes = 1 if unresolved_entries > 0 else 0 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: if rematched == 0:
return { return {
@@ -908,6 +934,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"details": details, "details": details,
"l4_matches": l4_matches,
"recipe": recipe, "recipe": recipe,
} }
@@ -921,6 +948,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"details": details, "details": details,
"l4_matches": l4_matches,
"recipe": await self.get_recipe_by_id(recipe_id), "recipe": await self.get_recipe_by_id(recipe_id),
} }
@@ -930,6 +958,8 @@ class RecipeScanner:
local_cache: dict[str, dict[str, Any]], local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]], autov3_cache: dict[str, dict[str, Any]],
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None, filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
*,
relaxed: bool = False,
) -> Tuple[int, int, Dict[str, Any]]: ) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models. """Rematch a single recipe's lora/checkpoint entries against local models.
@@ -945,16 +975,24 @@ class RecipeScanner:
autov3_cache: L3 computed-autov3 cache snapshot autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback 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: Returns:
Tuple of (rematched_entries, errors, details). The errors element Tuple of (rematched_entries, errors, details). The errors element
is always 0 on a normal return a persistence failure RAISES is always 0 on a normal return a persistence failure RAISES
``RecipePersistenceError`` so callers can count it. ``details`` ``RecipePersistenceError`` so callers can count it. ``details``
carries the per-entry outcome: 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 "unresolved": [{type, entry}]}`` where an unresolved entry is a
rematch candidate that found no local match an expected outcome rematch candidate that found no local match an expected outcome
(the model may simply not exist locally), not an error. (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: Raises:
RecipePersistenceError: when the recipe changed but RecipePersistenceError: when the recipe changed but
@@ -963,11 +1001,23 @@ class RecipeScanner:
rematched = 0 rematched = 0
details: Dict[str, Any] = {"matched": [], "unresolved": []} 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 # Lora entries
loras = recipe.get("loras", []) loras = recipe.get("loras", [])
if isinstance(loras, list): if isinstance(loras, list):
for entry in loras: for lora_index, entry in enumerate(loras):
if not self._is_rematch_candidate(entry): if not is_actionable_candidate(entry):
continue continue
item, level = await self._match_rematch_entry_with_level( item, level = await self._match_rematch_entry_with_level(
entry, entry,
@@ -991,6 +1041,7 @@ class RecipeScanner:
"entry": self._entry_identifier(entry), "entry": self._entry_identifier(entry),
"file_name": item.get("file_name") or "", "file_name": item.get("file_name") or "",
"match_level": level, "match_level": level,
"lora_index": lora_index,
} }
) )
self._write_rematch_lora_entry(entry, item) self._write_rematch_lora_entry(entry, item)
@@ -1000,7 +1051,7 @@ class RecipeScanner:
# silently since ``entry.get`` on a str would raise AttributeError). # silently since ``entry.get`` on a str would raise AttributeError).
checkpoint = recipe.get("checkpoint") checkpoint = recipe.get("checkpoint")
if isinstance(checkpoint, dict): if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint): if is_actionable_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level( item, level = await self._match_rematch_entry_with_level(
checkpoint, checkpoint,
local_cache, local_cache,
@@ -1065,8 +1116,36 @@ class RecipeScanner:
self._update_fts_index_for_recipe(recipe, "update") self._update_fts_index_for_recipe(recipe, "update")
return (rematched, 0, details) 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( 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]: ) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally. """Rematch every recipe's deleted lora/checkpoint entries locally.
@@ -1080,14 +1159,19 @@ class RecipeScanner:
Args: Args:
progress_callback: Optional callback for progress updates 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: Returns:
Dict summary of the rematch run with unified counters Dict summary of the rematch run with unified counters
(matched_recipes/matched_entries/unresolved_recipes/unresolved_ (matched_recipes/matched_entries/unresolved_recipes/unresolved_
entries plus the legacy success/status/rematched/skipped/errors/ entries plus the legacy success/status/rematched/skipped/errors/
total fields). ``rematched`` (legacy) counts updated recipes total fields) and ``l4_matches``. ``rematched`` (legacy) counts
use ``matched_entries`` for the entry-level total. updated recipes use ``matched_entries`` for the entry-level
total.
""" """
start_time = time.perf_counter() start_time = time.perf_counter()
@@ -1109,6 +1193,7 @@ class RecipeScanner:
unresolved_entries = 0 unresolved_entries = 0
skipped_count = 0 skipped_count = 0
errors_count = 0 errors_count = 0
l4_matches: List[Dict[str, Any]] = []
for i, recipe in enumerate(all_recipes): for i, recipe in enumerate(all_recipes):
if self.is_cancelled(): if self.is_cancelled():
@@ -1137,6 +1222,7 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
} }
) )
return { return {
@@ -1150,6 +1236,7 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
} }
try: try:
@@ -1165,11 +1252,15 @@ class RecipeScanner:
) )
rematched, _errors, details = await self._rematch_single_recipe( 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: if rematched > 0:
matched_recipes += 1 matched_recipes += 1
matched_entries += rematched matched_entries += rematched
l4_matches.extend(
self._collect_l4_matches(recipe.get("id"), details)
)
else: else:
skipped_count += 1 skipped_count += 1
@@ -1215,6 +1306,7 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
} }
) )
@@ -1228,9 +1320,12 @@ class RecipeScanner:
"matched_entries": matched_entries, "matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes, "unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries, "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. """Rematch a set of recipes by their IDs.
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
@@ -1241,14 +1336,18 @@ class RecipeScanner:
Args: Args:
recipe_ids: List of recipe ids to rematch. recipe_ids: List of recipe ids to rematch.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns: Returns:
Dict summary of the bulk run with unified counters Dict summary of the bulk run with unified counters
(matched_recipes, matched_entries, unresolved_recipes, (matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy total/rematched/skipped/errors unresolved_entries plus the legacy total/rematched/skipped/errors
fields) and a per-recipe ``details`` list. The legacy ``rematched`` fields), a per-recipe ``details`` list, and ``l4_matches`` a
field is the total entry count (same as ``matched_entries``) flattened list of filename-level matches for review/undo. The
unlike ``rematch_all_recipes`` where it counts updated recipes. 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) total = len(recipe_ids)
matched_recipes = 0 matched_recipes = 0
@@ -1259,10 +1358,13 @@ class RecipeScanner:
errors = 0 errors = 0
recipes: List[Dict[str, Any]] = [] recipes: List[Dict[str, Any]] = []
details_list: List[Dict[str, Any]] = [] details_list: List[Dict[str, Any]] = []
l4_matches: List[Dict[str, Any]] = []
for recipe_id in recipe_ids: for recipe_id in recipe_ids:
try: 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"): if result.get("success"):
matched_recipes += result.get("matched_recipes", 0) matched_recipes += result.get("matched_recipes", 0)
matched_entries += result.get("matched_entries", 0) matched_entries += result.get("matched_entries", 0)
@@ -1275,6 +1377,9 @@ class RecipeScanner:
details_list.append( details_list.append(
{"recipe_id": recipe_id, **result["details"]} {"recipe_id": recipe_id, **result["details"]}
) )
l4_matches.extend(
self._collect_l4_matches(recipe_id, result["details"])
)
else: else:
errors += result.get("errors", 0) errors += result.get("errors", 0)
except RecipeNotFoundError: except RecipeNotFoundError:
@@ -1309,12 +1414,22 @@ class RecipeScanner:
"unresolved_entries": unresolved_entries, "unresolved_entries": unresolved_entries,
"recipes": recipes, "recipes": recipes,
"details": details_list, "details": details_list,
"l4_matches": l4_matches,
} }
def _write_rematch_lora_entry( def _write_rematch_lora_entry(
self, entry: Dict[str, Any], item: Dict[str, Any] self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None: ) -> None:
"""Write back a matched local model to a lora recipe entry.""" """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["isDeleted"] = False
entry["hashInvalid"] = False entry["hashInvalid"] = False
@@ -1338,6 +1453,8 @@ class RecipeScanner:
if civitai.get("name"): if civitai.get("name"):
entry["modelVersionName"] = civitai["name"] entry["modelVersionName"] = civitai["name"]
entry["reconnectSnapshot"] = snapshot
def _write_rematch_checkpoint_entry( def _write_rematch_checkpoint_entry(
self, entry: Dict[str, Any], item: Dict[str, Any] self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None: ) -> None:
@@ -1349,6 +1466,15 @@ class RecipeScanner:
when they already exist on the entry (or written fresh for the when they already exist on the entry (or written fresh for the
identifier key when neither identifier form exists). 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["isDeleted"] = False
entry["hashInvalid"] = False entry["hashInvalid"] = False
@@ -1389,6 +1515,8 @@ class RecipeScanner:
else: else:
entry["modelVersionId"] = civ_id entry["modelVersionId"] = civ_id
entry["reconnectSnapshot"] = snapshot
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool: async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
"""Helper to save a recipe to both JSON and EXIF metadata.""" """Helper to save a recipe to both JSON and EXIF metadata."""
recipe_id = recipe.get("id") recipe_id = recipe.get("id")
+180
View File
@@ -592,3 +592,183 @@ button:disabled,
margin-top: 2px; margin-top: 2px;
flex-shrink: 0; 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) { if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided'); 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'); 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, { const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify(body),
recipe_ids: recipeIds,
}),
}); });
const result = await response.json(); const result = await response.json();
@@ -4,6 +4,7 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js'; import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js'; import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
export class GlobalContextMenu extends BaseContextMenu { export class GlobalContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -368,6 +369,18 @@ export class GlobalContextMenu extends BaseContextMenu {
return; 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; this._rematchInProgress = true;
menuItem?.classList.add('disabled'); menuItem?.classList.add('disabled');
@@ -384,6 +397,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const response = await fetch('/api/lm/recipes/rematch', { const response = await fetch('/api/lm/recipes/rematch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
}); });
const result = await response.json(); const result = await response.json();
@@ -458,6 +472,11 @@ export class GlobalContextMenu extends BaseContextMenu {
if (window.recipesPage) { if (window.recipesPage) {
window.recipesPage.refresh(); 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') { } else if (p.status === 'error') {
throw new Error(p.error || 'Rematch failed'); throw new Error(p.error || 'Rematch failed');
} else if (p.status === 'cancelled') { } else if (p.status === 'cancelled') {
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js'; import { moveManager } from '../../managers/MoveManager.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js'; import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu { 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 // Capture before any await: the menu's click handler nulls currentCard
const filePath = this.currentCard?.dataset?.filepath; 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 { try {
showToast('Rematching recipe to local models...', {}, 'info'); showToast('Rematching recipe to local models...', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, { 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(); const result = await response.json();
@@ -330,6 +342,11 @@ export class RecipeContextMenu extends BaseContextMenu {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe); 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) { } else if (result.unresolved_entries > 0) {
// Entries existed but have no local model — expected for // Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error. // models deleted from Civitai; informational, not an error.
+20 -21
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component // 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 { isModelWeightFile } from '../utils/modelFileTypes.js';
import { buildCivitaiUrl } from '../utils/civitaiUtils.js'; import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
@@ -1078,8 +1078,9 @@ class RecipeModal {
// Mirror the checkpoint "broken" rule: deleted, an // Mirror the checkpoint "broken" rule: deleted, an
// unresolvable hash, or a name-only remnant with no CivitAI // unresolvable hash, or a name-only remnant with no CivitAI
// identifiers at all cannot be fixed by downloading // identifiers at all cannot be fixed by downloading, so no
// reconnecting a local LoRA is the only remediation. // download button is offered. Reconnect is always available
// for missing entries (see renderLoraItemActions).
const needsReconnect = !existsLocally const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora)); && (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
@@ -1180,7 +1181,7 @@ class RecipeModal {
</div> </div>
${actionsRow} ${actionsRow}
</div> </div>
${needsReconnect ? ` ${!existsLocally ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}"> <div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions"> <div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p> <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. * the model cannot be resolved never for transient transport errors.
*/ */
_isUnresolvableDownloadError(message) { _isUnresolvableDownloadError(message) {
if (!message) { return isUnresolvableDownloadError(message);
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
} }
getResourceCivitaiUrl(resource) { getResourceCivitaiUrl(resource) {
@@ -2915,19 +2912,9 @@ class RecipeModal {
} }
const controls = []; const controls = [];
if (needsReconnect) { 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 {
// needsReconnect already implies canDownloadLora() here, so the // 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 downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(` controls.push(`
@@ -2938,6 +2925,18 @@ class RecipeModal {
</button> </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>`; 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 { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js'; import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js'; import { bulkManager } from './managers/BulkManager.js';
import { rematchModalManager } from './managers/RematchModalManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js'; import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js'; import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js'; import { doctorManager } from './managers/DoctorManager.js';
@@ -68,6 +69,7 @@ export class AppCore {
window.doctorManager = doctorManager; window.doctorManager = doctorManager;
window.moveManager = moveManager; window.moveManager = moveManager;
window.bulkManager = bulkManager; window.bulkManager = bulkManager;
window.rematchModalManager = rematchModalManager;
// Initialize UI components // Initialize UI components
window.headerManager = new HeaderManager(); 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 { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { rematchModalManager } from './RematchModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js'; import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
@@ -978,6 +979,15 @@ export class BulkManager {
return; 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 { try {
const apiClient = this.getActiveApiClient(); const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels); const filePaths = Array.from(state.selectedModels);
@@ -989,7 +999,7 @@ export class BulkManager {
state.loadingManager.showSimpleLoading('Rematching recipes to local models...'); 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) { if (result.success) {
const total = result.total || filePaths.length; const total = result.total || filePaths.length;
@@ -1050,6 +1060,12 @@ export class BulkManager {
} }
if (state.bulkMode) this.toggleBulkMode(); 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 { } else {
throw new Error(result.error || 'Bulk rematch failed'); throw new Error(result.error || 'Bulk rematch failed');
} }
@@ -1,7 +1,9 @@
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { extractRecipeId } from '../api/recipeApi.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA); this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = []; this.pendingLoras = [];
this.pendingRecipes = []; this.pendingRecipes = [];
this.pendingMissingByRecipe = null;
} }
/** /**
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
// Execute download // Execute download
await this.executeDownload(this.pendingLoras); await this.executeDownload(this.pendingLoras);
this.pendingLoras = []; this.pendingLoras = [];
this.pendingMissingByRecipe = null;
} }
/** /**
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
// Collect missing LoRAs with deduplication // Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes); 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) { if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info'); showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0; let completedDownloads = 0;
let failedDownloads = 0; let failedDownloads = 0;
let markedInvalidCount = 0;
let currentLoraProgress = 0; let currentLoraProgress = 0;
let cancelled = false; let cancelled = false;
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
if (!response.success) { if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`); console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++; 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 { } else {
completedDownloads++; completedDownloads++;
updateProgress(100, completedDownloads, ''); updateProgress(100, completedDownloads, '');
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
if (!cancelled) { if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error); console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++; failedDownloads++;
if (isUnresolvableDownloadError(error?.message)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
} }
} }
} }
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
}, 'warning'); }, '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) // Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
if (state.virtualScroller) { if (state.virtualScroller) {
const { extractRecipeId } = await import('../api/recipeApi.js');
for (const recipe of this.pendingRecipes) { for (const recipe of this.pendingRecipes) {
const recipeId = extractRecipeId(recipe.file_path); const recipeId = extractRecipeId(recipe.file_path);
if (!recipeId) continue; 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 * Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null * @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); document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true; 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'; 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() { export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder'); const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`); const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
@@ -126,3 +126,53 @@
</div> </div>
</div> </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(); 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 () => { it('throws the backend error when bulk rematch fails', async () => {
const api = new RecipeSidebarApiClient(); const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({ global.fetch.mockResolvedValue({
@@ -143,6 +143,16 @@ async function flushAsyncTasks() {
await new Promise((resolve) => setTimeout(resolve, 0)); 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() { function createDeferred() {
let resolve; let resolve;
let reject; let reject;
@@ -2266,15 +2276,23 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); 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); expect(rematchItem.classList.contains('disabled')).toBe(true);
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', { expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
}); });
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress'); expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
expect(global.fetch).toHaveBeenCalledTimes(2); expect(global.fetch).toHaveBeenCalledTimes(2);
@@ -2331,10 +2349,15 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); 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++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.'); expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
@@ -2384,10 +2407,15 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); 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++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.'); expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
@@ -2437,10 +2465,15 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); 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++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.'); expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
@@ -2489,10 +2522,15 @@ describe('Interaction-level regression coverage', () => {
}); });
rematchItem.dispatchEvent(new Event('click', { bubbles: true })); 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++) { for (let i = 0; i < 5; i++) {
await flushAsyncTasks(); await flushAsyncTasks();
} }
await runPromise;
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).'); expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
expect(showToastMock).toHaveBeenCalledWith( 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', () => { describe('RecipeContextMenu.rematchRecipe', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -91,8 +114,16 @@ describe('RecipeContextMenu.rematchRecipe', () => {
await flushAsyncTasks(); 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', { expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: false }),
}); });
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete', 'toast.recipes.rematchComplete',
@@ -126,6 +157,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchUnmatched', 'toast.recipes.rematchUnmatched',
@@ -155,6 +188,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
@@ -186,6 +221,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -207,6 +244,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
.dispatchEvent(new Event('click', { bubbles: true })); .dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks(); await flushAsyncTasks();
await confirmRematchOptions();
await flushAsyncTasks();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -214,4 +253,88 @@ describe('RecipeContextMenu.rematchRecipe', () => {
'error' '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), stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(), sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: 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', () => ({ 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(); const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources); recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring(); await flushWiring();
@@ -301,10 +305,12 @@ describe('RecipeModal resource item interactions', () => {
expect(item).not.toBeNull(); expect(item).not.toBeNull();
expect(item.classList.contains('missing-locally')).toBe(true); expect(item.classList.contains('missing-locally')).toBe(true);
// Missing from the local library (badge) but still downloadable by its // 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('.missing-badge')).not.toBeNull();
expect(item.querySelector('.lora-download')).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 () => { 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)); await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false); 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"]'); const item = document.querySelector('[data-lora-index="1"]');
expect(item.querySelector('.lora-download')).not.toBeNull(); 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 () => { 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(), 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', () => { describe('BulkManager.rematchSelectedRecipes', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -114,12 +130,16 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(rematchBulkModelsMock).toHaveBeenCalledWith([ expect(rematchBulkModelsMock).toHaveBeenCalledWith(
[
'/recipes/a.webp', '/recipes/a.webp',
'/recipes/b.webp', '/recipes/b.webp',
'/recipes/c.webp', '/recipes/c.webp',
]); ],
{ relaxed: false }
);
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchComplete', 'toast.recipes.rematchComplete',
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 }, { rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
@@ -155,6 +175,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchCompleteErrors', 'toast.recipes.rematchCompleteErrors',
@@ -182,6 +203,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchAllFailed', 'toast.recipes.rematchAllFailed',
@@ -215,6 +237,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchUnmatched', 'toast.recipes.rematchUnmatched',
@@ -243,6 +266,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchSkipped', 'toast.recipes.rematchSkipped',
@@ -268,6 +292,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
}); });
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -284,6 +309,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
rematchBulkModelsMock.mockRejectedValue(new Error('network down')); rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
await bulk.rematchSelectedRecipes(); await bulk.rematchSelectedRecipes();
await confirmRematchOptions();
expect(showToastMock).toHaveBeenCalledWith( expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.rematchFailed', 'toast.recipes.rematchFailed',
@@ -319,4 +345,100 @@ describe('BulkManager.rematchSelectedRecipes', () => {
); );
expect(rematchBulkModelsMock).not.toHaveBeenCalled(); 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_all_calls: List[Any] = []
self.rematch_by_id_calls: List[str] = [] self.rematch_by_id_calls: List[str] = []
self.rematch_bulk_calls: List[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]] = {} 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 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: def reset_cancellation(self) -> None:
self.reset_calls += 1 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.""" """Run a canned rematch-all run, mirroring the real progress events."""
if progress_callback: if progress_callback:
await progress_callback({"status": "started"}) await progress_callback({"status": "started"})
@@ -142,6 +145,7 @@ class StubRecipeScanner:
{"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1} {"status": "completed", "rematched": 1, "skipped": 0, "errors": 0, "total": 1}
) )
self.rematch_all_calls.append(progress_callback) self.rematch_all_calls.append(progress_callback)
self.rematch_all_relaxed.append(relaxed)
return { return {
"success": True, "success": True,
"status": "completed", "status": "completed",
@@ -151,14 +155,20 @@ class StubRecipeScanner:
"total": 1, "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_calls.append(recipe_id)
self.rematch_by_id_relaxed.append(relaxed)
if recipe_id not in self.rematch_results: if recipe_id not in self.rematch_results:
raise RecipeNotFoundError(f"Recipe not found: {recipe_id}") raise RecipeNotFoundError(f"Recipe not found: {recipe_id}")
return self.rematch_results[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_calls.append(list(recipe_ids))
self.rematch_bulk_relaxed.append(relaxed)
total = len(recipe_ids) total = len(recipe_ids)
rematched = 0 rematched = 0
skipped = 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"] 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( async def test_get_rematch_progress_404_when_no_progress(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> 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", "entry": "old.safetensors",
"file_name": "m.safetensors", "file_name": "m.safetensors",
"match_level": "L1", "match_level": "L1",
"lora_index": 0,
} }
] ]
assert result["recipe"] is enriched 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 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 # 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], local_cache: dict[str, Any],
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
filename_cache=None, filename_cache=None,
**_kwargs: Any,
) -> tuple[int, int, dict[str, Any]]: ) -> tuple[int, int, dict[str, Any]]:
if recipe.get("id") == "boom": if recipe.get("id") == "boom":
raise RuntimeError("kaboom") 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], local_cache: dict[str, Any],
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
filename_cache=None, filename_cache=None,
**kwargs: Any,
) -> tuple[int, int, dict[str, Any]]: ) -> tuple[int, int, dict[str, Any]]:
nonlocal entered nonlocal entered
if recipe.get("id") == "r0": if recipe.get("id") == "r0":
entered = True entered = True
await release.wait() 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) 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 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): async def test_find_all_duplicate_recipes_groups_by_fingerprint(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner