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

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

- Suggest local reconnect candidates when the panel opens, ranked by
  identity (same hash / same CivitAI version) then filename/name
  similarity, with a hard filter on confident base-model mismatches;
  the input gets a Combobox backed by the same endpoint as you type.
- Snapshot the pre-reconnect entry and offer a permanent restore:
  reconnected entries show an undo icon at the right end of the info
  row, with the original filename in the tooltip.
- Relax the manual reconnect base-model guard to a three-tier check:
  exact/unknown labels pass silently, same-architecture families
  (e.g. Pony <-> Illustrious) pass with a warning toast, and only
  cross-architecture mismatches stay hard-rejected.
This commit is contained in:
Will Miao
2026-08-30 08:17:35 +08:00
parent 6e31da7a70
commit 838a374a56
23 changed files with 1893 additions and 45 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ The dedicated services encapsulate long-running work so handlers stay thin.
| Use case | Entry point | Dependencies | Guarantees | | Use case | Entry point | Dependencies | Guarantees |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `RecipeAnalysisService` | `analyze_uploaded_image`, `analyze_remote_image`, `analyze_local_image`, `analyze_widget_metadata` | `ExifUtils`, `RecipeParserFactory`, downloader factory, optional metadata collector/processor | Normalises missing/invalid payloads into `RecipeValidationError`; generates consistent fingerprint data to keep duplicate detection stable; temporary files are cleaned up after every analysis path. | | `RecipeAnalysisService` | `analyze_uploaded_image`, `analyze_remote_image`, `analyze_local_image`, `analyze_widget_metadata` | `ExifUtils`, `RecipeParserFactory`, downloader factory, optional metadata collector/processor | Normalises missing/invalid payloads into `RecipeValidationError`; generates consistent fingerprint data to keep duplicate detection stable; temporary files are cleaned up after every analysis path. |
| `RecipePersistenceService` | `save_recipe`, `delete_recipe`, `update_recipe`, `reconnect_lora`, `bulk_delete`, `save_recipe_from_widget` | `ExifUtils`, recipe scanner, card preview sizing constants | Writes images/JSON metadata atomically; updates scanner caches and hash indices before returning; recalculates fingerprints whenever LoRA assignments change. | | `RecipePersistenceService` | `save_recipe`, `delete_recipe`, `update_recipe`, `reconnect_lora`, `get_reconnect_suggestions`, `bulk_delete`, `save_recipe_from_widget` | `ExifUtils`, recipe scanner, card preview sizing constants | Writes images/JSON metadata atomically; updates scanner caches and hash indices before returning; recalculates fingerprints whenever LoRA assignments change. |
| `RecipeSharingService` | `share_recipe`, `prepare_download` | `tempfile`, recipe scanner | Copies originals to TTL-managed temp files; metadata lookups re-use the scanner; expired shares trigger cleanup and `RecipeNotFoundError`. | | `RecipeSharingService` | `share_recipe`, `prepare_download` | `tempfile`, recipe scanner | Copies originals to TTL-managed temp files; metadata lookups re-use the scanner; expired shares trigger cleanup and `RecipeNotFoundError`. |
## Maintaining critical invariants ## Maintaining critical invariants
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "Geben Sie die LoRA-Syntax oder den Namen zum Neuverknüpfen ein:", "reconnectInstructions": "Geben Sie die LoRA-Syntax oder den Namen zum Neuverknüpfen ein:",
"reconnectExample": "Beispiel: <lora:name:1> oder nur der Name", "reconnectExample": "Beispiel: <lora:name:1> oder nur der Name",
"reconnectPlaceholder": "LoRA-Namen oder -Syntax eingeben", "reconnectPlaceholder": "LoRA-Namen oder -Syntax eingeben",
"reconnectSuggestionsLoading": "Lokale Bibliothek wird durchsucht...",
"reconnectSuggestionsEmpty": "Keine passenden LoRAs in Ihrer lokalen Bibliothek",
"reconnectMatchSameHash": "Gleicher Hash",
"reconnectMatchSameVersion": "Gleiche Modellversion",
"reconnectMatchSimilarFilename": "Ähnlicher Dateiname",
"reconnectMatchSimilarName": "Ähnlicher Name",
"undoReconnect": "Rückgängig",
"undoReconnectTooltip": "Stellt die Verknüpfung wieder her, die dieser Eintrag vor dem Neuverknüpfen hatte",
"undoReconnectTooltipNamed": "Stellt {name} wieder her (die Verknüpfung vor dem Neuverknüpfen)",
"viewOnCivitai": "Auf CivitAI anzeigen", "viewOnCivitai": "Auf CivitAI anzeigen",
"openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen", "openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen",
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen" "openCheckpointDetails": "{name} in der Modellbibliothek anzeigen"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download", "preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein", "enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
"reconnectedSuccessfully": "LoRA erfolgreich neu verbunden", "reconnectedSuccessfully": "LoRA erfolgreich neu verbunden",
"reconnectBaseModelMismatch": "Neuverbindung erfolgreich, aber die Basismodelle unterscheiden sich (Rezept: {recipe}, LoRA: {lora}) — sie sind architekturkompatibel",
"reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}", "reconnectFailed": "Fehler beim Neuverbinden des LoRA: {message}",
"loraRestored": "LoRA auf die vorherige Verknüpfung zurückgesetzt",
"loraRestoreFailed": "Fehler beim Wiederherstellen des LoRA: {message}",
"noPromptToSend": "Kein zu sendender Prompt", "noPromptToSend": "Kein zu sendender Prompt",
"cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID", "cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID",
"sendFailed": "Fehler beim Senden des Rezepts an Workflow", "sendFailed": "Fehler beim Senden des Rezepts an Workflow",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "Enter LoRA syntax or name to reconnect:", "reconnectInstructions": "Enter LoRA syntax or name to reconnect:",
"reconnectExample": "Example: <lora:name:1> or just the name", "reconnectExample": "Example: <lora:name:1> or just the name",
"reconnectPlaceholder": "Enter LoRA name or syntax", "reconnectPlaceholder": "Enter LoRA name or syntax",
"reconnectSuggestionsLoading": "Searching local library...",
"reconnectSuggestionsEmpty": "No matching LoRAs in your local library",
"reconnectMatchSameHash": "Same hash",
"reconnectMatchSameVersion": "Same model version",
"reconnectMatchSimilarFilename": "Similar filename",
"reconnectMatchSimilarName": "Similar name",
"undoReconnect": "Undo",
"undoReconnectTooltip": "Restore the association this entry had before reconnecting",
"undoReconnectTooltipNamed": "Restore to {name} (the association before reconnecting)",
"viewOnCivitai": "View on CivitAI", "viewOnCivitai": "View on CivitAI",
"openLoraDetails": "View {name} in the LoRA library", "openLoraDetails": "View {name} in the LoRA library",
"openCheckpointDetails": "View {name} in the model library" "openCheckpointDetails": "View {name} in the model library"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "Error preparing LoRAs for download", "preparingForDownloadFailed": "Error preparing LoRAs for download",
"enterLoraName": "Please enter a LoRA name or syntax", "enterLoraName": "Please enter a LoRA name or syntax",
"reconnectedSuccessfully": "LoRA reconnected successfully", "reconnectedSuccessfully": "LoRA reconnected successfully",
"reconnectBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, LoRA: {lora}) — they are architecture-compatible",
"reconnectFailed": "Error reconnecting LoRA: {message}", "reconnectFailed": "Error reconnecting LoRA: {message}",
"loraRestored": "LoRA restored to its previous association",
"loraRestoreFailed": "Error restoring LoRA: {message}",
"noPromptToSend": "No prompt to send", "noPromptToSend": "No prompt to send",
"cannotSend": "Cannot send recipe: Missing recipe ID", "cannotSend": "Cannot send recipe: Missing recipe ID",
"sendFailed": "Failed to send recipe to workflow", "sendFailed": "Failed to send recipe to workflow",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "Introduce la sintaxis o el nombre del LoRA para reconectar:", "reconnectInstructions": "Introduce la sintaxis o el nombre del LoRA para reconectar:",
"reconnectExample": "Ejemplo: <lora:name:1> o solo el nombre", "reconnectExample": "Ejemplo: <lora:name:1> o solo el nombre",
"reconnectPlaceholder": "Introduce el nombre o la sintaxis del LoRA", "reconnectPlaceholder": "Introduce el nombre o la sintaxis del LoRA",
"reconnectSuggestionsLoading": "Buscando en la biblioteca local...",
"reconnectSuggestionsEmpty": "No hay LoRAs coincidentes en tu biblioteca local",
"reconnectMatchSameHash": "Mismo hash",
"reconnectMatchSameVersion": "Misma versión del modelo",
"reconnectMatchSimilarFilename": "Nombre de archivo similar",
"reconnectMatchSimilarName": "Nombre similar",
"undoReconnect": "Deshacer",
"undoReconnectTooltip": "Restaura la asociación que esta entrada tenía antes de reconectar",
"undoReconnectTooltipNamed": "Restaurar a {name} (la asociación antes de reconectar)",
"viewOnCivitai": "Ver en CivitAI", "viewOnCivitai": "Ver en CivitAI",
"openLoraDetails": "Ver {name} en la biblioteca de LoRAs", "openLoraDetails": "Ver {name} en la biblioteca de LoRAs",
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos" "openCheckpointDetails": "Ver {name} en la biblioteca de modelos"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "Error preparando LoRAs para descarga", "preparingForDownloadFailed": "Error preparando LoRAs para descarga",
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis", "enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
"reconnectedSuccessfully": "LoRA reconectado exitosamente", "reconnectedSuccessfully": "LoRA reconectado exitosamente",
"reconnectBaseModelMismatch": "Reconectado, pero los modelos base difieren (receta: {recipe}, LoRA: {lora}) — son compatibles a nivel de arquitectura",
"reconnectFailed": "Error reconectando LoRA: {message}", "reconnectFailed": "Error reconectando LoRA: {message}",
"loraRestored": "LoRA restaurado a su asociación anterior",
"loraRestoreFailed": "Error restaurando LoRA: {message}",
"noPromptToSend": "No hay prompt para enviar", "noPromptToSend": "No hay prompt para enviar",
"cannotSend": "No se puede enviar receta: Falta ID de receta", "cannotSend": "No se puede enviar receta: Falta ID de receta",
"sendFailed": "Error al enviar receta al workflow", "sendFailed": "Error al enviar receta al workflow",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "Entrez la syntaxe ou le nom du LoRA à reconnecter:", "reconnectInstructions": "Entrez la syntaxe ou le nom du LoRA à reconnecter:",
"reconnectExample": "Exemple: <lora:name:1> ou simplement le nom", "reconnectExample": "Exemple: <lora:name:1> ou simplement le nom",
"reconnectPlaceholder": "Entrez le nom ou la syntaxe du LoRA", "reconnectPlaceholder": "Entrez le nom ou la syntaxe du LoRA",
"reconnectSuggestionsLoading": "Recherche dans la bibliothèque locale...",
"reconnectSuggestionsEmpty": "Aucun LoRA correspondant dans votre bibliothèque locale",
"reconnectMatchSameHash": "Hash identique",
"reconnectMatchSameVersion": "Même version du modèle",
"reconnectMatchSimilarFilename": "Nom de fichier similaire",
"reconnectMatchSimilarName": "Nom similaire",
"undoReconnect": "Annuler",
"undoReconnectTooltip": "Restaurer l'association que cette entrée avait avant la reconnexion",
"undoReconnectTooltipNamed": "Restaurer vers {name} (l'association avant la reconnexion)",
"viewOnCivitai": "Voir sur CivitAI", "viewOnCivitai": "Voir sur CivitAI",
"openLoraDetails": "Voir {name} dans la bibliothèque LoRA", "openLoraDetails": "Voir {name} dans la bibliothèque LoRA",
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles" "openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement", "preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA", "enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
"reconnectedSuccessfully": "LoRA reconnecté avec succès", "reconnectedSuccessfully": "LoRA reconnecté avec succès",
"reconnectBaseModelMismatch": "Reconnexion effectuée, mais les modèles de base diffèrent (Recipe : {recipe}, LoRA : {lora}) — ils sont compatibles au niveau architectural",
"reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}", "reconnectFailed": "Erreur lors de la reconnexion du LoRA : {message}",
"loraRestored": "LoRA restauré à son association précédente",
"loraRestoreFailed": "Erreur lors de la restauration du LoRA : {message}",
"noPromptToSend": "Aucun prompt à envoyer", "noPromptToSend": "Aucun prompt à envoyer",
"cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant", "cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant",
"sendFailed": "Échec de l'envoi de la recipe vers le workflow", "sendFailed": "Échec de l'envoi de la recipe vers le workflow",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "הזן תחביר או שם של LoRA לחיבור מחדש:", "reconnectInstructions": "הזן תחביר או שם של LoRA לחיבור מחדש:",
"reconnectExample": "דוגמה: <lora:name:1> או רק את השם", "reconnectExample": "דוגמה: <lora:name:1> או רק את השם",
"reconnectPlaceholder": "הזן שם או תחביר של LoRA", "reconnectPlaceholder": "הזן שם או תחביר של LoRA",
"reconnectSuggestionsLoading": "מחפש בספרייה המקומית...",
"reconnectSuggestionsEmpty": "לא נמצאו LoRAs תואמים בספרייה המקומית שלך",
"reconnectMatchSameHash": "אותו hash",
"reconnectMatchSameVersion": "אותה גרסת מודל",
"reconnectMatchSimilarFilename": "שם קובץ דומה",
"reconnectMatchSimilarName": "שם דומה",
"undoReconnect": "בטל",
"undoReconnectTooltip": "שחזר את השיוך שהיה לרשומה זו לפני החיבור מחדש",
"undoReconnectTooltipNamed": "שחזר ל-{name} (השיוך לפני החיבור מחדש)",
"viewOnCivitai": "הצג ב-CivitAI", "viewOnCivitai": "הצג ב-CivitAI",
"openLoraDetails": "הצג את {name} בספריית ה-LoRA", "openLoraDetails": "הצג את {name} בספריית ה-LoRA",
"openCheckpointDetails": "הצג את {name} בספריית המודלים" "openCheckpointDetails": "הצג את {name} בספריית המודלים"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה", "preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
"enterLoraName": "אנא הזן שם LoRA או תחביר", "enterLoraName": "אנא הזן שם LoRA או תחביר",
"reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה", "reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה",
"reconnectBaseModelMismatch": "הקישור מחדש הצליח, אך מודלי הבסיס שונים (מתכון: {recipe}, LoRA: {lora}) — הם תואמים מבחינת הארכיטקטורה",
"reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}", "reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}",
"loraRestored": "LoRA שוחזר לשיוך הקודם",
"loraRestoreFailed": "שגיאה בשחזור LoRA: {message}",
"noPromptToSend": "אין פרומפט לשליחה", "noPromptToSend": "אין פרומפט לשליחה",
"cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון", "cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון",
"sendFailed": "שליחת המתכון ל-workflow נכשלה", "sendFailed": "שליחת המתכון ל-workflow נכשלה",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "再接続する LoRA の構文または名前を入力してください:", "reconnectInstructions": "再接続する LoRA の構文または名前を入力してください:",
"reconnectExample": "例:<lora:name:1> または名前のみ", "reconnectExample": "例:<lora:name:1> または名前のみ",
"reconnectPlaceholder": "LoRA 名または構文を入力", "reconnectPlaceholder": "LoRA 名または構文を入力",
"reconnectSuggestionsLoading": "ローカルライブラリを検索中...",
"reconnectSuggestionsEmpty": "ローカルライブラリに一致するLoRAがありません",
"reconnectMatchSameHash": "同じハッシュ",
"reconnectMatchSameVersion": "同じモデルバージョン",
"reconnectMatchSimilarFilename": "類似のファイル名",
"reconnectMatchSimilarName": "類似の名前",
"undoReconnect": "元に戻す",
"undoReconnectTooltip": "このエントリーを再接続前の関連付けに戻します",
"undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)",
"viewOnCivitai": "CivitAI で表示", "viewOnCivitai": "CivitAI で表示",
"openLoraDetails": "LoRA ライブラリで {name} を表示", "openLoraDetails": "LoRA ライブラリで {name} を表示",
"openCheckpointDetails": "モデルライブラリで {name} を表示" "openCheckpointDetails": "モデルライブラリで {name} を表示"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました", "preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
"enterLoraName": "LoRA名または構文を入力してください", "enterLoraName": "LoRA名または構文を入力してください",
"reconnectedSuccessfully": "LoRAが正常に再接続されました", "reconnectedSuccessfully": "LoRAが正常に再接続されました",
"reconnectBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、LoRA:{lora})— アーキテクチャ互換です",
"reconnectFailed": "LoRA再接続エラー:{message}", "reconnectFailed": "LoRA再接続エラー:{message}",
"loraRestored": "LoRAが以前の関連付けに復元されました",
"loraRestoreFailed": "LoRA復元エラー:{message}",
"noPromptToSend": "送信するプロンプトがありません", "noPromptToSend": "送信するプロンプトがありません",
"cannotSend": "レシピを送信できません:レシピIDがありません", "cannotSend": "レシピを送信できません:レシピIDがありません",
"sendFailed": "レシピのワークフローへの送信に失敗しました", "sendFailed": "レシピのワークフローへの送信に失敗しました",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "다시 연결할 LoRA 구문 또는 이름을 입력하세요:", "reconnectInstructions": "다시 연결할 LoRA 구문 또는 이름을 입력하세요:",
"reconnectExample": "예:<lora:name:1> 또는 이름만 입력", "reconnectExample": "예:<lora:name:1> 또는 이름만 입력",
"reconnectPlaceholder": "LoRA 이름 또는 구문 입력", "reconnectPlaceholder": "LoRA 이름 또는 구문 입력",
"reconnectSuggestionsLoading": "로컬 라이브러리 검색 중...",
"reconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 LoRA가 없습니다",
"reconnectMatchSameHash": "동일한 해시",
"reconnectMatchSameVersion": "동일한 모델 버전",
"reconnectMatchSimilarFilename": "유사한 파일 이름",
"reconnectMatchSimilarName": "유사한 이름",
"undoReconnect": "실행 취소",
"undoReconnectTooltip": "이 항목을 다시 연결 전의 연결 상태로 복원",
"undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}",
"viewOnCivitai": "CivitAI에서 보기", "viewOnCivitai": "CivitAI에서 보기",
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기", "openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기" "openCheckpointDetails": "모델 라이브러리에서 {name} 보기"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류", "preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요", "enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다", "reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
"reconnectBaseModelMismatch": "다시 연결했지만 베이스 모델이 다릅니다(레시피: {recipe}, LoRA: {lora}) — 아키텍처 호환입니다",
"reconnectFailed": "LoRA 다시 연결 오류: {message}", "reconnectFailed": "LoRA 다시 연결 오류: {message}",
"loraRestored": "LoRA가 이전 연결 상태로 복원되었습니다",
"loraRestoreFailed": "LoRA 복원 오류: {message}",
"noPromptToSend": "보낼 프롬프트가 없습니다", "noPromptToSend": "보낼 프롬프트가 없습니다",
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락", "cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다", "sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "Введите синтаксис или имя LoRA для переподключения:", "reconnectInstructions": "Введите синтаксис или имя LoRA для переподключения:",
"reconnectExample": "Пример: <lora:name:1> или просто имя", "reconnectExample": "Пример: <lora:name:1> или просто имя",
"reconnectPlaceholder": "Введите имя или синтаксис LoRA", "reconnectPlaceholder": "Введите имя или синтаксис LoRA",
"reconnectSuggestionsLoading": "Поиск в локальной библиотеке...",
"reconnectSuggestionsEmpty": "В локальной библиотеке нет подходящих LoRA",
"reconnectMatchSameHash": "Тот же хеш",
"reconnectMatchSameVersion": "Та же версия модели",
"reconnectMatchSimilarFilename": "Похожее имя файла",
"reconnectMatchSimilarName": "Похожее имя",
"undoReconnect": "Отменить",
"undoReconnectTooltip": "Восстановить привязку, которая была у записи до переподключения",
"undoReconnectTooltipNamed": "Восстановить {name} (привязка до переподключения)",
"viewOnCivitai": "Открыть на CivitAI", "viewOnCivitai": "Открыть на CivitAI",
"openLoraDetails": "Открыть {name} в библиотеке LoRA", "openLoraDetails": "Открыть {name} в библиотеке LoRA",
"openCheckpointDetails": "Открыть {name} в библиотеке моделей" "openCheckpointDetails": "Открыть {name} в библиотеке моделей"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки", "preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис", "enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
"reconnectedSuccessfully": "LoRA успешно переподключена", "reconnectedSuccessfully": "LoRA успешно переподключена",
"reconnectBaseModelMismatch": "Переподключение выполнено, но базовые модели различаются (рецепт: {recipe}, LoRA: {lora}) — они совместимы по архитектуре",
"reconnectFailed": "Ошибка переподключения LoRA: {message}", "reconnectFailed": "Ошибка переподключения LoRA: {message}",
"loraRestored": "LoRA восстановлена к прежней привязке",
"loraRestoreFailed": "Ошибка восстановления LoRA: {message}",
"noPromptToSend": "Нет промпта для отправки", "noPromptToSend": "Нет промпта для отправки",
"cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта", "cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта",
"sendFailed": "Не удалось отправить рецепт в workflow", "sendFailed": "Не удалось отправить рецепт в workflow",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "输入 LoRA 语法或名称以重新关联:", "reconnectInstructions": "输入 LoRA 语法或名称以重新关联:",
"reconnectExample": "示例:<lora:name:1> 或只填名称", "reconnectExample": "示例:<lora:name:1> 或只填名称",
"reconnectPlaceholder": "输入 LoRA 名称或语法", "reconnectPlaceholder": "输入 LoRA 名称或语法",
"reconnectSuggestionsLoading": "正在搜索本地库...",
"reconnectSuggestionsEmpty": "本地库中没有匹配的 LoRA",
"reconnectMatchSameHash": "相同哈希",
"reconnectMatchSameVersion": "相同模型版本",
"reconnectMatchSimilarFilename": "相似文件名",
"reconnectMatchSimilarName": "相似名称",
"undoReconnect": "撤销",
"undoReconnectTooltip": "恢复此条目在重新关联前的关联",
"undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)",
"viewOnCivitai": "在 CivitAI 上查看", "viewOnCivitai": "在 CivitAI 上查看",
"openLoraDetails": "在 LoRA 库中查看 {name}", "openLoraDetails": "在 LoRA 库中查看 {name}",
"openCheckpointDetails": "在模型库中查看 {name}" "openCheckpointDetails": "在模型库中查看 {name}"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "准备下载 LoRA 时出错", "preparingForDownloadFailed": "准备下载 LoRA 时出错",
"enterLoraName": "请输入 LoRA 名称或语法", "enterLoraName": "请输入 LoRA 名称或语法",
"reconnectedSuccessfully": "LoRA 重新连接成功", "reconnectedSuccessfully": "LoRA 重新连接成功",
"reconnectBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},LoRA:{lora})——两者架构兼容",
"reconnectFailed": "LoRA 重新连接出错:{message}", "reconnectFailed": "LoRA 重新连接出错:{message}",
"loraRestored": "LoRA 已恢复为重新关联前的关联",
"loraRestoreFailed": "LoRA 恢复出错:{message}",
"noPromptToSend": "没有可发送的提示词", "noPromptToSend": "没有可发送的提示词",
"cannotSend": "无法发送配方:缺少配方 ID", "cannotSend": "无法发送配方:缺少配方 ID",
"sendFailed": "发送配方到工作流失败", "sendFailed": "发送配方到工作流失败",
+12
View File
@@ -906,6 +906,15 @@
"reconnectInstructions": "輸入 LoRA 語法或名稱以重新關聯:", "reconnectInstructions": "輸入 LoRA 語法或名稱以重新關聯:",
"reconnectExample": "範例:<lora:name:1> 或只填名稱", "reconnectExample": "範例:<lora:name:1> 或只填名稱",
"reconnectPlaceholder": "輸入 LoRA 名稱或語法", "reconnectPlaceholder": "輸入 LoRA 名稱或語法",
"reconnectSuggestionsLoading": "正在搜尋本地庫...",
"reconnectSuggestionsEmpty": "本地庫中沒有符合的 LoRA",
"reconnectMatchSameHash": "相同雜湊",
"reconnectMatchSameVersion": "相同模型版本",
"reconnectMatchSimilarFilename": "相似檔案名稱",
"reconnectMatchSimilarName": "相似名稱",
"undoReconnect": "撤銷",
"undoReconnectTooltip": "恢復此條目在重新關聯前的關聯",
"undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)",
"viewOnCivitai": "在 CivitAI 上檢視", "viewOnCivitai": "在 CivitAI 上檢視",
"openLoraDetails": "在 LoRA 庫中檢視 {name}", "openLoraDetails": "在 LoRA 庫中檢視 {name}",
"openCheckpointDetails": "在模型庫中檢視 {name}" "openCheckpointDetails": "在模型庫中檢視 {name}"
@@ -2046,7 +2055,10 @@
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤", "preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
"enterLoraName": "請輸入 LoRA 名稱或語法", "enterLoraName": "請輸入 LoRA 名稱或語法",
"reconnectedSuccessfully": "LoRA 重新連結成功", "reconnectedSuccessfully": "LoRA 重新連結成功",
"reconnectBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},LoRA:{lora})——兩者架構相容",
"reconnectFailed": "LoRA 重新連結錯誤:{message}", "reconnectFailed": "LoRA 重新連結錯誤:{message}",
"loraRestored": "LoRA 已恢復為重新關聯前的關聯",
"loraRestoreFailed": "LoRA 恢復錯誤:{message}",
"noPromptToSend": "沒有可發送的提示詞", "noPromptToSend": "沒有可發送的提示詞",
"cannotSend": "無法傳送配方:缺少配方 ID", "cannotSend": "無法傳送配方:缺少配方 ID",
"sendFailed": "傳送配方到工作流失敗", "sendFailed": "傳送配方到工作流失敗",
+61
View File
@@ -113,6 +113,8 @@ class RecipeHandlerSet:
"update_recipe": self.management.update_recipe, "update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open, "record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora, "reconnect_lora": self.management.reconnect_lora,
"restore_lora": self.management.restore_lora,
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid, "mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
"find_duplicates": self.query.find_duplicates, "find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk, "move_recipes_bulk": self.management.move_recipes_bulk,
@@ -1593,6 +1595,65 @@ class RecipeManagementHandler:
self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True) self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def restore_lora(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
for field in ("recipe_id", "lora_index"):
if field not in data:
raise RecipeValidationError(f"Missing required field: {field}")
result = await self._persistence_service.restore_lora(
recipe_scanner=recipe_scanner,
recipe_id=data["recipe_id"],
lora_index=int(data["lora_index"]),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error restoring LoRA: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def get_reconnect_suggestions(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
recipe_id = request.match_info.get("recipe_id")
lora_index_raw = request.match_info.get("lora_index")
if not recipe_id or lora_index_raw is None:
raise RecipeValidationError("recipe_id and lora_index are required")
try:
lora_index = int(lora_index_raw)
except (TypeError, ValueError):
raise RecipeValidationError("lora_index must be an integer")
result = await self._persistence_service.get_reconnect_suggestions(
recipe_scanner=recipe_scanner,
recipe_id=recipe_id,
lora_index=lora_index,
query=request.query.get("query") or None,
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"error": str(exc)}, status=404)
except Exception as exc:
self._logger.error(
"Error suggesting reconnect candidates: %s", exc, exc_info=True
)
return web.json_response({"error": str(exc)}, status=500)
async def mark_lora_hash_invalid(self, request: web.Request) -> web.Response: async def mark_lora_hash_invalid(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
+6
View File
@@ -49,6 +49,12 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("POST", "/api/lm/recipe/lora/restore", "restore_lora"),
RouteDefinition(
"GET",
"/api/lm/recipe/{recipe_id}/lora/{lora_index}/reconnect-suggestions",
"get_reconnect_suggestions",
),
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid" "POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
), ),
+255
View File
@@ -5,6 +5,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import copy
import difflib
import json import json
import logging import logging
import os import os
@@ -244,6 +246,188 @@ class RecipeScanner:
self._local_filename_cache_versions = versions self._local_filename_cache_versions = versions
return cache return cache
@staticmethod
def _strip_weight_extension(name: str) -> str:
"""Strip a known weight-file extension, preserving the original case."""
lower = name.lower()
for ext in sorted(WEIGHT_FILE_EXTENSIONS, key=len, reverse=True):
if lower.endswith(ext):
return name[: -len(ext)]
return name
async def suggest_reconnect_candidates(
self,
*,
entry: dict[str, Any],
recipe_base_model: Optional[str],
query: Optional[str] = None,
limit: int = 5,
) -> list[dict[str, Any]]:
"""Rank local LoRAs as reconnect candidates for a broken recipe entry.
Identity signals (same hash / same CivitAI model version) outrank
similarity signals (filename / model name fuzzy match). A confident
base-model mismatch (both sides known and different) is a hard
rejection here. This is deliberately stricter than reconnect itself,
which tolerates same-architecture-family labels (Pony ↔ Illustrious):
suggestions trade recall for a noise-free list, and the input box
remains available for deliberate cross-family picks. Unknown on
either side stays eligible, matching ``find_matching_models``.
When ``query`` is given
(search-as-you-type), identity signals are skipped and both
similarity signals score against the query, with a substring hit
(query of 3+ chars) flooring that signal's ratio at 0.8.
The name-similarity threshold (0.65) is stricter than the filename
one (0.55): long generic names share tokens like "style"/"pony" and
score deceptively high (measured 0.638 for unrelated models), while
filenames are the authoritative match key and get more slack.
"""
if limit <= 0 or not isinstance(entry, dict):
return []
lora_scanner = self._lora_scanner
if lora_scanner is None:
return []
data = await lora_scanner.get_cached_data()
recipe_bm = (recipe_base_model or "").strip().casefold()
def _base_model_known_mismatch(item: dict[str, Any]) -> bool:
"""Confident mismatch only — unknown on either side stays eligible."""
if not recipe_bm or recipe_bm == "unknown":
return False
item_bm = (item.get("base_model") or "").strip().casefold()
return bool(item_bm) and item_bm != "unknown" and item_bm != recipe_bm
def _base_model_adjustment(item: dict[str, Any]) -> float:
# Mismatches are already filtered out; this only boosts known-equal.
if not recipe_bm or recipe_bm == "unknown":
return 0.0
item_bm = (item.get("base_model") or "").strip().casefold()
return 0.1 if item_bm == recipe_bm else 0.0
pool: list[dict[str, Any]] = []
for item in getattr(data, "raw_data", None) or []:
if not isinstance(item, dict):
continue
# Items without a sha256 (pending/failed downloads) leave the
# entry without a usable hash — same rule as the filename cache.
if not (item.get("sha256") or "").strip():
continue
if not self._is_type_compatible(item, is_checkpoint=False):
continue
if _base_model_known_mismatch(item):
continue
pool.append(item)
if not pool:
return []
# Basename collision counts decide whether target_name needs the
# folder-relative path to resolve uniquely in find_matching_models.
basename_counts: dict[str, int] = {}
for item in pool:
key = self._normalize_filename_key(item.get("file_name") or "")
if key:
basename_counts[key] = basename_counts.get(key, 0) + 1
best: dict[str, dict[str, Any]] = {}
def _consider(item: dict[str, Any], score: float, reason: str) -> None:
key = item.get("file_path") or item.get("file_name") or ""
if not key:
return
current = best.get(key)
if current is None or score > current["score"]:
best[key] = {"item": item, "score": score, "reason": reason}
query_text = (query or "").strip()
if not query_text:
entry_hash = (entry.get("hash") or "").lower()
if entry_hash:
hash_cache = await self.build_local_hash_cache()
hit = hash_cache.get(entry_hash)
if (
isinstance(hit, dict)
and (hit.get("sha256") or "").strip()
and self._is_type_compatible(hit, is_checkpoint=False)
and not _base_model_known_mismatch(hit)
):
_consider(hit, 1.0 + _base_model_adjustment(hit), "same_hash")
version_id = entry.get("modelVersionId") or entry.get("id")
if version_id is not None:
hit = self._get_lora_from_version_index(str(version_id))
if (
isinstance(hit, dict)
and (hit.get("sha256") or "").strip()
and not _base_model_known_mismatch(hit)
):
_consider(hit, 0.95 + _base_model_adjustment(hit), "same_version")
filename_source = query_text or (entry.get("file_name") or "")
name_source = query_text or (entry.get("modelName") or "")
norm_filename_source = self._normalize_filename_key(filename_source)
name_source_cf = name_source.casefold()
# Substring hits floor the similarity ratio, but only for meaningful
# queries — a 1-2 character query is a substring of nearly every
# filename and would flood the suggestions with noise.
substring_floor = len(query_text) >= 3
for item in pool:
adjustment = _base_model_adjustment(item)
item_filename = self._normalize_filename_key(item.get("file_name") or "")
if norm_filename_source and item_filename:
ratio = difflib.SequenceMatcher(
None, norm_filename_source, item_filename
).ratio()
if substring_floor and norm_filename_source in item_filename:
ratio = max(ratio, 0.8)
if ratio >= 0.55:
_consider(
item, 0.5 + 0.4 * ratio + adjustment, "similar_filename"
)
item_name = (item.get("model_name") or "").casefold()
if name_source_cf and item_name:
ratio = difflib.SequenceMatcher(
None, name_source_cf, item_name
).ratio()
if substring_floor and name_source_cf in item_name:
ratio = max(ratio, 0.8)
if ratio >= 0.65:
_consider(item, 0.4 + 0.35 * ratio + adjustment, "similar_name")
suggestions = []
for record in best.values():
item = record["item"]
file_name = item.get("file_name") or ""
stem = self._strip_weight_extension(file_name)
folder = (item.get("folder") or "").replace("\\", "/").strip("/")
norm_key = self._normalize_filename_key(file_name)
if norm_key and basename_counts.get(norm_key, 0) > 1 and folder:
target_name = f"{folder}/{stem}"
else:
target_name = stem
suggestions.append(
{
"file_name": file_name,
"file_path": item.get("file_path") or "",
"model_name": item.get("model_name") or "",
"base_model": item.get("base_model") or "",
"preview_url": item.get("preview_url") or "",
"hash": (item.get("sha256") or "").lower(),
"score": round(record["score"], 3),
"match_reason": record["reason"],
"target_name": target_name,
}
)
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
return suggestions[:limit]
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool: def _is_rematch_candidate(self, entry: dict[str, Any]) -> 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.
@@ -3677,6 +3861,13 @@ class RecipeScanner:
raise RecipeNotFoundError("LoRA index out of range in recipe") raise RecipeNotFoundError("LoRA index out of range in recipe")
lora_entry = loras[lora_index] lora_entry = loras[lora_index]
# Snapshot the pre-update state so the association can be restored
# later (undo reconnect). Never nest snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in lora_entry.items()
if key != "reconnectSnapshot"
}
lora_entry["isDeleted"] = False lora_entry["isDeleted"] = False
lora_entry["hashInvalid"] = False lora_entry["hashInvalid"] = False
lora_entry["exclude"] = False lora_entry["exclude"] = False
@@ -3695,6 +3886,8 @@ class RecipeScanner:
lora_entry["modelVersionName"] = civitai_info.get("name", "") lora_entry["modelVersionName"] = civitai_info.get("name", "")
lora_entry["modelVersionId"] = civitai_info.get("id") lora_entry["modelVersionId"] = civitai_info.get("id")
lora_entry["reconnectSnapshot"] = snapshot
from ..utils.utils import calculate_recipe_fingerprint from ..utils.utils import calculate_recipe_fingerprint
recipe_data["fingerprint"] = calculate_recipe_fingerprint( recipe_data["fingerprint"] = calculate_recipe_fingerprint(
@@ -3730,6 +3923,68 @@ class RecipeScanner:
updated_lora = self._enrich_lora_entry(updated_lora) updated_lora = self._enrich_lora_entry(updated_lora)
return recipe_data, updated_lora return recipe_data, updated_lora
async def restore_lora_entry(
self,
recipe_id: str,
lora_index: int,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Restore a LoRA entry to its pre-reconnect snapshot.
Reverses :meth:`update_lora_entry`: the entry saved under
``reconnectSnapshot`` becomes the entry again and the snapshot is
dropped. Returns the updated recipe data and the restored LoRA
metadata.
"""
recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found")
async with self._mutation_lock:
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
recipe_data = json.load(file_obj)
loras = recipe_data.get("loras", [])
if lora_index < 0 or lora_index >= len(loras):
raise RecipeNotFoundError("LoRA index out of range in recipe")
snapshot = loras[lora_index].get("reconnectSnapshot")
if not isinstance(snapshot, dict):
raise RecipeValidationError(
"LoRA entry has no reconnect snapshot to restore"
)
restored_entry = copy.deepcopy(snapshot)
restored_entry.pop("reconnectSnapshot", None)
loras[lora_index] = restored_entry
from ..utils.utils import calculate_recipe_fingerprint
recipe_data["fingerprint"] = calculate_recipe_fingerprint(
recipe_data.get("loras", [])
)
recipe_data["modified"] = time.time()
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
cache = await self.get_cached_data()
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
if not replaced:
await cache.add_recipe(recipe_data, resort=False)
self._schedule_resort()
# Update FTS index
self._update_fts_index_for_recipe(recipe_data, "update")
# Update persistent SQLite cache
if self._persistent_cache:
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
self._json_path_map[recipe_id] = recipe_json_path
restored_lora = self._enrich_lora_entry(dict(restored_entry))
return recipe_data, restored_lora
async def set_lora_entry_hash_invalid( async def set_lora_entry_hash_invalid(
self, self,
recipe_id: str, recipe_id: str,
+90 -8
View File
@@ -13,6 +13,11 @@ from typing import Any, Awaitable, Dict, Iterable, Optional, cast
from ...config import config from ...config import config
from ...recipes.constants import GEN_PARAM_KEYS from ...recipes.constants import GEN_PARAM_KEYS
from ...utils.base_model import (
RELATION_COMPATIBLE,
RELATION_INCOMPATIBLE,
base_model_relation,
)
from ...utils.utils import calculate_recipe_fingerprint from ...utils.utils import calculate_recipe_fingerprint
from ..pending_delete_service import get_pending_delete_service from ..pending_delete_service import get_pending_delete_service
from .errors import RecipeNotFoundError, RecipeValidationError from .errors import RecipeNotFoundError, RecipeValidationError
@@ -430,19 +435,30 @@ class RecipePersistenceService:
with open(recipe_path, "r", encoding="utf-8") as file_obj: with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_base_model = json.load(file_obj).get("base_model", "") recipe_base_model = json.load(file_obj).get("base_model", "")
target_lora = await recipe_scanner.get_local_lora(target_name, recipe_base_model)
if not target_lora:
matches = await recipe_scanner.find_local_loras_by_name(target_name) matches = await recipe_scanner.find_local_loras_by_name(target_name)
if len(matches) > 1: if not matches:
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
# Three-tier base-model guard: exact/unknown labels pass silently;
# labels from the same architecture family (e.g. Pony ↔ Illustrious)
# pass but are reported so the UI can warn; confident architecture
# mismatches stay hard-rejected because they can never load.
eligible: list[tuple[dict, str]] = []
for match in matches:
relation = base_model_relation(recipe_base_model, match.get("base_model"))
if relation != RELATION_INCOMPATIBLE:
eligible.append((match, relation))
if not eligible:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
if len(eligible) > 1:
raise RecipeValidationError( raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; " f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate" "include the folder path to disambiguate"
) )
if len(matches) == 1: target_lora, target_relation = eligible[0]
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
recipe_data, updated_lora = await recipe_scanner.update_lora_entry( recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
recipe_id, recipe_id,
@@ -451,6 +467,43 @@ class RecipePersistenceService:
target_lora=target_lora, target_lora=target_lora,
) )
image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
matching_recipes = []
if "fingerprint" in recipe_data:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(recipe_data["fingerprint"])
if recipe_id in matching_recipes:
matching_recipes.remove(recipe_id)
payload: dict[str, Any] = {
"success": True,
"recipe_id": recipe_id,
"updated_lora": updated_lora,
"matching_recipes": matching_recipes,
}
if target_relation == RELATION_COMPATIBLE:
# Structured data, not prose — the frontend localizes the warning.
payload["base_model_mismatch"] = {
"recipe_base_model": recipe_base_model,
"lora_base_model": target_lora.get("base_model") or "",
}
return PersistenceResult(payload)
async def restore_lora(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
) -> PersistenceResult:
"""Restore a LoRA entry to the state captured before its reconnect."""
recipe_data, updated_lora = await recipe_scanner.restore_lora_entry(
recipe_id, lora_index
)
image_path = recipe_data.get("file_path") image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path): if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data) self._exif_utils.append_recipe_metadata(image_path, recipe_data)
@@ -470,6 +523,35 @@ class RecipePersistenceService:
} }
) )
async def get_reconnect_suggestions(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
query: str | None = None,
) -> PersistenceResult:
"""Return ranked local LoRA candidates for reconnecting a recipe entry."""
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found")
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_data = json.load(file_obj)
loras = recipe_data.get("loras") or []
if lora_index < 0 or lora_index >= len(loras):
raise RecipeValidationError(f"Invalid lora_index: {lora_index}")
suggestions = await recipe_scanner.suggest_reconnect_candidates(
entry=loras[lora_index],
recipe_base_model=recipe_data.get("base_model"),
query=query,
)
return PersistenceResult({"success": True, "suggestions": suggestions})
async def mark_lora_hash_invalid( async def mark_lora_hash_invalid(
self, self,
*, *,
+79
View File
@@ -0,0 +1,79 @@
"""Base-model architecture families and compatibility relations.
CivitAI base-model labels describe fine-tune lineages, not architectures.
A LoRA physically loads on any checkpoint sharing its tensor architecture,
so e.g. Pony / Illustrious / NoobAI / SDXL 1.0 LoRAs are interchangeable
(quality varies, but nothing breaks). Different architectures (SD 1.5 vs
SDXL vs Flux) are guaranteed failures and must stay hard-rejected.
Only families with high-confidence architecture equivalence are listed.
Anything not in the table is treated as its own family, i.e. only an exact
label match is accepted — unknown new labels never get wrongly waved through.
"""
from __future__ import annotations
from typing import Optional
# Normalized (casefolded, stripped) base-model label -> architecture family.
_BASE_MODEL_FAMILIES = {
# SD 1.x — all share the original 512px latent UNet.
"sd 1.4": "sd1",
"sd 1.5": "sd1",
"sd 1.5 lcm": "sd1",
"sd 1.5 hyper": "sd1",
# SDXL lineage — Pony / Illustrious / NoobAI are SDXL fine-tunes.
# Note: Pony V7 is AuraFlow-based, NOT SDXL, so it is deliberately absent.
"sdxl 1.0": "sdxl",
"sdxl lightning": "sdxl",
"sdxl hyper": "sdxl",
"pony": "sdxl",
"pony diffusion": "sdxl",
"pony diffusion v6 xl": "sdxl",
"illustrious": "sdxl",
"illustrious 0.1": "sdxl",
"illustrious 1.0": "sdxl",
"illustrious 1.1": "sdxl",
"noobai": "sdxl",
# Flux.1 — dev/schnell/Krea share the 12B rectified-flow transformer.
"flux.1 d": "flux1",
"flux.1 s": "flux1",
"flux.1 krea": "flux1",
# SD 3.5 Large and its Turbo distill share the 8B MMDiT. SD 3 (2B) and
# SD 3.5 Medium (2.5B) have different shapes and stay unlisted.
"sd 3.5 large": "sd35-large",
"sd 3.5 large turbo": "sd35-large",
}
_UNKNOWN_TOKENS = {"", "unknown", "other", "none", "null"}
# Relation constants returned by base_model_relation().
RELATION_UNKNOWN = "unknown" # at least one side has no usable label
RELATION_SAME = "same" # identical labels
RELATION_COMPATIBLE = "compatible" # different labels, same architecture family
RELATION_INCOMPATIBLE = "incompatible" # different labels, different/unknown family
def _normalize(label: Optional[str]) -> str:
return (label or "").strip().casefold()
def base_model_relation(a: Optional[str], b: Optional[str]) -> str:
"""Classify how two base-model labels relate for reconnect purposes.
``RELATION_UNKNOWN`` when either side has no usable label (callers treat
it as lenient-allow), ``RELATION_SAME`` for identical labels,
``RELATION_COMPATIBLE`` when both labels map to the same architecture
family, and ``RELATION_INCOMPATIBLE`` otherwise — including when a label
is missing from the family table (conservative fallback).
"""
na, nb = _normalize(a), _normalize(b)
if na in _UNKNOWN_TOKENS or nb in _UNKNOWN_TOKENS:
return RELATION_UNKNOWN
if na == nb:
return RELATION_SAME
fa = _BASE_MODEL_FAMILIES.get(na)
fb = _BASE_MODEL_FAMILIES.get(nb)
if fa is not None and fa == fb:
return RELATION_COMPATIBLE
return RELATION_INCOMPATIBLE
+116 -3
View File
@@ -729,6 +729,9 @@
.recipe-lora-item { .recipe-lora-item {
display: flex; display: flex;
/* The reconnect panel is a full-width child that wraps below the
thumbnail + content row. */
flex-wrap: wrap;
gap: var(--space-2); gap: var(--space-2);
padding: 10px var(--space-2); padding: 10px var(--space-2);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -887,6 +890,29 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
/* Restore icon for manually reconnected entries: its presence on the info
row doubles as the "was reconnected" marker. */
.lora-undo-reconnect {
margin-left: auto;
background: none;
border: none;
color: var(--text-color);
opacity: 0.55;
cursor: pointer;
padding: 2px 4px;
border-radius: var(--border-radius-xs);
font-size: 0.95em;
line-height: 1;
transition: var(--transition-base);
}
.lora-undo-reconnect:hover,
.lora-undo-reconnect:focus-visible {
opacity: 1;
color: var(--lora-accent);
background: var(--lora-surface);
}
.local-badge, .local-badge,
.missing-badge, .missing-badge,
.invalid-hash-badge { .invalid-hash-badge {
@@ -968,13 +994,17 @@
/* LoRA reconnect container: an inline extension of the item, not a nested /* LoRA reconnect container: an inline extension of the item, not a nested
card — a dashed separator reads lighter than another bordered box inside card — a dashed separator reads lighter than another bordered box inside
an already bordered item. */ an already bordered item. It is a direct child of .recipe-lora-item and
spans the full row (thumbnail column included). */
.lora-reconnect-container { .lora-reconnect-container {
display: none; display: none;
flex-direction: column; flex-direction: column;
flex-basis: 100%;
/* Flex items default to min-width:auto — never let content force the
panel wider than the row. */
min-width: 0;
border-top: 1px dashed var(--border-color); border-top: 1px dashed var(--border-color);
padding-top: 10px; padding-top: 10px;
margin-top: 10px;
gap: 10px; gap: 10px;
} }
@@ -1015,7 +1045,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background: var(--bg-color); background: var(--bg-color);
color: var(--text-color); color: var(--text-color);
font-size: 0.9em; font-size: 0.95em;
} }
.reconnect-error { .reconnect-error {
@@ -1029,6 +1059,89 @@
display: block; display: block;
} }
.reconnect-suggestions {
display: flex;
flex-direction: column;
gap: 4px;
}
.reconnect-suggestions:empty {
display: none;
}
.reconnect-suggestions-loading,
.reconnect-suggestions-empty {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
padding: 4px 2px;
}
.reconnect-suggestion {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
/* Buttons default to content-box: without this, width:100% + padding +
border overflows the panel by 18px and forces a horizontal scrollbar. */
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface, var(--bg-color));
color: var(--text-color);
font-size: 0.95em;
text-align: left;
cursor: pointer;
transition: var(--transition-base);
}
.reconnect-suggestion:hover,
.reconnect-suggestion:focus-visible {
border-color: var(--lora-accent);
}
.reconnect-suggestion-preview {
width: 40px;
height: 40px;
border-radius: var(--border-radius-xs);
object-fit: cover;
flex-shrink: 0;
background: var(--bg-color);
}
.reconnect-suggestion-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.reconnect-suggestion-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reconnect-suggestion-secondary {
font-size: 0.9em;
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reconnect-suggestion-reason {
flex-shrink: 0;
padding: 2px 6px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
color: var(--lora-accent);
font-size: 0.85em;
white-space: nowrap;
}
.reconnect-actions { .reconnect-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+39 -9
View File
@@ -28,8 +28,14 @@ export class Combobox {
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown. * @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
* @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions] * @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions]
* Async function returning dynamic suggestions for the current input. * Async function returning dynamic suggestions for the current input.
* @param {string} [options.placeholder] Placeholder text for the empty state. * @param {string} [options.placeholder] Placeholder text for the input and the
* dropdown empty state (see emptyText to override the latter).
* @param {string} [options.emptyText] Text for the dropdown empty state;
* defaults to `placeholder`, then 'No options'. Unlike `placeholder`
* it never touches the input element.
* @param {(value: string) => void} [options.onSelect] Callback when an option is chosen. * @param {(value: string) => void} [options.onSelect] Callback when an option is chosen.
* @param {(value: string) => void} [options.onCommit] Callback when Enter is
* pressed without a highlighted option (free-text commit).
*/ */
constructor(inputElement, options = {}) { constructor(inputElement, options = {}) {
if (!inputElement || inputElement.tagName !== 'INPUT') { if (!inputElement || inputElement.tagName !== 'INPUT') {
@@ -41,7 +47,9 @@ export class Combobox {
this.presets = Array.isArray(options.presets) ? [...options.presets] : []; this.presets = Array.isArray(options.presets) ? [...options.presets] : [];
this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null; this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null;
this.placeholder = options.placeholder || ''; this.placeholder = options.placeholder || '';
this.emptyText = options.emptyText || '';
this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null; this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null;
this.onCommit = typeof options.onCommit === 'function' ? options.onCommit : null;
// Internal state // Internal state
this._isOpen = false; this._isOpen = false;
@@ -109,19 +117,24 @@ export class Combobox {
// ---- event wiring ---- // ---- event wiring ----
_bindEvents() { _bindEvents() {
this.input.addEventListener('focus', () => { // Keep references so destroy() can detach input listeners — callers
// may destroy a Combobox while its input stays in the DOM.
this._focusHandler = () => {
if (this._suppressInputOpen) return; if (this._suppressInputOpen) return;
this._open(); this._open();
}); };
this.input.addEventListener('focus', this._focusHandler);
this.input.addEventListener('input', () => { this._inputHandler = () => {
if (this._suppressInputOpen) return; if (this._suppressInputOpen) return;
this._open(); // no-op if already open this._open(); // no-op if already open
this._refresh(); // re-filter by current input value this._refresh(); // re-filter by current input value
this._scheduleFetch(); this._scheduleFetch();
}); };
this.input.addEventListener('input', this._inputHandler);
this.input.addEventListener('keydown', (event) => this._onKeyDown(event)); this._keyDownHandler = (event) => this._onKeyDown(event);
this.input.addEventListener('keydown', this._keyDownHandler);
// Click an option (delegated) // Click an option (delegated)
this.panel.addEventListener('click', (event) => { this.panel.addEventListener('click', (event) => {
@@ -167,6 +180,9 @@ export class Combobox {
event.preventDefault(); event.preventDefault();
this._open(); this._open();
this._setActiveIndex(0); this._setActiveIndex(0);
} else if (event.key === 'Enter' && typeof this.onCommit === 'function') {
event.preventDefault();
this.onCommit(this.input.value);
} }
return; return;
} }
@@ -184,11 +200,17 @@ export class Combobox {
case 'Enter': case 'Enter':
// Only intercept Enter to pick an option when one is actively // Only intercept Enter to pick an option when one is actively
// highlighted; otherwise let the input's default behavior // highlighted; otherwise commit the free-text value (when an
// (form submit / free-text commit) proceed. // onCommit handler is registered) and let the input's default
// behavior proceed otherwise.
if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) { if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) {
event.preventDefault(); event.preventDefault();
this._choose(this._renderedOptions[this._activeIndex]); this._choose(this._renderedOptions[this._activeIndex]);
} else if (typeof this.onCommit === 'function') {
event.preventDefault();
const value = this.input.value;
this._close();
this.onCommit(value);
} }
break; break;
@@ -254,7 +276,7 @@ export class Combobox {
if (items.length === 0) { if (items.length === 0) {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'lm-combobox-empty'; empty.className = 'lm-combobox-empty';
empty.textContent = this.placeholder ? this.placeholder : 'No options'; empty.textContent = this.emptyText || this.placeholder || 'No options';
this.panel.appendChild(empty); this.panel.appendChild(empty);
this._activeIndex = -1; this._activeIndex = -1;
return; return;
@@ -333,11 +355,19 @@ export class Combobox {
if (this.panel && this.panel.parentNode) { if (this.panel && this.panel.parentNode) {
this.panel.parentNode.removeChild(this.panel); this.panel.parentNode.removeChild(this.panel);
} }
this.input.removeEventListener('focus', this._focusHandler);
this.input.removeEventListener('input', this._inputHandler);
this.input.removeEventListener('keydown', this._keyDownHandler);
document.removeEventListener('mousedown', this._outsideClickHandler); document.removeEventListener('mousedown', this._outsideClickHandler);
window.removeEventListener('resize', this._resizeHandler); window.removeEventListener('resize', this._resizeHandler);
window.removeEventListener('scroll', this._resizeHandler, true); window.removeEventListener('scroll', this._resizeHandler, true);
} }
/** Whether the dropdown panel is currently open. */
isOpen() {
return this._isOpen;
}
_choose(value) { _choose(value) {
this.input.value = value; this.input.value = value;
this._close(); this._close();
+241 -6
View File
@@ -12,6 +12,7 @@ import { openMediaViewer } from './shared/MediaViewer.js';
import { showRecipeDeleteConfirmation } from './RecipeCard.js'; import { showRecipeDeleteConfirmation } from './RecipeCard.js';
import { renderCompactTags, setupTagTooltip } from './shared/utils.js'; import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
import { setupTagEditMode } from './shared/ModelTags.js'; import { setupTagEditMode } from './shared/ModelTags.js';
import { Combobox } from './Combobox.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([ const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt', 'prompt',
@@ -171,7 +172,10 @@ class RecipeModal {
reconnectContainers.forEach(container => { reconnectContainers.forEach(container => {
if (container.classList.contains('active') && if (container.classList.contains('active') &&
!container.contains(event.target) && !container.contains(event.target) &&
!event.target.closest('.lora-reconnect')) { !event.target.closest('.lora-reconnect') &&
// The Combobox dropdown lives on document.body — clicks on
// its options are part of the reconnect interaction.
!event.target.closest('.lm-combobox-panel')) {
this.hideReconnectInput(container); this.hideReconnectInput(container);
} }
}); });
@@ -236,6 +240,7 @@ class RecipeModal {
this.navigationKeyHandler = null; this.navigationKeyHandler = null;
} }
this.navigationInProgress = false; this.navigationInProgress = false;
this._destroyAllReconnectComboboxes();
} }
setupNavigationShortcuts() { setupNavigationShortcuts() {
@@ -878,6 +883,9 @@ class RecipeModal {
} }
if (lorasListElement && loras.length > 0) { if (lorasListElement && loras.length > 0) {
// The list innerHTML below discards every reconnect container;
// tear down their Combobox panels (appended to document.body) first.
this._destroyAllReconnectComboboxes();
lorasListElement.innerHTML = loras.map(lora => { lorasListElement.innerHTML = loras.map(lora => {
const existsLocally = lora.inLibrary; const existsLocally = lora.inLibrary;
const isDeleted = lora.isDeleted; const isDeleted = lora.isDeleted;
@@ -941,6 +949,24 @@ class RecipeModal {
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openLoraDetails', { name: lora.modelName }, `View ${lora.modelName} in the LoRA library`))}"` ? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openLoraDetails', { name: lora.modelName }, `View ${lora.modelName} in the LoRA library`))}"`
: ''; : '';
// A reconnect snapshot marks a manually reconnected entry.
// The restore icon on the info row doubles as that marker;
// its tooltip names the previous association.
let undoReconnectIcon = '';
if (existsLocally && lora.reconnectSnapshot) {
const previousName = lora.reconnectSnapshot.file_name || lora.reconnectSnapshot.modelName || '';
const undoLabel = translate('recipes.resources.undoReconnect', {}, 'Undo');
const undoTooltip = previousName
? translate('recipes.resources.undoReconnectTooltipNamed', { name: previousName }, `Restore to ${previousName} (the association before reconnecting)`)
: translate('recipes.resources.undoReconnectTooltip', {}, 'Restore the association this entry had before reconnecting');
undoReconnectIcon = `
<button type="button" class="lora-undo-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(undoTooltip)}" aria-label="${escapeHtml(undoTooltip)}">
<i class="fas fa-rotate-left" aria-hidden="true"></i>
</button>
`;
}
return ` return `
<div class="${loraItemClass}" data-lora-index="${loraIndex}"${rowA11yAttributes}> <div class="${loraItemClass}" data-lora-index="${loraIndex}"${rowA11yAttributes}>
<div class="recipe-lora-thumbnail"> <div class="recipe-lora-thumbnail">
@@ -958,8 +984,10 @@ class RecipeModal {
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''} ${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div> <div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''} ${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
${undoReconnectIcon}
</div> </div>
${actionsRow} ${actionsRow}
</div>
${isDeleted || lora.hashInvalid ? ` ${isDeleted || lora.hashInvalid ? `
<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">
@@ -973,10 +1001,10 @@ class RecipeModal {
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button> <button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
</div> </div>
</div> </div>
<div class="reconnect-suggestions"></div>
<p class="reconnect-error" role="alert"></p> <p class="reconnect-error" role="alert"></p>
</div>` : ''} </div>` : ''}
</div> </div>
</div>
`; `;
}).join(''); }).join('');
@@ -988,6 +1016,7 @@ class RecipeModal {
this.recipeLorasSyntax = ''; this.recipeLorasSyntax = '';
} else if (lorasListElement) { } else if (lorasListElement) {
this._destroyAllReconnectComboboxes();
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>'; lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
this.recipeLorasSyntax = ''; this.recipeLorasSyntax = '';
} }
@@ -1665,12 +1694,17 @@ class RecipeModal {
this.clearReconnectError(input.closest('.lora-reconnect-container')); this.clearReconnectError(input.closest('.lora-reconnect-container'));
}); });
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const container = input.closest('.lora-reconnect-container'); const container = input.closest('.lora-reconnect-container');
// When a Combobox is attached it owns Enter (pick a highlighted
// option, or commit free text via onCommit) and, while its
// dropdown is open, Escape (close the dropdown first).
const combobox = this._reconnectComboboxes && this._reconnectComboboxes.get(container);
if (e.key === 'Enter') {
if (combobox) return;
const loraIndex = container.getAttribute('data-lora-index'); const loraIndex = container.getAttribute('data-lora-index');
this.reconnectLora(loraIndex, input.value); this.reconnectLora(loraIndex, input.value);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
const container = input.closest('.lora-reconnect-container'); if (combobox && combobox.isOpen()) return;
this.hideReconnectInput(container); this.hideReconnectInput(container);
} }
}); });
@@ -1680,7 +1714,7 @@ class RecipeModal {
showReconnectInput(loraIndex) { showReconnectInput(loraIndex) {
// Hide any currently active reconnect containers // Hide any currently active reconnect containers
document.querySelectorAll('.lora-reconnect-container.active').forEach(active => { document.querySelectorAll('.lora-reconnect-container.active').forEach(active => {
active.classList.remove('active'); this.hideReconnectInput(active);
}); });
// Show the reconnect container for this lora // Show the reconnect container for this lora
@@ -1690,6 +1724,8 @@ class RecipeModal {
this.clearReconnectError(container); this.clearReconnectError(container);
const input = container.querySelector('.reconnect-input'); const input = container.querySelector('.reconnect-input');
input.focus(); input.focus();
this._attachReconnectCombobox(container, loraIndex);
this._loadReconnectSuggestions(container, loraIndex);
} }
} }
@@ -1700,6 +1736,139 @@ class RecipeModal {
const input = container.querySelector('.reconnect-input'); const input = container.querySelector('.reconnect-input');
if (input) input.value = ''; if (input) input.value = '';
} }
if (container) {
this._destroyReconnectCombobox(container);
// Invalidate any in-flight suggestions fetch for this panel
this._reconnectSuggestionsToken = (this._reconnectSuggestionsToken || 0) + 1;
const suggestions = container.querySelector('.reconnect-suggestions');
if (suggestions) suggestions.innerHTML = '';
}
}
_attachReconnectCombobox(container, loraIndex) {
if (!this._reconnectComboboxes) {
this._reconnectComboboxes = new Map();
}
if (this._reconnectComboboxes.has(container)) {
return;
}
const input = container.querySelector('.reconnect-input');
if (!input) {
return;
}
const combobox = new Combobox(input, {
fetchOptions: async (value) => {
const suggestions = await this._fetchReconnectSuggestions(loraIndex, value);
return suggestions.map(suggestion => suggestion.target_name);
},
// emptyText only labels the dropdown empty state; the input keeps
// its own translated placeholder from the markup.
emptyText: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'),
onCommit: (value) => {
this.reconnectLora(loraIndex, value);
},
});
this._reconnectComboboxes.set(container, combobox);
}
_destroyReconnectCombobox(container) {
const combobox = this._reconnectComboboxes && this._reconnectComboboxes.get(container);
if (combobox) {
combobox.destroy();
this._reconnectComboboxes.delete(container);
}
}
_destroyAllReconnectComboboxes() {
if (!this._reconnectComboboxes) {
return;
}
this._reconnectComboboxes.forEach(combobox => combobox.destroy());
this._reconnectComboboxes.clear();
this._reconnectSuggestionsToken = (this._reconnectSuggestionsToken || 0) + 1;
}
async _fetchReconnectSuggestions(loraIndex, query) {
const suffix = query ? `?query=${encodeURIComponent(query)}` : '';
const response = await fetch(`/api/lm/recipe/${this.recipeId}/lora/${loraIndex}/reconnect-suggestions${suffix}`);
if (!response.ok) {
return [];
}
const result = await response.json();
return result && result.success && Array.isArray(result.suggestions) ? result.suggestions : [];
}
async _loadReconnectSuggestions(container, loraIndex) {
const listElement = container.querySelector('.reconnect-suggestions');
if (!listElement) {
return;
}
const token = (this._reconnectSuggestionsToken || 0) + 1;
this._reconnectSuggestionsToken = token;
listElement.innerHTML = `<div class="reconnect-suggestions-loading">${escapeHtml(translate('recipes.resources.reconnectSuggestionsLoading', {}, 'Searching local library...'))}</div>`;
try {
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
// Stale guard: panel closed or another item opened while fetching
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return;
}
this._renderReconnectSuggestions(container, suggestions, loraIndex);
} catch (error) {
console.error('Error fetching reconnect suggestions:', error);
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return;
}
this._renderReconnectSuggestions(container, [], loraIndex);
}
}
_renderReconnectSuggestions(container, suggestions, loraIndex) {
const listElement = container.querySelector('.reconnect-suggestions');
if (!listElement) {
return;
}
listElement.innerHTML = '';
if (!suggestions.length) {
const empty = document.createElement('div');
empty.className = 'reconnect-suggestions-empty';
empty.textContent = translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
listElement.appendChild(empty);
return;
}
const reasonLabels = {
same_hash: translate('recipes.resources.reconnectMatchSameHash', {}, 'Same hash'),
same_version: translate('recipes.resources.reconnectMatchSameVersion', {}, 'Same model version'),
similar_filename: translate('recipes.resources.reconnectMatchSimilarFilename', {}, 'Similar filename'),
similar_name: translate('recipes.resources.reconnectMatchSimilarName', {}, 'Similar name'),
};
suggestions.forEach(suggestion => {
// The filename (stem) is what the match scored on and what gets
// submitted — show it as the primary label, with the base model
// as secondary context. The model name is omitted: it played no
// part in the match and only adds noise.
const stem = suggestion.target_name || suggestion.file_name || '';
const secondaryParts = [];
if (suggestion.base_model) {
secondaryParts.push(suggestion.base_model);
}
const secondary = secondaryParts.join(' · ');
const row = document.createElement('button');
row.type = 'button';
row.className = 'reconnect-suggestion';
row.title = stem;
row.innerHTML = `
<img class="reconnect-suggestion-preview" src="${escapeHtml(suggestion.preview_url || '/loras_static/images/no-preview.png')}" alt="" loading="lazy" onerror="this.src='/loras_static/images/no-preview.png'">
<span class="reconnect-suggestion-info">
<span class="reconnect-suggestion-name">${escapeHtml(stem)}</span>
${secondary ? `<span class="reconnect-suggestion-secondary">${escapeHtml(secondary)}</span>` : ''}
</span>
<span class="reconnect-suggestion-reason">${escapeHtml(reasonLabels[suggestion.match_reason] || suggestion.match_reason || '')}</span>
`;
row.addEventListener('click', () => {
this.reconnectLora(loraIndex, suggestion.target_name);
});
listElement.appendChild(row);
});
} }
showReconnectError(container, message) { showReconnectError(container, message) {
@@ -1761,6 +1930,19 @@ class RecipeModal {
// Show success message // Show success message
showToast('toast.recipes.reconnectedSuccessfully', {}, 'success'); showToast('toast.recipes.reconnectedSuccessfully', {}, 'success');
// Same-architecture-family reconnects (e.g. Pony ↔ Illustrious)
// succeed but carry structured mismatch data — warn the user.
if (result.base_model_mismatch) {
showToast(
'toast.recipes.reconnectBaseModelMismatch',
{
recipe: result.base_model_mismatch.recipe_base_model,
lora: result.base_model_mismatch.lora_base_model,
},
'warning'
);
}
// Refresh modal to show updated content // Refresh modal to show updated content
setTimeout(() => { setTimeout(() => {
this.showRecipeDetails(this.currentRecipe); this.showRecipeDetails(this.currentRecipe);
@@ -1780,6 +1962,47 @@ class RecipeModal {
} }
} }
async restoreLora(loraIndex) {
try {
state.loadingManager.showSimpleLoading('Restoring LoRA...');
const response = await fetch('/api/lm/recipe/lora/restore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: this.recipeId,
lora_index: loraIndex
})
});
const result = await response.json();
if (result.success) {
// Swap the entry back to its pre-reconnect state
this.currentRecipe.loras[loraIndex] = result.updated_lora;
showToast('toast.recipes.loraRestored', {}, 'success');
setTimeout(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
loras: this.currentRecipe.loras
});
} else {
showToast('toast.recipes.loraRestoreFailed', { message: result.error }, 'error');
}
} catch (error) {
console.error('Error restoring LoRA:', error);
showToast('toast.recipes.loraRestoreFailed', { message: error.message }, 'error');
} finally {
state.loadingManager.hide();
}
}
renderCheckpoint(checkpoint) { renderCheckpoint(checkpoint) {
const existsLocally = !!checkpoint.inLibrary; const existsLocally = !!checkpoint.inLibrary;
const localPath = checkpoint.localPath || ''; const localPath = checkpoint.localPath || '';
@@ -2042,7 +2265,8 @@ class RecipeModal {
renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) { renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) {
// In-library LoRAs need no remediation: the badge and the local path // In-library LoRAs need no remediation: the badge and the local path
// already tell the full story. // already tell the full story. (The restore affordance for manually
// reconnected entries lives on the info row, not here.)
if (existsLocally) { if (existsLocally) {
return ''; return '';
} }
@@ -2113,6 +2337,17 @@ class RecipeModal {
this.showReconnectInput(button.dataset.loraIndex); this.showReconnectInput(button.dataset.loraIndex);
}); });
}); });
lorasListElement.querySelectorAll('.lora-undo-reconnect').forEach(button => {
if (button.dataset.wired === 'true') {
return;
}
button.dataset.wired = 'true';
button.addEventListener('click', (e) => {
e.stopPropagation();
this.restoreLora(button.dataset.loraIndex);
});
});
} }
/** /**
@@ -539,4 +539,241 @@ describe('RecipeModal resource item interactions', () => {
checkpointItem.click(); checkpointItem.click();
expect(navigateSpy).not.toHaveBeenCalled(); expect(navigateSpy).not.toHaveBeenCalled();
}); });
describe('reconnect suggestions', () => {
const suggestionsPayload = {
success: true,
suggestions: [
{
file_name: 'deleted-lora-v1.safetensors',
file_path: '/models/loras/deleted-lora-v1.safetensors',
model_name: 'Deleted LoRA v1',
base_model: 'SD 1.5',
preview_url: '/preview/deleted.png',
hash: 'abc123',
score: 0.95,
match_reason: 'same_version',
target_name: 'deleted-lora-v1',
},
],
};
function mockSuggestionsFetch(payload) {
const requests = [];
global.fetch = vi.fn(async (url, options) => {
requests.push({ url: String(url), options });
if (String(url).includes('/reconnect-suggestions')) {
return { ok: true, json: async () => payload };
}
if (String(url).includes('/recipe/lora/reconnect')) {
return {
ok: true,
json: async () => ({
success: true,
updated_lora: { name: 'deleted-lora-v1', modelName: 'Deleted LoRA v1', inLibrary: true },
}),
};
}
return { ok: true, json: async () => ({}) };
});
return requests;
}
async function openReconnectPanel(recipeModal, loraIndex) {
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const item = document.querySelector(`[data-lora-index="${loraIndex}"]`);
item.querySelector('.lora-reconnect').click();
return item.querySelector('.lora-reconnect-container');
}
it('fetches suggestions when the panel opens and renders them as rows', async () => {
const recipeModal = await createRecipeModal();
mockSuggestionsFetch(suggestionsPayload);
const container = await openReconnectPanel(recipeModal, 2);
// The loading state shows synchronously while the fetch is in flight
expect(container.querySelector('.reconnect-suggestions-loading')).not.toBeNull();
await vi.waitFor(() => {
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1);
});
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe-resources/lora/2/reconnect-suggestions'
);
const row = container.querySelector('.reconnect-suggestion');
// Primary label is the file stem (what the match scored on and what
// gets submitted); the secondary line shows only the base model — the
// model name is noise and intentionally omitted.
expect(row.querySelector('.reconnect-suggestion-name').textContent).toBe('deleted-lora-v1');
expect(row.querySelector('.reconnect-suggestion-secondary').textContent).toBe('SD 1.5');
expect(row.querySelector('.reconnect-suggestion-reason').textContent).toBe('Same model version');
expect(row.title).toBe('deleted-lora-v1');
const preview = row.querySelector('.reconnect-suggestion-preview');
expect(preview.getAttribute('src')).toBe('/preview/deleted.png');
});
it('reconnects with the suggestion target_name when a row is clicked', async () => {
const recipeModal = await createRecipeModal();
const requests = mockSuggestionsFetch(suggestionsPayload);
const container = await openReconnectPanel(recipeModal, 2);
await vi.waitFor(() => {
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1);
});
container.querySelector('.reconnect-suggestion').click();
await vi.waitFor(() => {
expect(requests.some(r => r.url === '/api/lm/recipe/lora/reconnect')).toBe(true);
});
const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/lora/reconnect');
expect(reconnectRequest.options.method).toBe('POST');
// lora_index rides as the DOM attribute string, same as the manual form
expect(JSON.parse(reconnectRequest.options.body)).toEqual({
recipe_id: 'recipe-resources',
lora_index: '2',
target_name: 'deleted-lora-v1',
});
});
it('warns when the reconnect crossed base-model families', async () => {
const recipeModal = await createRecipeModal();
global.fetch = vi.fn(async (url) => {
if (String(url).includes('/reconnect-suggestions')) {
return { ok: true, json: async () => suggestionsPayload };
}
if (String(url).includes('/recipe/lora/reconnect')) {
return {
ok: true,
json: async () => ({
success: true,
updated_lora: { name: 'deleted-lora-v1', modelName: 'Deleted LoRA v1', inLibrary: true },
base_model_mismatch: { recipe_base_model: 'Illustrious', lora_base_model: 'Pony' },
}),
};
}
return { ok: true, json: async () => ({}) };
});
const container = await openReconnectPanel(recipeModal, 2);
await vi.waitFor(() => {
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1);
});
container.querySelector('.reconnect-suggestion').click();
await vi.waitFor(() => {
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.reconnectBaseModelMismatch',
{ recipe: 'Illustrious', lora: 'Pony' },
'warning'
);
});
});
it('shows an empty state when no suggestions are available', async () => {
const recipeModal = await createRecipeModal();
mockSuggestionsFetch({ success: true, suggestions: [] });
const container = await openReconnectPanel(recipeModal, 3);
await vi.waitFor(() => {
expect(container.querySelector('.reconnect-suggestions-empty')).not.toBeNull();
});
expect(container.querySelector('.reconnect-suggestions-empty').textContent)
.toBe('No matching LoRAs in your local library');
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(0);
});
it('submits free text via the combobox onCommit when Enter is pressed', async () => {
const recipeModal = await createRecipeModal();
const requests = mockSuggestionsFetch({ success: true, suggestions: [] });
const container = await openReconnectPanel(recipeModal, 2);
const input = container.querySelector('.reconnect-input');
input.value = 'typed-lora-name';
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await vi.waitFor(() => {
expect(requests.some(r => r.url === '/api/lm/recipe/lora/reconnect')).toBe(true);
});
const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/lora/reconnect');
expect(JSON.parse(reconnectRequest.options.body)).toEqual({
recipe_id: 'recipe-resources',
lora_index: '2',
target_name: 'typed-lora-name',
});
});
it('keeps the panel open when the combobox dropdown is clicked', async () => {
const recipeModal = await createRecipeModal();
mockSuggestionsFetch({ success: true, suggestions: [] });
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
// Open the panel directly — button wiring races the hydration re-render,
// and this test is about the document click handler, not the button.
recipeModal.showReconnectInput('2');
const container = document.querySelector('.lora-reconnect-container[data-lora-index="2"]');
expect(container.classList.contains('active')).toBe(true);
// The dropdown panel lives on document.body; clicking an option there is
// part of the reconnect interaction, not an outside click.
const panel = document.createElement('div');
panel.className = 'lm-combobox-panel';
document.body.appendChild(panel);
panel.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(container.classList.contains('active')).toBe(true);
panel.remove();
// A genuine outside click still closes the panel
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(container.classList.contains('active')).toBe(false);
});
});
it('offers undo for reconnected entries and restores via the API', async () => {
const recipeModal = await createRecipeModal();
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
isolatedRecipe.loras[0].reconnectSnapshot = { file_name: 'gone', isDeleted: true };
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
const requests = [];
global.fetch = vi.fn(async (url, options) => {
requests.push({ url: String(url), options });
if (String(url).includes('/recipe/lora/restore')) {
return {
ok: true,
json: async () => ({
success: true,
updated_lora: { name: 'gone', modelName: 'Gone', inLibrary: false, isDeleted: true },
}),
};
}
return { ok: true, json: async () => ({}) };
});
recipeModal.showRecipeDetails(isolatedRecipe);
await flushWiring();
const item = document.querySelector('[data-lora-index="0"]');
const undoButton = item.querySelector('.lora-undo-reconnect');
expect(undoButton).not.toBeNull();
undoButton.click();
// Wait for the whole restore chain (fetch -> json -> toast), not just the
// request itself.
await vi.waitFor(() => {
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.loraRestored', {}, 'success');
});
const restoreRequest = requests.find(r => r.url === '/api/lm/recipe/lora/restore');
expect(restoreRequest.options.method).toBe('POST');
expect(JSON.parse(restoreRequest.options.body)).toEqual({
recipe_id: 'recipe-resources',
lora_index: '0',
});
});
}); });
+394
View File
@@ -16,6 +16,7 @@ from py.services.recipe_scanner import RecipeScanner
from py.services import settings_manager as settings_manager_module from py.services import settings_manager as settings_manager_module
from py.utils.models import BaseModelMetadata from py.utils.models import BaseModelMetadata
from py.utils.utils import calculate_recipe_fingerprint from py.utils.utils import calculate_recipe_fingerprint
from py.services.recipes.errors import RecipeValidationError
async def _wait_for_resort(scanner: RecipeScanner) -> None: async def _wait_for_resort(scanner: RecipeScanner) -> None:
@@ -164,6 +165,285 @@ async def test_local_lora_lookup_requires_unambiguous_name_and_matching_base_mod
assert await scanner.get_local_lora_by_hash("b" * 64) is models[1] assert await scanner.get_local_lora_by_hash("b" * 64) is models[1]
def _suggestion_item(**overrides):
item = {
"sha256": "ab" * 32,
"file_name": "style.safetensors",
"file_path": "/models/loras/style.safetensors",
"folder": "",
"model_name": "Style LoRA",
"base_model": "SD 1.5",
"preview_url": "/preview/style.png",
}
item.update(overrides)
return item
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_same_hash_ranks_first(recipe_scanner):
scanner, stub = recipe_scanner
stub.cache_version = 1
same_hash = _suggestion_item(
file_name="zzz-unrelated.safetensors",
file_path="/models/loras/zzz-unrelated.safetensors",
model_name="Unrelated",
)
similar = _suggestion_item(
sha256="cd" * 32,
file_name="anime-style-v2.safetensors",
file_path="/models/loras/anime-style-v2.safetensors",
model_name="Anime Style",
)
stub._cache.raw_data = [same_hash, similar]
suggestions = await scanner.suggest_reconnect_candidates(
entry={"hash": "ab" * 32, "file_name": "anime-style-v2.safetensors"},
recipe_base_model="SD 1.5",
)
assert suggestions[0]["match_reason"] == "same_hash"
assert suggestions[0]["file_path"] == same_hash["file_path"]
assert suggestions[0]["score"] >= 1.0
assert any(s["match_reason"] == "similar_filename" for s in suggestions[1:])
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_same_version(recipe_scanner):
scanner, stub = recipe_scanner
item = _suggestion_item()
stub._cache.raw_data = [item]
stub._cache.version_index[456] = item
suggestions = await scanner.suggest_reconnect_candidates(
entry={"modelVersionId": 456},
recipe_base_model="SD 1.5",
)
assert len(suggestions) == 1
assert suggestions[0]["match_reason"] == "same_version"
assert suggestions[0]["score"] >= 0.95
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_base_model_mismatch_excluded(recipe_scanner):
scanner, stub = recipe_scanner
matching = _suggestion_item(
file_name="anime-style.safetensors",
file_path="/models/loras/anime-style.safetensors",
model_name="Anime Style",
base_model="SD 1.5",
)
mismatched = _suggestion_item(
sha256="cd" * 32,
file_name="anime-style.safetensors",
file_path="/models/loras/sdxl/anime-style.safetensors",
folder="sdxl",
model_name="Anime Style",
base_model="SDXL 1.0",
)
stub._cache.raw_data = [matching, mismatched]
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "anime-style.safetensors"},
recipe_base_model="SD 1.5",
)
# A confident base-model mismatch is a hard rejection — reconnect itself
# enforces that rule, so suggesting the mismatch would guarantee failure.
assert [s["file_path"] for s in suggestions] == [matching["file_path"]]
assert suggestions[0]["target_name"] == "anime-style"
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_base_model_unknown_stays_eligible(recipe_scanner):
scanner, stub = recipe_scanner
unknown_item = _suggestion_item(
file_name="anime-style.safetensors",
file_path="/models/loras/anime-style.safetensors",
model_name="Anime Style",
base_model="",
)
stub._cache.raw_data = [unknown_item]
# Unknown base model on the item side must not be rejected — reconnect
# accepts it too (find_matching_models lenient guard).
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "anime-style.safetensors"},
recipe_base_model="SD 1.5",
)
assert [s["file_path"] for s in suggestions] == [unknown_item["file_path"]]
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_same_hash_mismatched_base_model_excluded(
recipe_scanner,
):
scanner, stub = recipe_scanner
stub.cache_version = 1
mismatched = _suggestion_item(
file_name="zzz-unrelated.safetensors",
file_path="/models/loras/zzz-unrelated.safetensors",
model_name="Unrelated",
base_model="SDXL 1.0",
)
stub._cache.raw_data = [mismatched]
# Even the strongest identity signal (same hash) must not surface a
# candidate that reconnect would reject on base-model grounds.
suggestions = await scanner.suggest_reconnect_candidates(
entry={"hash": "ab" * 32, "file_name": "other.safetensors"},
recipe_base_model="SD 1.5",
)
assert suggestions == []
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_basename_collision_uses_folder_path(recipe_scanner):
scanner, stub = recipe_scanner
first = _suggestion_item(
file_name="anime-style.safetensors",
file_path="/models/loras/anime-style.safetensors",
model_name="Anime Style",
base_model="SD 1.5",
)
second = _suggestion_item(
sha256="cd" * 32,
file_name="anime-style.safetensors",
file_path="/models/loras/sd15/anime-style.safetensors",
folder="sd15",
model_name="Anime Style v2",
base_model="SD 1.5",
)
stub._cache.raw_data = [first, second]
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "anime-style.safetensors"},
recipe_base_model="SD 1.5",
)
# Duplicate basenames disambiguate target_name with the folder path.
assert {s["target_name"] for s in suggestions} == {"anime-style", "sd15/anime-style"}
scanner, stub = recipe_scanner
checkpoint = _suggestion_item(sub_type="checkpoint")
lora = _suggestion_item(
sha256="cd" * 32,
file_path="/models/loras/other/style.safetensors",
folder="other",
)
stub._cache.raw_data = [checkpoint, lora]
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "style.safetensors"},
recipe_base_model=None,
)
assert all(s["file_path"] != checkpoint["file_path"] for s in suggestions)
assert any(s["file_path"] == lora["file_path"] for s in suggestions)
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_respects_limit(recipe_scanner):
scanner, stub = recipe_scanner
stub._cache.raw_data = [
_suggestion_item(
sha256=f"{i:064x}",
file_name=f"anime-style-{i}.safetensors",
file_path=f"/models/loras/anime-style-{i}.safetensors",
model_name=f"Anime Style {i}",
)
for i in range(10)
]
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "anime-style.safetensors"},
recipe_base_model="SD 1.5",
limit=3,
)
assert len(suggestions) == 3
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_query_substring(recipe_scanner):
scanner, stub = recipe_scanner
item = _suggestion_item(
file_name="anime-style.safetensors",
file_path="/models/loras/anime-style.safetensors",
model_name="Anime Style",
)
stub._cache.raw_data = [item]
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "unrelated.safetensors"},
recipe_base_model="SD 1.5",
query="anime",
)
assert len(suggestions) == 1
assert suggestions[0]["match_reason"] == "similar_filename"
# Substring hits floor the ratio at 0.8: 0.5 + 0.4 * 0.8 + 0.1 base boost.
assert suggestions[0]["score"] == 0.92
assert suggestions[0]["target_name"] == "anime-style"
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_skips_items_without_hash(recipe_scanner):
scanner, stub = recipe_scanner
no_hash = _suggestion_item(sha256="")
stub._cache.raw_data = [no_hash]
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "style.safetensors"},
recipe_base_model="SD 1.5",
)
assert suggestions == []
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_short_query_no_substring_floor(recipe_scanner):
scanner, stub = recipe_scanner
item = _suggestion_item(
file_name="anime-style.safetensors",
file_path="/models/loras/anime-style.safetensors",
model_name="Anime Style",
)
stub._cache.raw_data = [item]
# A 1-2 character query is a substring of nearly everything; it must NOT
# floor the ratio, otherwise every library item surfaces as a suggestion.
suggestions = await scanner.suggest_reconnect_candidates(
entry={"file_name": "unrelated.safetensors"},
recipe_base_model="SD 1.5",
query="a",
)
assert suggestions == []
@pytest.mark.asyncio
async def test_suggest_reconnect_candidates_name_threshold_filters_generic_overlap(recipe_scanner):
scanner, stub = recipe_scanner
item = _suggestion_item(
file_name="not-artists-styles-pony.safetensors",
file_path="/models/loras/not-artists-styles-pony.safetensors",
model_name="Not Artists Styles for Pony Diffusion V6 XL",
)
stub._cache.raw_data = [item]
# Long names sharing generic tokens ("style", "pony", "diffusion") score
# ~0.638 — below the name-similarity threshold, so unrelated models stay
# out of the suggestions.
suggestions = await scanner.suggest_reconnect_candidates(
entry={"modelName": "Concept Art Twilight Style SDXL_LoRA_Pony Diffusion"},
recipe_base_model="Pony",
)
assert suggestions == []
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch): def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None RecipeScanner._instance = None
settings_manager_module.reset_settings_manager() settings_manager_module.reset_settings_manager()
@@ -331,6 +611,120 @@ async def test_update_lora_entry_updates_cache_and_file(tmp_path: Path, recipe_s
assert cached_recipe["fingerprint"] == expected_fingerprint assert cached_recipe["fingerprint"] == expected_fingerprint
async def test_update_lora_entry_snapshots_previous_state(tmp_path: Path, recipe_scanner):
scanner, stub = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe_id = "recipe-snapshot"
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
original_entry = {
"file_name": "old",
"strength": 1.0,
"hash": "",
"isDeleted": True,
"exclude": True,
}
recipe_data = {
"id": recipe_id,
"file_path": str(tmp_path / "image.png"),
"title": "Original",
"modified": 0.0,
"created_date": 0.0,
"loras": [dict(original_entry)],
}
recipe_path.write_text(json.dumps(recipe_data))
await scanner.add_recipe(dict(recipe_data))
target_info = {
"sha256": "abc123",
"file_path": str(tmp_path / "loras" / "target.safetensors"),
"preview_url": "preview.png",
"civitai": {"id": 42, "name": "v1", "model": {"name": "Target"}},
}
stub.register_model("target", target_info)
await scanner.update_lora_entry(
recipe_id, 0, target_name="target", target_lora=target_info
)
with recipe_path.open("r", encoding="utf-8") as file_obj:
persisted = json.load(file_obj)
snapshot = persisted["loras"][0]["reconnectSnapshot"]
assert snapshot == original_entry
# Snapshots never nest
assert "reconnectSnapshot" not in snapshot
async def test_restore_lora_entry_round_trip(tmp_path: Path, recipe_scanner):
scanner, stub = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe_id = "recipe-restore"
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
original_entry = {
"file_name": "old",
"strength": 1.0,
"hash": "",
"isDeleted": True,
"exclude": True,
}
recipe_data = {
"id": recipe_id,
"file_path": str(tmp_path / "image.png"),
"title": "Original",
"modified": 0.0,
"created_date": 0.0,
"loras": [dict(original_entry)],
}
recipe_path.write_text(json.dumps(recipe_data))
await scanner.add_recipe(dict(recipe_data))
target_info = {
"sha256": "abc123",
"file_path": str(tmp_path / "loras" / "target.safetensors"),
"preview_url": "preview.png",
"civitai": {"id": 42, "name": "v1", "model": {"name": "Target"}},
}
stub.register_model("target", target_info)
await scanner.update_lora_entry(
recipe_id, 0, target_name="target", target_lora=target_info
)
restored_recipe, restored_lora = await scanner.restore_lora_entry(recipe_id, 0)
entry = restored_recipe["loras"][0]
assert entry == original_entry
assert "reconnectSnapshot" not in entry
assert restored_lora["isDeleted"] is True
assert restored_lora["inLibrary"] is False
assert restored_recipe["fingerprint"] == calculate_recipe_fingerprint([original_entry])
with recipe_path.open("r", encoding="utf-8") as file_obj:
persisted = json.load(file_obj)
assert persisted["loras"][0] == original_entry
assert persisted["fingerprint"] == restored_recipe["fingerprint"]
async def test_restore_lora_entry_without_snapshot_rejected(tmp_path: Path, recipe_scanner):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe_id = "recipe-no-snapshot"
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
recipe_path.write_text(
json.dumps({"id": recipe_id, "loras": [{"file_name": "plain"}]})
)
with pytest.raises(RecipeValidationError):
await scanner.restore_lora_entry(recipe_id, 0)
async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_scanner): async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_scanner):
scanner, _ = recipe_scanner scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes" recipes_dir = Path(config.loras_roots[0]) / "recipes"
+183
View File
@@ -1315,6 +1315,189 @@ async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp
) )
@pytest.mark.asyncio
async def test_reconnect_lora_family_compatible_succeeds_with_warning(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
pony_item = {
"file_name": "style.safetensors",
"folder": "",
"file_path": "/models/loras/style.safetensors",
"base_model": "Pony",
"sha256": "ab" * 32,
}
recipe_path = tmp_path / "recipe.json"
recipe_path.write_text(
json.dumps({"id": "r1", "base_model": "Illustrious", "loras": [{}]})
)
class DummyScanner:
async def get_recipe_json_path(self, recipe_id):
return str(recipe_path)
async def find_local_loras_by_name(self, name, base_model=None):
return [pony_item]
async def update_lora_entry(self, recipe_id, lora_index, *, target_name, target_lora):
assert target_lora is pony_item
return ({"id": "r1"}, {"file_name": target_lora["file_name"]})
async def find_recipes_by_fingerprint(self, fingerprint):
return []
result = await service.reconnect_lora(
recipe_scanner=DummyScanner(), recipe_id="r1", lora_index=0, target_name="style"
)
assert result.payload["success"] is True
assert result.payload["base_model_mismatch"] == {
"recipe_base_model": "Illustrious",
"lora_base_model": "Pony",
}
@pytest.mark.asyncio
async def test_reconnect_lora_exact_base_model_has_no_warning(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
item = {
"file_name": "style.safetensors",
"folder": "",
"file_path": "/models/loras/style.safetensors",
"base_model": "SDXL 1.0",
"sha256": "ab" * 32,
}
recipe_path = tmp_path / "recipe.json"
recipe_path.write_text(
json.dumps({"id": "r1", "base_model": "SDXL 1.0", "loras": [{}]})
)
class DummyScanner:
async def get_recipe_json_path(self, recipe_id):
return str(recipe_path)
async def find_local_loras_by_name(self, name, base_model=None):
return [item]
async def update_lora_entry(self, recipe_id, lora_index, *, target_name, target_lora):
return ({"id": "r1"}, {"file_name": target_lora["file_name"]})
async def find_recipes_by_fingerprint(self, fingerprint):
return []
result = await service.reconnect_lora(
recipe_scanner=DummyScanner(), recipe_id="r1", lora_index=0, target_name="style"
)
assert result.payload["success"] is True
assert "base_model_mismatch" not in result.payload
@pytest.mark.asyncio
async def test_get_reconnect_suggestions_loads_entry_and_delegates(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
recipe_path = tmp_path / "recipe.json"
recipe_path.write_text(
json.dumps(
{
"id": "r1",
"base_model": "SD 1.5",
"loras": [
{"file_name": "a.safetensors", "hash": "aaa"},
{"file_name": "b.safetensors", "hash": "bbb", "isDeleted": True},
],
}
)
)
class DummyScanner:
def __init__(self):
self.calls = []
async def get_recipe_json_path(self, recipe_id):
assert recipe_id == "r1"
return str(recipe_path)
async def suggest_reconnect_candidates(
self, *, entry, recipe_base_model, query=None, limit=5
):
self.calls.append(
{
"entry": entry,
"recipe_base_model": recipe_base_model,
"query": query,
}
)
return [
{
"file_name": "b.safetensors",
"score": 1.0,
"match_reason": "same_hash",
"target_name": "b",
}
]
scanner = DummyScanner()
result = await service.get_reconnect_suggestions(
recipe_scanner=scanner, recipe_id="r1", lora_index=1, query="b"
)
assert result.payload["success"] is True
assert result.payload["suggestions"][0]["target_name"] == "b"
assert scanner.calls == [
{
"entry": {"file_name": "b.safetensors", "hash": "bbb", "isDeleted": True},
"recipe_base_model": "SD 1.5",
"query": "b",
}
]
@pytest.mark.asyncio
async def test_get_reconnect_suggestions_validates_recipe_and_index(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
class MissingScanner:
async def get_recipe_json_path(self, recipe_id):
return str(tmp_path / "missing.json")
with pytest.raises(RecipeNotFoundError):
await service.get_reconnect_suggestions(
recipe_scanner=MissingScanner(), recipe_id="nope", lora_index=0
)
recipe_path = tmp_path / "recipe.json"
recipe_path.write_text(json.dumps({"id": "r1", "loras": []}))
class EmptyScanner:
async def get_recipe_json_path(self, recipe_id):
return str(recipe_path)
with pytest.raises(RecipeValidationError, match="lora_index"):
await service.get_reconnect_suggestions(
recipe_scanner=EmptyScanner(), recipe_id="r1", lora_index=0
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mark_lora_hash_invalid_delegates_and_reports(tmp_path): async def test_mark_lora_hash_invalid_delegates_and_reports(tmp_path):
service = RecipePersistenceService( service = RecipePersistenceService(
+53
View File
@@ -0,0 +1,53 @@
"""Unit tests for base-model architecture-family relations."""
from py.utils.base_model import (
RELATION_COMPATIBLE,
RELATION_INCOMPATIBLE,
RELATION_SAME,
RELATION_UNKNOWN,
base_model_relation,
)
def test_identical_labels_are_same():
assert base_model_relation("SDXL 1.0", "sdxl 1.0") == RELATION_SAME
assert base_model_relation(" Pony ", "pony") == RELATION_SAME
def test_sdxl_lineage_is_compatible():
assert base_model_relation("Pony", "Illustrious") == RELATION_COMPATIBLE
assert base_model_relation("Illustrious", "SDXL 1.0") == RELATION_COMPATIBLE
assert base_model_relation("NoobAI", "SDXL Lightning") == RELATION_COMPATIBLE
def test_sd1_lineage_is_compatible():
assert base_model_relation("SD 1.5", "SD 1.4") == RELATION_COMPATIBLE
assert base_model_relation("SD 1.5 LCM", "SD 1.5") == RELATION_COMPATIBLE
def test_flux1_lineage_is_compatible():
assert base_model_relation("Flux.1 D", "Flux.1 S") == RELATION_COMPATIBLE
def test_cross_architecture_is_incompatible():
assert base_model_relation("SD 1.5", "SDXL 1.0") == RELATION_INCOMPATIBLE
assert base_model_relation("Pony", "Flux.1 D") == RELATION_INCOMPATIBLE
def test_pony_v7_is_not_sdxl_compatible():
# Pony V7 is AuraFlow-based; sharing a name prefix with Pony means nothing.
assert base_model_relation("Pony", "Pony V7") == RELATION_INCOMPATIBLE
def test_unknown_labels_stay_unknown():
assert base_model_relation("", "SDXL 1.0") == RELATION_UNKNOWN
assert base_model_relation("SDXL 1.0", "unknown") == RELATION_UNKNOWN
assert base_model_relation(None, None) == RELATION_UNKNOWN
def test_unlisted_labels_fall_back_to_strict():
# A label missing from the family table only matches itself exactly —
# unknown new CivitAI labels must never be wrongly waved through.
assert base_model_relation("Wan Video", "Wan Video") == RELATION_SAME
assert base_model_relation("Wan Video", "Hunyuan Video") == RELATION_INCOMPATIBLE
assert base_model_relation("Wan Video", "Pony") == RELATION_INCOMPATIBLE