From 838a374a56e67839a1cb7b90ee55c5c8d864806c Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sun, 30 Aug 2026 08:17:35 +0800 Subject: [PATCH] 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. --- docs/architecture/recipe_routes.md | 2 +- locales/de.json | 12 + locales/en.json | 12 + locales/es.json | 12 + locales/fr.json | 12 + locales/he.json | 12 + locales/ja.json | 12 + locales/ko.json | 12 + locales/ru.json | 12 + locales/zh-CN.json | 12 + locales/zh-TW.json | 12 + py/routes/handlers/recipe_handlers.py | 61 +++ py/routes/recipe_route_registrar.py | 6 + py/services/recipe_scanner.py | 255 ++++++++++++ py/services/recipes/persistence_service.py | 106 ++++- py/utils/base_model.py | 79 ++++ static/css/components/recipe-modal.css | 119 +++++- static/js/components/Combobox.js | 48 ++- static/js/components/RecipeModal.js | 275 +++++++++++- .../recipeModal.resourceItems.test.js | 237 +++++++++++ tests/services/test_recipe_scanner.py | 394 ++++++++++++++++++ tests/services/test_recipe_services.py | 183 ++++++++ tests/utils/test_base_model.py | 53 +++ 23 files changed, 1893 insertions(+), 45 deletions(-) create mode 100644 py/utils/base_model.py create mode 100644 tests/utils/test_base_model.py diff --git a/docs/architecture/recipe_routes.md b/docs/architecture/recipe_routes.md index 0bdb7c90..4e7d9540 100644 --- a/docs/architecture/recipe_routes.md +++ b/docs/architecture/recipe_routes.md @@ -54,7 +54,7 @@ The dedicated services encapsulate long-running work so handlers stay thin. | 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. | -| `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`. | ## Maintaining critical invariants diff --git a/locales/de.json b/locales/de.json index 327dc004..df6d968c 100644 --- a/locales/de.json +++ b/locales/de.json @@ -906,6 +906,15 @@ "reconnectInstructions": "Geben Sie die LoRA-Syntax oder den Namen zum Neuverknüpfen ein:", "reconnectExample": "Beispiel: oder nur der Name", "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", "openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen", "openCheckpointDetails": "{name} in der Modellbibliothek anzeigen" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download", "enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein", "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}", + "loraRestored": "LoRA auf die vorherige Verknüpfung zurückgesetzt", + "loraRestoreFailed": "Fehler beim Wiederherstellen des LoRA: {message}", "noPromptToSend": "Kein zu sendender Prompt", "cannotSend": "Kann Rezept nicht senden: Fehlende Rezept-ID", "sendFailed": "Fehler beim Senden des Rezepts an Workflow", diff --git a/locales/en.json b/locales/en.json index 921ff549..6b6d93c4 100644 --- a/locales/en.json +++ b/locales/en.json @@ -906,6 +906,15 @@ "reconnectInstructions": "Enter LoRA syntax or name to reconnect:", "reconnectExample": "Example: or just the name", "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", "openLoraDetails": "View {name} in the LoRA library", "openCheckpointDetails": "View {name} in the model library" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "Error preparing LoRAs for download", "enterLoraName": "Please enter a LoRA name or syntax", "reconnectedSuccessfully": "LoRA reconnected successfully", + "reconnectBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, LoRA: {lora}) — they are architecture-compatible", "reconnectFailed": "Error reconnecting LoRA: {message}", + "loraRestored": "LoRA restored to its previous association", + "loraRestoreFailed": "Error restoring LoRA: {message}", "noPromptToSend": "No prompt to send", "cannotSend": "Cannot send recipe: Missing recipe ID", "sendFailed": "Failed to send recipe to workflow", diff --git a/locales/es.json b/locales/es.json index fb4b18d7..ce5660cb 100644 --- a/locales/es.json +++ b/locales/es.json @@ -906,6 +906,15 @@ "reconnectInstructions": "Introduce la sintaxis o el nombre del LoRA para reconectar:", "reconnectExample": "Ejemplo: o solo el nombre", "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", "openLoraDetails": "Ver {name} en la biblioteca de LoRAs", "openCheckpointDetails": "Ver {name} en la biblioteca de modelos" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "Error preparando LoRAs para descarga", "enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis", "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}", + "loraRestored": "LoRA restaurado a su asociación anterior", + "loraRestoreFailed": "Error restaurando LoRA: {message}", "noPromptToSend": "No hay prompt para enviar", "cannotSend": "No se puede enviar receta: Falta ID de receta", "sendFailed": "Error al enviar receta al workflow", diff --git a/locales/fr.json b/locales/fr.json index ea5c9d93..c36c19f6 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -906,6 +906,15 @@ "reconnectInstructions": "Entrez la syntaxe ou le nom du LoRA à reconnecter:", "reconnectExample": "Exemple: ou simplement le nom", "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", "openLoraDetails": "Voir {name} dans la bibliothèque LoRA", "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", "enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA", "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}", + "loraRestored": "LoRA restauré à son association précédente", + "loraRestoreFailed": "Erreur lors de la restauration du LoRA : {message}", "noPromptToSend": "Aucun prompt à envoyer", "cannotSend": "Impossible d'envoyer la recipe : ID de recipe manquant", "sendFailed": "Échec de l'envoi de la recipe vers le workflow", diff --git a/locales/he.json b/locales/he.json index df1ef340..e7575988 100644 --- a/locales/he.json +++ b/locales/he.json @@ -906,6 +906,15 @@ "reconnectInstructions": "הזן תחביר או שם של LoRA לחיבור מחדש:", "reconnectExample": "דוגמה: או רק את השם", "reconnectPlaceholder": "הזן שם או תחביר של LoRA", + "reconnectSuggestionsLoading": "מחפש בספרייה המקומית...", + "reconnectSuggestionsEmpty": "לא נמצאו LoRAs תואמים בספרייה המקומית שלך", + "reconnectMatchSameHash": "אותו hash", + "reconnectMatchSameVersion": "אותה גרסת מודל", + "reconnectMatchSimilarFilename": "שם קובץ דומה", + "reconnectMatchSimilarName": "שם דומה", + "undoReconnect": "בטל", + "undoReconnectTooltip": "שחזר את השיוך שהיה לרשומה זו לפני החיבור מחדש", + "undoReconnectTooltipNamed": "שחזר ל-{name} (השיוך לפני החיבור מחדש)", "viewOnCivitai": "הצג ב-CivitAI", "openLoraDetails": "הצג את {name} בספריית ה-LoRA", "openCheckpointDetails": "הצג את {name} בספריית המודלים" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה", "enterLoraName": "אנא הזן שם LoRA או תחביר", "reconnectedSuccessfully": "LoRA קושר מחדש בהצלחה", + "reconnectBaseModelMismatch": "הקישור מחדש הצליח, אך מודלי הבסיס שונים (מתכון: {recipe}, LoRA: {lora}) — הם תואמים מבחינת הארכיטקטורה", "reconnectFailed": "שגיאה בקישור מחדש של LoRA: {message}", + "loraRestored": "LoRA שוחזר לשיוך הקודם", + "loraRestoreFailed": "שגיאה בשחזור LoRA: {message}", "noPromptToSend": "אין פרומפט לשליחה", "cannotSend": "לא ניתן לשלוח מתכון: חסר מזהה מתכון", "sendFailed": "שליחת המתכון ל-workflow נכשלה", diff --git a/locales/ja.json b/locales/ja.json index d6b46b0a..f997d675 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -906,6 +906,15 @@ "reconnectInstructions": "再接続する LoRA の構文または名前を入力してください:", "reconnectExample": "例: または名前のみ", "reconnectPlaceholder": "LoRA 名または構文を入力", + "reconnectSuggestionsLoading": "ローカルライブラリを検索中...", + "reconnectSuggestionsEmpty": "ローカルライブラリに一致するLoRAがありません", + "reconnectMatchSameHash": "同じハッシュ", + "reconnectMatchSameVersion": "同じモデルバージョン", + "reconnectMatchSimilarFilename": "類似のファイル名", + "reconnectMatchSimilarName": "類似の名前", + "undoReconnect": "元に戻す", + "undoReconnectTooltip": "このエントリーを再接続前の関連付けに戻します", + "undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)", "viewOnCivitai": "CivitAI で表示", "openLoraDetails": "LoRA ライブラリで {name} を表示", "openCheckpointDetails": "モデルライブラリで {name} を表示" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました", "enterLoraName": "LoRA名または構文を入力してください", "reconnectedSuccessfully": "LoRAが正常に再接続されました", + "reconnectBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、LoRA:{lora})— アーキテクチャ互換です", "reconnectFailed": "LoRA再接続エラー:{message}", + "loraRestored": "LoRAが以前の関連付けに復元されました", + "loraRestoreFailed": "LoRA復元エラー:{message}", "noPromptToSend": "送信するプロンプトがありません", "cannotSend": "レシピを送信できません:レシピIDがありません", "sendFailed": "レシピのワークフローへの送信に失敗しました", diff --git a/locales/ko.json b/locales/ko.json index 1e8ddb2c..5be6b09f 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -906,6 +906,15 @@ "reconnectInstructions": "다시 연결할 LoRA 구문 또는 이름을 입력하세요:", "reconnectExample": "예: 또는 이름만 입력", "reconnectPlaceholder": "LoRA 이름 또는 구문 입력", + "reconnectSuggestionsLoading": "로컬 라이브러리 검색 중...", + "reconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 LoRA가 없습니다", + "reconnectMatchSameHash": "동일한 해시", + "reconnectMatchSameVersion": "동일한 모델 버전", + "reconnectMatchSimilarFilename": "유사한 파일 이름", + "reconnectMatchSimilarName": "유사한 이름", + "undoReconnect": "실행 취소", + "undoReconnectTooltip": "이 항목을 다시 연결 전의 연결 상태로 복원", + "undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}", "viewOnCivitai": "CivitAI에서 보기", "openLoraDetails": "LoRA 라이브러리에서 {name} 보기", "openCheckpointDetails": "모델 라이브러리에서 {name} 보기" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "LoRA 다운로드 준비 오류", "enterLoraName": "LoRA 이름 또는 문법을 입력해주세요", "reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다", + "reconnectBaseModelMismatch": "다시 연결했지만 베이스 모델이 다릅니다(레시피: {recipe}, LoRA: {lora}) — 아키텍처 호환입니다", "reconnectFailed": "LoRA 다시 연결 오류: {message}", + "loraRestored": "LoRA가 이전 연결 상태로 복원되었습니다", + "loraRestoreFailed": "LoRA 복원 오류: {message}", "noPromptToSend": "보낼 프롬프트가 없습니다", "cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락", "sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다", diff --git a/locales/ru.json b/locales/ru.json index b3036449..e8c66e7b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -906,6 +906,15 @@ "reconnectInstructions": "Введите синтаксис или имя LoRA для переподключения:", "reconnectExample": "Пример: или просто имя", "reconnectPlaceholder": "Введите имя или синтаксис LoRA", + "reconnectSuggestionsLoading": "Поиск в локальной библиотеке...", + "reconnectSuggestionsEmpty": "В локальной библиотеке нет подходящих LoRA", + "reconnectMatchSameHash": "Тот же хеш", + "reconnectMatchSameVersion": "Та же версия модели", + "reconnectMatchSimilarFilename": "Похожее имя файла", + "reconnectMatchSimilarName": "Похожее имя", + "undoReconnect": "Отменить", + "undoReconnectTooltip": "Восстановить привязку, которая была у записи до переподключения", + "undoReconnectTooltipNamed": "Восстановить {name} (привязка до переподключения)", "viewOnCivitai": "Открыть на CivitAI", "openLoraDetails": "Открыть {name} в библиотеке LoRA", "openCheckpointDetails": "Открыть {name} в библиотеке моделей" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки", "enterLoraName": "Пожалуйста, введите название LoRA или синтаксис", "reconnectedSuccessfully": "LoRA успешно переподключена", + "reconnectBaseModelMismatch": "Переподключение выполнено, но базовые модели различаются (рецепт: {recipe}, LoRA: {lora}) — они совместимы по архитектуре", "reconnectFailed": "Ошибка переподключения LoRA: {message}", + "loraRestored": "LoRA восстановлена к прежней привязке", + "loraRestoreFailed": "Ошибка восстановления LoRA: {message}", "noPromptToSend": "Нет промпта для отправки", "cannotSend": "Невозможно отправить рецепт: отсутствует ID рецепта", "sendFailed": "Не удалось отправить рецепт в workflow", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 557f6725..95c9ff2b 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -906,6 +906,15 @@ "reconnectInstructions": "输入 LoRA 语法或名称以重新关联:", "reconnectExample": "示例: 或只填名称", "reconnectPlaceholder": "输入 LoRA 名称或语法", + "reconnectSuggestionsLoading": "正在搜索本地库...", + "reconnectSuggestionsEmpty": "本地库中没有匹配的 LoRA", + "reconnectMatchSameHash": "相同哈希", + "reconnectMatchSameVersion": "相同模型版本", + "reconnectMatchSimilarFilename": "相似文件名", + "reconnectMatchSimilarName": "相似名称", + "undoReconnect": "撤销", + "undoReconnectTooltip": "恢复此条目在重新关联前的关联", + "undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)", "viewOnCivitai": "在 CivitAI 上查看", "openLoraDetails": "在 LoRA 库中查看 {name}", "openCheckpointDetails": "在模型库中查看 {name}" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "准备下载 LoRA 时出错", "enterLoraName": "请输入 LoRA 名称或语法", "reconnectedSuccessfully": "LoRA 重新连接成功", + "reconnectBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},LoRA:{lora})——两者架构兼容", "reconnectFailed": "LoRA 重新连接出错:{message}", + "loraRestored": "LoRA 已恢复为重新关联前的关联", + "loraRestoreFailed": "LoRA 恢复出错:{message}", "noPromptToSend": "没有可发送的提示词", "cannotSend": "无法发送配方:缺少配方 ID", "sendFailed": "发送配方到工作流失败", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 6b7338d5..1cbc40f7 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -906,6 +906,15 @@ "reconnectInstructions": "輸入 LoRA 語法或名稱以重新關聯:", "reconnectExample": "範例: 或只填名稱", "reconnectPlaceholder": "輸入 LoRA 名稱或語法", + "reconnectSuggestionsLoading": "正在搜尋本地庫...", + "reconnectSuggestionsEmpty": "本地庫中沒有符合的 LoRA", + "reconnectMatchSameHash": "相同雜湊", + "reconnectMatchSameVersion": "相同模型版本", + "reconnectMatchSimilarFilename": "相似檔案名稱", + "reconnectMatchSimilarName": "相似名稱", + "undoReconnect": "撤銷", + "undoReconnectTooltip": "恢復此條目在重新關聯前的關聯", + "undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)", "viewOnCivitai": "在 CivitAI 上檢視", "openLoraDetails": "在 LoRA 庫中檢視 {name}", "openCheckpointDetails": "在模型庫中檢視 {name}" @@ -2046,7 +2055,10 @@ "preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤", "enterLoraName": "請輸入 LoRA 名稱或語法", "reconnectedSuccessfully": "LoRA 重新連結成功", + "reconnectBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},LoRA:{lora})——兩者架構相容", "reconnectFailed": "LoRA 重新連結錯誤:{message}", + "loraRestored": "LoRA 已恢復為重新關聯前的關聯", + "loraRestoreFailed": "LoRA 恢復錯誤:{message}", "noPromptToSend": "沒有可發送的提示詞", "cannotSend": "無法傳送配方:缺少配方 ID", "sendFailed": "傳送配方到工作流失敗", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 4a25c0e2..35051813 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -113,6 +113,8 @@ class RecipeHandlerSet: "update_recipe": self.management.update_recipe, "record_recipe_open": self.management.record_recipe_open, "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, "find_duplicates": self.query.find_duplicates, "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) 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: try: await self._ensure_dependencies_ready() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 5ed9e27f..20562e88 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -49,6 +49,12 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), 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/restore", "restore_lora"), + RouteDefinition( + "GET", + "/api/lm/recipe/{recipe_id}/lora/{lora_index}/reconnect-suggestions", + "get_reconnect_suggestions", + ), RouteDefinition( "POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid" ), diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 54627e1d..c58dec8c 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -5,6 +5,8 @@ from __future__ import annotations import asyncio +import copy +import difflib import json import logging import os @@ -244,6 +246,188 @@ class RecipeScanner: self._local_filename_cache_versions = versions 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: """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") 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["hashInvalid"] = False lora_entry["exclude"] = False @@ -3695,6 +3886,8 @@ class RecipeScanner: lora_entry["modelVersionName"] = civitai_info.get("name", "") lora_entry["modelVersionId"] = civitai_info.get("id") + lora_entry["reconnectSnapshot"] = snapshot + from ..utils.utils import calculate_recipe_fingerprint recipe_data["fingerprint"] = calculate_recipe_fingerprint( @@ -3730,6 +3923,68 @@ class RecipeScanner: updated_lora = self._enrich_lora_entry(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( self, recipe_id: str, diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 33c3dcbd..fdec3edd 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -13,6 +13,11 @@ from typing import Any, Awaitable, Dict, Iterable, Optional, cast from ...config import config 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 ..pending_delete_service import get_pending_delete_service from .errors import RecipeNotFoundError, RecipeValidationError @@ -430,20 +435,31 @@ class RecipePersistenceService: with open(recipe_path, "r", encoding="utf-8") as file_obj: recipe_base_model = json.load(file_obj).get("base_model", "") - target_lora = await recipe_scanner.get_local_lora(target_name, recipe_base_model) - if not target_lora: - matches = await recipe_scanner.find_local_loras_by_name(target_name) - 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" - ) + matches = await recipe_scanner.find_local_loras_by_name(target_name) + if not matches: raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}") + # Three-tier base-model guard: exact/unknown labels pass silently; + # labels from the same architecture family (e.g. Pony ↔ Illustrious) + # pass but are reported so the UI can warn; confident architecture + # mismatches stay hard-rejected because they can never load. + eligible: list[tuple[dict, str]] = [] + for match in matches: + relation = base_model_relation(recipe_base_model, match.get("base_model")) + if relation != RELATION_INCOMPATIBLE: + eligible.append((match, relation)) + + if not eligible: + raise RecipeValidationError( + f"Local LoRA '{target_name}' has a different base model than the recipe" + ) + if len(eligible) > 1: + raise RecipeValidationError( + 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_id, lora_index, @@ -451,6 +467,43 @@ class RecipePersistenceService: 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") if image_path and os.path.exists(image_path): 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( self, *, diff --git a/py/utils/base_model.py b/py/utils/base_model.py new file mode 100644 index 00000000..ba296645 --- /dev/null +++ b/py/utils/base_model.py @@ -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 diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index 691b0902..871f1e83 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -729,6 +729,9 @@ .recipe-lora-item { display: flex; + /* The reconnect panel is a full-width child that wraps below the + thumbnail + content row. */ + flex-wrap: wrap; gap: var(--space-2); padding: 10px var(--space-2); border: 1px solid var(--border-color); @@ -887,6 +890,29 @@ 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, .missing-badge, .invalid-hash-badge { @@ -968,13 +994,17 @@ /* LoRA reconnect container: an inline extension of the item, not a nested 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 { display: none; 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); padding-top: 10px; - margin-top: 10px; gap: 10px; } @@ -1015,7 +1045,7 @@ border-radius: var(--border-radius-xs); background: var(--bg-color); color: var(--text-color); - font-size: 0.9em; + font-size: 0.95em; } .reconnect-error { @@ -1029,6 +1059,89 @@ 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 { display: flex; justify-content: flex-end; diff --git a/static/js/components/Combobox.js b/static/js/components/Combobox.js index fe013e58..4d485f78 100644 --- a/static/js/components/Combobox.js +++ b/static/js/components/Combobox.js @@ -28,8 +28,14 @@ export class Combobox { * @param {string[]} [options.presets=[]] Static preset values shown in dropdown. * @param {(inputValue: string) => Promise} [options.fetchOptions] * 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.onCommit] Callback when Enter is + * pressed without a highlighted option (free-text commit). */ constructor(inputElement, options = {}) { if (!inputElement || inputElement.tagName !== 'INPUT') { @@ -41,7 +47,9 @@ export class Combobox { this.presets = Array.isArray(options.presets) ? [...options.presets] : []; this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null; this.placeholder = options.placeholder || ''; + this.emptyText = options.emptyText || ''; this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null; + this.onCommit = typeof options.onCommit === 'function' ? options.onCommit : null; // Internal state this._isOpen = false; @@ -109,19 +117,24 @@ export class Combobox { // ---- event wiring ---- _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; this._open(); - }); + }; + this.input.addEventListener('focus', this._focusHandler); - this.input.addEventListener('input', () => { + this._inputHandler = () => { if (this._suppressInputOpen) return; this._open(); // no-op if already open this._refresh(); // re-filter by current input value 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) this.panel.addEventListener('click', (event) => { @@ -167,6 +180,9 @@ export class Combobox { event.preventDefault(); this._open(); this._setActiveIndex(0); + } else if (event.key === 'Enter' && typeof this.onCommit === 'function') { + event.preventDefault(); + this.onCommit(this.input.value); } return; } @@ -184,11 +200,17 @@ export class Combobox { case 'Enter': // Only intercept Enter to pick an option when one is actively - // highlighted; otherwise let the input's default behavior - // (form submit / free-text commit) proceed. + // highlighted; otherwise commit the free-text value (when an + // onCommit handler is registered) and let the input's default + // behavior proceed otherwise. if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) { event.preventDefault(); 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; @@ -254,7 +276,7 @@ export class Combobox { if (items.length === 0) { const empty = document.createElement('div'); 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._activeIndex = -1; return; @@ -333,11 +355,19 @@ export class Combobox { if (this.panel && this.panel.parentNode) { 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); window.removeEventListener('resize', this._resizeHandler); window.removeEventListener('scroll', this._resizeHandler, true); } + /** Whether the dropdown panel is currently open. */ + isOpen() { + return this._isOpen; + } + _choose(value) { this.input.value = value; this._close(); diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index ff7a9bb7..a442be86 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -12,6 +12,7 @@ import { openMediaViewer } from './shared/MediaViewer.js'; import { showRecipeDeleteConfirmation } from './RecipeCard.js'; import { renderCompactTags, setupTagTooltip } from './shared/utils.js'; import { setupTagEditMode } from './shared/ModelTags.js'; +import { Combobox } from './Combobox.js'; const ALLOWED_GEN_PARAM_KEYS = new Set([ 'prompt', @@ -171,7 +172,10 @@ class RecipeModal { reconnectContainers.forEach(container => { if (container.classList.contains('active') && !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); } }); @@ -236,6 +240,7 @@ class RecipeModal { this.navigationKeyHandler = null; } this.navigationInProgress = false; + this._destroyAllReconnectComboboxes(); } setupNavigationShortcuts() { @@ -878,6 +883,9 @@ class RecipeModal { } 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 => { const existsLocally = lora.inLibrary; 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`))}"` : ''; + // 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 = ` + + `; + } + return `
@@ -958,24 +984,26 @@ class RecipeModal { ${lora.modelVersionName ? `
${lora.modelVersionName}
` : ''}
Weight: ${lora.strength || 1.0}
${lora.baseModel ? `
${lora.baseModel}
` : ''} + ${undoReconnectIcon}
${actionsRow} - ${isDeleted || lora.hashInvalid ? ` -
-
-

${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}

- ${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: or just the name'))} -
-
- -
- - -
-
- -
` : ''}
+ ${isDeleted || lora.hashInvalid ? ` +
+
+

${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}

+ ${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: or just the name'))} +
+
+ +
+ + +
+
+
+ +
` : ''} `; }).join(''); @@ -988,6 +1016,7 @@ class RecipeModal { this.recipeLorasSyntax = ''; } else if (lorasListElement) { + this._destroyAllReconnectComboboxes(); lorasListElement.innerHTML = '
No LoRAs associated with this recipe
'; this.recipeLorasSyntax = ''; } @@ -1665,12 +1694,17 @@ class RecipeModal { this.clearReconnectError(input.closest('.lora-reconnect-container')); }); 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') { - const container = input.closest('.lora-reconnect-container'); + if (combobox) return; const loraIndex = container.getAttribute('data-lora-index'); this.reconnectLora(loraIndex, input.value); } else if (e.key === 'Escape') { - const container = input.closest('.lora-reconnect-container'); + if (combobox && combobox.isOpen()) return; this.hideReconnectInput(container); } }); @@ -1680,7 +1714,7 @@ class RecipeModal { showReconnectInput(loraIndex) { // Hide any currently active reconnect containers document.querySelectorAll('.lora-reconnect-container.active').forEach(active => { - active.classList.remove('active'); + this.hideReconnectInput(active); }); // Show the reconnect container for this lora @@ -1690,6 +1724,8 @@ class RecipeModal { this.clearReconnectError(container); const input = container.querySelector('.reconnect-input'); input.focus(); + this._attachReconnectCombobox(container, loraIndex); + this._loadReconnectSuggestions(container, loraIndex); } } @@ -1700,6 +1736,139 @@ class RecipeModal { const input = container.querySelector('.reconnect-input'); 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 = `
${escapeHtml(translate('recipes.resources.reconnectSuggestionsLoading', {}, 'Searching local library...'))}
`; + 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 = ` + + + ${escapeHtml(stem)} + ${secondary ? `${escapeHtml(secondary)}` : ''} + + ${escapeHtml(reasonLabels[suggestion.match_reason] || suggestion.match_reason || '')} + `; + row.addEventListener('click', () => { + this.reconnectLora(loraIndex, suggestion.target_name); + }); + listElement.appendChild(row); + }); } showReconnectError(container, message) { @@ -1761,6 +1930,19 @@ class RecipeModal { // Show success message 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 setTimeout(() => { 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) { const existsLocally = !!checkpoint.inLibrary; const localPath = checkpoint.localPath || ''; @@ -2042,7 +2265,8 @@ class RecipeModal { renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) { // 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) { return ''; } @@ -2113,6 +2337,17 @@ class RecipeModal { 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); + }); + }); } /** diff --git a/tests/frontend/components/recipeModal.resourceItems.test.js b/tests/frontend/components/recipeModal.resourceItems.test.js index d0db3ab7..5cd94250 100644 --- a/tests/frontend/components/recipeModal.resourceItems.test.js +++ b/tests/frontend/components/recipeModal.resourceItems.test.js @@ -539,4 +539,241 @@ describe('RecipeModal resource item interactions', () => { checkpointItem.click(); 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', + }); + }); }); diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index e76c06e3..2af6eb07 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -16,6 +16,7 @@ from py.services.recipe_scanner import RecipeScanner from py.services import settings_manager as settings_manager_module from py.utils.models import BaseModelMetadata from py.utils.utils import calculate_recipe_fingerprint +from py.services.recipes.errors import RecipeValidationError 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] +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): RecipeScanner._instance = None 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 +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): scanner, _ = recipe_scanner recipes_dir = Path(config.loras_roots[0]) / "recipes" diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py index 6675a811..367c116e 100644 --- a/tests/services/test_recipe_services.py +++ b/tests/services/test_recipe_services.py @@ -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 async def test_mark_lora_hash_invalid_delegates_and_reports(tmp_path): service = RecipePersistenceService( diff --git a/tests/utils/test_base_model.py b/tests/utils/test_base_model.py new file mode 100644 index 00000000..8f8cfb30 --- /dev/null +++ b/tests/utils/test_base_model.py @@ -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