mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(recipes): reconnect suggestions, undo, and base-model family tolerance
Enhance the deleted-LoRA reconnect flow in the recipe modal: - Suggest local reconnect candidates when the panel opens, ranked by identity (same hash / same CivitAI version) then filename/name similarity, with a hard filter on confident base-model mismatches; the input gets a Combobox backed by the same endpoint as you type. - Snapshot the pre-reconnect entry and offer a permanent restore: reconnected entries show an undo icon at the right end of the info row, with the original filename in the tooltip. - Relax the manual reconnect base-model guard to a three-tier check: exact/unknown labels pass silently, same-architecture families (e.g. Pony <-> Illustrious) pass with a warning toast, and only cross-architecture mismatches stay hard-rejected.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "Geben Sie die LoRA-Syntax oder den Namen zum Neuverknüpfen ein:",
|
||||
"reconnectExample": "Beispiel: <lora:name:1> 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",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "Enter LoRA syntax or name to reconnect:",
|
||||
"reconnectExample": "Example: <lora:name:1> 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",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "Introduce la sintaxis o el nombre del LoRA para reconectar:",
|
||||
"reconnectExample": "Ejemplo: <lora:name:1> 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",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "Entrez la syntaxe ou le nom du LoRA à reconnecter:",
|
||||
"reconnectExample": "Exemple: <lora:name:1> 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",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "הזן תחביר או שם של LoRA לחיבור מחדש:",
|
||||
"reconnectExample": "דוגמה: <lora:name:1> או רק את השם",
|
||||
"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 נכשלה",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "再接続する LoRA の構文または名前を入力してください:",
|
||||
"reconnectExample": "例:<lora:name:1> または名前のみ",
|
||||
"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": "レシピのワークフローへの送信に失敗しました",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "다시 연결할 LoRA 구문 또는 이름을 입력하세요:",
|
||||
"reconnectExample": "예:<lora:name:1> 또는 이름만 입력",
|
||||
"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": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "Введите синтаксис или имя LoRA для переподключения:",
|
||||
"reconnectExample": "Пример: <lora:name:1> или просто имя",
|
||||
"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",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "输入 LoRA 语法或名称以重新关联:",
|
||||
"reconnectExample": "示例:<lora:name:1> 或只填名称",
|
||||
"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": "发送配方到工作流失败",
|
||||
|
||||
@@ -906,6 +906,15 @@
|
||||
"reconnectInstructions": "輸入 LoRA 語法或名稱以重新關聯:",
|
||||
"reconnectExample": "範例:<lora:name:1> 或只填名稱",
|
||||
"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": "傳送配方到工作流失敗",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -28,8 +28,14 @@ export class Combobox {
|
||||
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
|
||||
* @param {(inputValue: string) => Promise<string[]>} [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();
|
||||
|
||||
@@ -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 = `
|
||||
<button type="button" class="lora-undo-reconnect" data-lora-index="${loraIndex}"
|
||||
title="${escapeHtml(undoTooltip)}" aria-label="${escapeHtml(undoTooltip)}">
|
||||
<i class="fas fa-rotate-left" aria-hidden="true"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${loraItemClass}" data-lora-index="${loraIndex}"${rowA11yAttributes}>
|
||||
<div class="recipe-lora-thumbnail">
|
||||
@@ -958,24 +984,26 @@ class RecipeModal {
|
||||
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||
${undoReconnectIcon}
|
||||
</div>
|
||||
${actionsRow}
|
||||
${isDeleted || lora.hashInvalid ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="reconnect-error" role="alert"></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
${isDeleted || lora.hashInvalid ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
<small>${escapeHtml(translate('recipes.resources.reconnectExample', {}, 'Example: <lora:name:1> or just the name'))}</small>
|
||||
</div>
|
||||
<div class="reconnect-form">
|
||||
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.reconnectPlaceholder', {}, 'Enter LoRA name or syntax'))}">
|
||||
<div class="reconnect-actions">
|
||||
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
|
||||
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-suggestions"></div>
|
||||
<p class="reconnect-error" role="alert"></p>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -988,6 +1016,7 @@ class RecipeModal {
|
||||
|
||||
this.recipeLorasSyntax = '';
|
||||
} else if (lorasListElement) {
|
||||
this._destroyAllReconnectComboboxes();
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
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 = `<div class="reconnect-suggestions-loading">${escapeHtml(translate('recipes.resources.reconnectSuggestionsLoading', {}, 'Searching local library...'))}</div>`;
|
||||
try {
|
||||
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
|
||||
// Stale guard: panel closed or another item opened while fetching
|
||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
this._renderReconnectSuggestions(container, suggestions, loraIndex);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reconnect suggestions:', error);
|
||||
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
this._renderReconnectSuggestions(container, [], loraIndex);
|
||||
}
|
||||
}
|
||||
|
||||
_renderReconnectSuggestions(container, suggestions, loraIndex) {
|
||||
const listElement = container.querySelector('.reconnect-suggestions');
|
||||
if (!listElement) {
|
||||
return;
|
||||
}
|
||||
listElement.innerHTML = '';
|
||||
if (!suggestions.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'reconnect-suggestions-empty';
|
||||
empty.textContent = translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
|
||||
listElement.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const reasonLabels = {
|
||||
same_hash: translate('recipes.resources.reconnectMatchSameHash', {}, 'Same hash'),
|
||||
same_version: translate('recipes.resources.reconnectMatchSameVersion', {}, 'Same model version'),
|
||||
similar_filename: translate('recipes.resources.reconnectMatchSimilarFilename', {}, 'Similar filename'),
|
||||
similar_name: translate('recipes.resources.reconnectMatchSimilarName', {}, 'Similar name'),
|
||||
};
|
||||
suggestions.forEach(suggestion => {
|
||||
// The filename (stem) is what the match scored on and what gets
|
||||
// submitted — show it as the primary label, with the base model
|
||||
// as secondary context. The model name is omitted: it played no
|
||||
// part in the match and only adds noise.
|
||||
const stem = suggestion.target_name || suggestion.file_name || '';
|
||||
const secondaryParts = [];
|
||||
if (suggestion.base_model) {
|
||||
secondaryParts.push(suggestion.base_model);
|
||||
}
|
||||
const secondary = secondaryParts.join(' · ');
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'reconnect-suggestion';
|
||||
row.title = stem;
|
||||
row.innerHTML = `
|
||||
<img class="reconnect-suggestion-preview" src="${escapeHtml(suggestion.preview_url || '/loras_static/images/no-preview.png')}" alt="" loading="lazy" onerror="this.src='/loras_static/images/no-preview.png'">
|
||||
<span class="reconnect-suggestion-info">
|
||||
<span class="reconnect-suggestion-name">${escapeHtml(stem)}</span>
|
||||
${secondary ? `<span class="reconnect-suggestion-secondary">${escapeHtml(secondary)}</span>` : ''}
|
||||
</span>
|
||||
<span class="reconnect-suggestion-reason">${escapeHtml(reasonLabels[suggestion.match_reason] || suggestion.match_reason || '')}</span>
|
||||
`;
|
||||
row.addEventListener('click', () => {
|
||||
this.reconnectLora(loraIndex, suggestion.target_name);
|
||||
});
|
||||
listElement.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
showReconnectError(container, message) {
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user