mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
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:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 חסרים נכשלה",
|
||||||
|
|||||||
@@ -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の情報取得に失敗しました",
|
||||||
|
|||||||
@@ -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 정보를 가져오는데 실패했습니다",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 信息失败",
|
||||||
|
|||||||
@@ -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 資訊失敗",
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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()">×</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')">×</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>
|
||||||
@@ -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/b.webp',
|
'/recipes/a.webp',
|
||||||
'/recipes/c.webp',
|
'/recipes/b.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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user