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,
+94 -12
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,20 +435,31 @@ 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) matches = await recipe_scanner.find_local_loras_by_name(target_name)
if not target_lora: if not matches:
matches = await recipe_scanner.find_local_loras_by_name(target_name)
if len(matches) > 1:
raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate"
)
if len(matches) == 1:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}") raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
# Three-tier base-model guard: exact/unknown labels pass silently;
# labels from the same architecture family (e.g. Pony ↔ Illustrious)
# pass but are reported so the UI can warn; confident architecture
# mismatches stay hard-rejected because they can never load.
eligible: list[tuple[dict, str]] = []
for match in matches:
relation = base_model_relation(recipe_base_model, match.get("base_model"))
if relation != RELATION_INCOMPATIBLE:
eligible.append((match, relation))
if not eligible:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
if len(eligible) > 1:
raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate"
)
target_lora, target_relation = eligible[0]
recipe_data, updated_lora = await recipe_scanner.update_lora_entry( recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
recipe_id, recipe_id,
lora_index, lora_index,
@@ -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();
+255 -20
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,24 +984,26 @@ 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}
${isDeleted || lora.hashInvalid ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
</div>
<div class="reconnect-form">
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
<div class="reconnect-actions">
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
</div>
</div>
<p class="reconnect-error" role="alert"></p>
</div>` : ''}
</div> </div>
${isDeleted || lora.hashInvalid ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
</div>
<div class="reconnect-form">
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
<div class="reconnect-actions">
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
</div>
</div>
<div class="reconnect-suggestions"></div>
<p class="reconnect-error" role="alert"></p>
</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) => {
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 (e.key === 'Enter') {
const container = input.closest('.lora-reconnect-container'); 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