diff --git a/locales/de.json b/locales/de.json index 08e9fdf9..fbc6d4e2 100644 --- a/locales/de.json +++ b/locales/de.json @@ -948,7 +948,14 @@ "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" + "openCheckpointDetails": "{name} in der Modellbibliothek anzeigen", + "checkpointDeletedTooltip": "Dieser Checkpoint wurde aus der Quelle gelöscht und kann nicht mehr heruntergeladen werden - verknüpfen Sie ihn mit einem lokalen Modell neu", + "checkpointHashInvalidTooltip": "Dieser Checkpoint-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert", + "reconnectCheckpoint": "Neu verknüpfen", + "reconnectCheckpointTooltip": "Mit einem lokalen Checkpoint neu verknüpfen", + "checkpointReconnectInstructions": "Geben Sie den Namen des Checkpoints zum Neuverknüpfen ein:", + "checkpointReconnectPlaceholder": "Name des Checkpoints eingeben", + "checkpointReconnectSuggestionsEmpty": "Keine passenden Checkpoints in Ihrer lokalen Bibliothek" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar", "missingCheckpointInfo": "Checkpoint-Informationen fehlen", "downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}", + "enterCheckpointName": "Bitte geben Sie einen Checkpoint-Namen ein", + "checkpointReconnectedSuccessfully": "Checkpoint erfolgreich neu verbunden", + "reconnectCheckpointBaseModelMismatch": "Neuverbindung erfolgreich, aber die Basismodelle unterscheiden sich (Rezept: {recipe}, Checkpoint: {checkpoint}) — sie sind architekturkompatibel", + "checkpointReconnectFailed": "Fehler beim Neuverbinden des Checkpoints: {message}", + "checkpointRestored": "Checkpoint auf die vorherige Verknüpfung zurückgesetzt", + "checkpointRestoreFailed": "Fehler beim Wiederherstellen des Checkpoints: {message}", + "checkpointDownloadUnavailable": "Dieser Checkpoint kann ohne CivitAI-Kennungen nicht heruntergeladen werden - versuchen Sie, ihn mit einem lokalen Checkpoint neu zu verknüpfen", "missingLoraDownloadInfo": "Download-Informationen für dieses LoRA fehlen", "hashNotFoundOnCivitai": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert oder der Hash ist ungültig", "downloadLoraFailed": "LoRA-Download fehlgeschlagen: {message}", diff --git a/locales/en.json b/locales/en.json index 8a1f5752..f52c8e10 100644 --- a/locales/en.json +++ b/locales/en.json @@ -948,7 +948,14 @@ "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" + "openCheckpointDetails": "View {name} in the model library", + "checkpointDeletedTooltip": "This checkpoint was deleted from the source and can no longer be downloaded - reconnect it with a local model", + "checkpointHashInvalidTooltip": "This checkpoint hash cannot be resolved on CivitAI - the model may have been updated", + "reconnectCheckpoint": "Reconnect", + "reconnectCheckpointTooltip": "Reconnect with a local checkpoint", + "checkpointReconnectInstructions": "Enter checkpoint name to reconnect:", + "checkpointReconnectPlaceholder": "Enter checkpoint name", + "checkpointReconnectSuggestionsEmpty": "No matching checkpoints in your local library" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "Checkpoint path not available", "missingCheckpointInfo": "Missing checkpoint information", "downloadCheckpointFailed": "Failed to download checkpoint: {message}", + "enterCheckpointName": "Please enter a checkpoint name", + "checkpointReconnectedSuccessfully": "Checkpoint reconnected successfully", + "reconnectCheckpointBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, checkpoint: {checkpoint}) — they are architecture-compatible", + "checkpointReconnectFailed": "Error reconnecting checkpoint: {message}", + "checkpointRestored": "Checkpoint restored to its previous association", + "checkpointRestoreFailed": "Error restoring checkpoint: {message}", + "checkpointDownloadUnavailable": "This checkpoint cannot be downloaded without CivitAI identifiers - try reconnecting it with a local checkpoint", "missingLoraDownloadInfo": "Missing download information for this LoRA", "hashNotFoundOnCivitai": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated or the hash is invalid", "downloadLoraFailed": "Failed to download LoRA: {message}", diff --git a/locales/es.json b/locales/es.json index 9c875d42..56c2162a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -948,7 +948,14 @@ "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" + "openCheckpointDetails": "Ver {name} en la biblioteca de modelos", + "checkpointDeletedTooltip": "Este checkpoint fue eliminado de la fuente y ya no se puede descargar - reconéctalo con un modelo local", + "checkpointHashInvalidTooltip": "El hash de este checkpoint no se puede resolver en CivitAI - el modelo puede haber sido actualizado", + "reconnectCheckpoint": "Reconectar", + "reconnectCheckpointTooltip": "Reconectar con un checkpoint local", + "checkpointReconnectInstructions": "Introduce el nombre del checkpoint para reconectar:", + "checkpointReconnectPlaceholder": "Introduce el nombre del checkpoint", + "checkpointReconnectSuggestionsEmpty": "No hay checkpoints coincidentes en tu biblioteca local" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "Ruta del checkpoint no disponible", "missingCheckpointInfo": "Falta información del checkpoint", "downloadCheckpointFailed": "Error al descargar el checkpoint: {message}", + "enterCheckpointName": "Introduce un nombre de checkpoint", + "checkpointReconnectedSuccessfully": "Checkpoint reconectado exitosamente", + "reconnectCheckpointBaseModelMismatch": "Reconectado, pero los modelos base difieren (receta: {recipe}, checkpoint: {checkpoint}) — son compatibles a nivel de arquitectura", + "checkpointReconnectFailed": "Error reconectando checkpoint: {message}", + "checkpointRestored": "Checkpoint restaurado a su asociación anterior", + "checkpointRestoreFailed": "Error restaurando checkpoint: {message}", + "checkpointDownloadUnavailable": "Este checkpoint no se puede descargar sin identificadores de CivitAI - intenta reconectarlo con un checkpoint local", "missingLoraDownloadInfo": "Falta la información de descarga de este LoRA", "hashNotFoundOnCivitai": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado o el hash no es válido", "downloadLoraFailed": "Error al descargar el LoRA: {message}", diff --git a/locales/fr.json b/locales/fr.json index a31b7e93..09b54565 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -948,7 +948,14 @@ "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" + "openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles", + "checkpointDeletedTooltip": "Ce checkpoint a été supprimé de la source et ne peut plus être téléchargé - reconnectez-le avec un modèle local", + "checkpointHashInvalidTooltip": "Le hash de ce checkpoint ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour", + "reconnectCheckpoint": "Reconnecter", + "reconnectCheckpointTooltip": "Reconnecter avec un checkpoint local", + "checkpointReconnectInstructions": "Entrez le nom du checkpoint à reconnecter:", + "checkpointReconnectPlaceholder": "Entrez le nom du checkpoint", + "checkpointReconnectSuggestionsEmpty": "Aucun checkpoint correspondant dans votre bibliothèque locale" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "Chemin du checkpoint indisponible", "missingCheckpointInfo": "Informations sur le checkpoint manquantes", "downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}", + "enterCheckpointName": "Veuillez saisir un nom de checkpoint", + "checkpointReconnectedSuccessfully": "Checkpoint reconnecté avec succès", + "reconnectCheckpointBaseModelMismatch": "Reconnexion effectuée, mais les modèles de base diffèrent (Recipe : {recipe}, checkpoint : {checkpoint}) — ils sont compatibles au niveau architectural", + "checkpointReconnectFailed": "Erreur lors de la reconnexion du checkpoint : {message}", + "checkpointRestored": "Checkpoint restauré à son association précédente", + "checkpointRestoreFailed": "Erreur lors de la restauration du checkpoint : {message}", + "checkpointDownloadUnavailable": "Ce checkpoint ne peut pas être téléchargé sans identifiants CivitAI - essayez de le reconnecter avec un checkpoint local", "missingLoraDownloadInfo": "Informations de téléchargement manquantes pour ce LoRA", "hashNotFoundOnCivitai": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour ou le hash est invalide", "downloadLoraFailed": "Échec du téléchargement du LoRA : {message}", diff --git a/locales/he.json b/locales/he.json index 6a1eb012..e05ed992 100644 --- a/locales/he.json +++ b/locales/he.json @@ -948,7 +948,14 @@ "undoReconnectTooltipNamed": "שחזר ל-{name} (השיוך לפני החיבור מחדש)", "viewOnCivitai": "הצג ב-CivitAI", "openLoraDetails": "הצג את {name} בספריית ה-LoRA", - "openCheckpointDetails": "הצג את {name} בספריית המודלים" + "openCheckpointDetails": "הצג את {name} בספריית המודלים", + "checkpointDeletedTooltip": "Checkpoint זה נמחק מהמקור ואינו זמין עוד להורדה - חבר אותו מחדש עם מודל מקומי", + "checkpointHashInvalidTooltip": "לא ניתן לפתור את ה-hash של Checkpoint זה ב-CivitAI - ייתכן שהמודל עודכן", + "reconnectCheckpoint": "חבר מחדש", + "reconnectCheckpointTooltip": "חבר מחדש עם Checkpoint מקומי", + "checkpointReconnectInstructions": "הזן שם של Checkpoint לחיבור מחדש:", + "checkpointReconnectPlaceholder": "הזן שם של Checkpoint", + "checkpointReconnectSuggestionsEmpty": "לא נמצאו Checkpoints תואמים בספרייה המקומית שלך" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "נתיב ה-checkpoint אינו זמין", "missingCheckpointInfo": "חסרים פרטי checkpoint", "downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}", + "enterCheckpointName": "הזן שם של Checkpoint", + "checkpointReconnectedSuccessfully": "Checkpoint קושר מחדש בהצלחה", + "reconnectCheckpointBaseModelMismatch": "הקישור מחדש הצליח, אך מודלי הבסיס שונים (מתכון: {recipe}, Checkpoint: {checkpoint}) — הם תואמים מבחינת הארכיטקטורה", + "checkpointReconnectFailed": "שגיאה בקישור מחדש של Checkpoint: {message}", + "checkpointRestored": "Checkpoint שוחזר לשיוך הקודם", + "checkpointRestoreFailed": "שגיאה בשחזור Checkpoint: {message}", + "checkpointDownloadUnavailable": "לא ניתן להוריד Checkpoint זה ללא מזהי CivitAI - נסה לחבר אותו מחדש עם Checkpoint מקומי", "missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה", "hashNotFoundOnCivitai": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן או שה-hash אינו תקין", "downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}", diff --git a/locales/ja.json b/locales/ja.json index ea9c8d8e..f7c59569 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -948,7 +948,14 @@ "undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)", "viewOnCivitai": "CivitAI で表示", "openLoraDetails": "LoRA ライブラリで {name} を表示", - "openCheckpointDetails": "モデルライブラリで {name} を表示" + "openCheckpointDetails": "モデルライブラリで {name} を表示", + "checkpointDeletedTooltip": "この Checkpoint はソースから削除されたため、ダウンロードできません - ローカルモデルで再接続してください", + "checkpointHashInvalidTooltip": "この Checkpoint のハッシュは CivitAI で解決できません - モデルが更新された可能性があります", + "reconnectCheckpoint": "再接続", + "reconnectCheckpointTooltip": "ローカルの Checkpoint と再接続", + "checkpointReconnectInstructions": "再接続する Checkpoint の名前を入力してください:", + "checkpointReconnectPlaceholder": "Checkpoint 名を入力", + "checkpointReconnectSuggestionsEmpty": "ローカルライブラリに一致するCheckpointがありません" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "Checkpointのパスがありません", "missingCheckpointInfo": "Checkpoint情報が不足しています", "downloadCheckpointFailed": "Checkpointのダウンロードに失敗しました: {message}", + "enterCheckpointName": "Checkpoint 名を入力してください", + "checkpointReconnectedSuccessfully": "Checkpointが正常に再接続されました", + "reconnectCheckpointBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、Checkpoint:{checkpoint})— アーキテクチャ互換です", + "checkpointReconnectFailed": "Checkpoint再接続エラー:{message}", + "checkpointRestored": "Checkpoint が以前の関連付けに復元されました", + "checkpointRestoreFailed": "Checkpoint復元エラー:{message}", + "checkpointDownloadUnavailable": "CivitAI の識別子がないため、この Checkpoint をダウンロードできません - ローカルの Checkpoint と再接続してみてください", "missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません", "hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります", "downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}", diff --git a/locales/ko.json b/locales/ko.json index c46d5b11..b414fa2e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -948,7 +948,14 @@ "undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}", "viewOnCivitai": "CivitAI에서 보기", "openLoraDetails": "LoRA 라이브러리에서 {name} 보기", - "openCheckpointDetails": "모델 라이브러리에서 {name} 보기" + "openCheckpointDetails": "모델 라이브러리에서 {name} 보기", + "checkpointDeletedTooltip": "이 Checkpoint는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다 - 로컬 모델로 다시 연결하세요", + "checkpointHashInvalidTooltip": "이 Checkpoint의 해시를 CivitAI에서 확인할 수 없습니다 - 모델이 업데이트되었을 수 있습니다", + "reconnectCheckpoint": "다시 연결", + "reconnectCheckpointTooltip": "로컬 Checkpoint와 다시 연결", + "checkpointReconnectInstructions": "다시 연결할 Checkpoint 이름을 입력하세요:", + "checkpointReconnectPlaceholder": "Checkpoint 이름 입력", + "checkpointReconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 Checkpoint가 없습니다" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "Checkpoint 경로를 사용할 수 없습니다", "missingCheckpointInfo": "Checkpoint 정보가 부족합니다", "downloadCheckpointFailed": "Checkpoint 다운로드 실패: {message}", + "enterCheckpointName": "Checkpoint 이름을 입력하세요", + "checkpointReconnectedSuccessfully": "Checkpoint가 성공적으로 다시 연결되었습니다", + "reconnectCheckpointBaseModelMismatch": "다시 연결했지만 베이스 모델이 다릅니다(레시피: {recipe}, Checkpoint: {checkpoint}) — 아키텍처 호환입니다", + "checkpointReconnectFailed": "Checkpoint 다시 연결 오류: {message}", + "checkpointRestored": "Checkpoint가 이전 연결 상태로 복원되었습니다", + "checkpointRestoreFailed": "Checkpoint 복원 오류: {message}", + "checkpointDownloadUnavailable": "CivitAI 식별자가 없어 이 Checkpoint를 다운로드할 수 없습니다 - 로컬 Checkpoint로 다시 연결해 보세요", "missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다", "hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다", "downloadLoraFailed": "LoRA 다운로드 실패: {message}", diff --git a/locales/ru.json b/locales/ru.json index 3dde64d5..f91036c4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -948,7 +948,14 @@ "undoReconnectTooltipNamed": "Восстановить {name} (привязка до переподключения)", "viewOnCivitai": "Открыть на CivitAI", "openLoraDetails": "Открыть {name} в библиотеке LoRA", - "openCheckpointDetails": "Открыть {name} в библиотеке моделей" + "openCheckpointDetails": "Открыть {name} в библиотеке моделей", + "checkpointDeletedTooltip": "Этот чекпойнт был удалён из источника и больше не может быть скачан - переподключите его к локальной модели", + "checkpointHashInvalidTooltip": "Хеш этого чекпойнта не удаётся разрешить на CivitAI - возможно, модель была обновлена", + "reconnectCheckpoint": "Переподключить", + "reconnectCheckpointTooltip": "Переподключить к локальному чекпойнту", + "checkpointReconnectInstructions": "Введите имя чекпойнта для переподключения:", + "checkpointReconnectPlaceholder": "Введите имя чекпойнта", + "checkpointReconnectSuggestionsEmpty": "В локальной библиотеке нет подходящих чекпойнтов" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "Путь к чекпойнту недоступен", "missingCheckpointInfo": "Отсутствуют данные о чекпойнте", "downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}", + "enterCheckpointName": "Введите имя чекпойнта", + "checkpointReconnectedSuccessfully": "Чекпойнт успешно переподключён", + "reconnectCheckpointBaseModelMismatch": "Переподключение выполнено, но базовые модели различаются (рецепт: {recipe}, чекпойнт: {checkpoint}) — они совместимы по архитектуре", + "checkpointReconnectFailed": "Ошибка переподключения чекпойнта: {message}", + "checkpointRestored": "Чекпойнт восстановлен к прежней привязке", + "checkpointRestoreFailed": "Ошибка восстановления чекпойнта: {message}", + "checkpointDownloadUnavailable": "Этот чекпойнт нельзя скачать без идентификаторов CivitAI - попробуйте переподключить его к локальному чекпойнту", "missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA", "hashNotFoundOnCivitai": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена или хеш недействителен", "downloadLoraFailed": "Не удалось скачать LoRA: {message}", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index cf1e7655..34b3e18f 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -948,7 +948,14 @@ "undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)", "viewOnCivitai": "在 CivitAI 上查看", "openLoraDetails": "在 LoRA 库中查看 {name}", - "openCheckpointDetails": "在模型库中查看 {name}" + "openCheckpointDetails": "在模型库中查看 {name}", + "checkpointDeletedTooltip": "此 Checkpoint 已从来源删除,无法再下载 - 请使用本地模型重新关联", + "checkpointHashInvalidTooltip": "此 Checkpoint 的哈希无法在 CivitAI 上解析 - 模型可能已更新", + "reconnectCheckpoint": "重新关联", + "reconnectCheckpointTooltip": "与本地 Checkpoint 重新关联", + "checkpointReconnectInstructions": "输入 Checkpoint 名称以重新关联:", + "checkpointReconnectPlaceholder": "输入 Checkpoint 名称", + "checkpointReconnectSuggestionsEmpty": "本地库中没有匹配的 Checkpoint" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "缺少Checkpoint路径", "missingCheckpointInfo": "缺少Checkpoint信息", "downloadCheckpointFailed": "下载Checkpoint失败:{message}", + "enterCheckpointName": "请输入 Checkpoint 名称", + "checkpointReconnectedSuccessfully": "Checkpoint 重新连接成功", + "reconnectCheckpointBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},Checkpoint:{checkpoint})——两者架构兼容", + "checkpointReconnectFailed": "Checkpoint 重新连接出错:{message}", + "checkpointRestored": "Checkpoint 已恢复为重新关联前的关联", + "checkpointRestoreFailed": "Checkpoint 恢复出错:{message}", + "checkpointDownloadUnavailable": "缺少 CivitAI 标识,无法下载此 Checkpoint - 请尝试使用本地 Checkpoint 重新关联", "missingLoraDownloadInfo": "缺少此 LoRA 的下载信息", "hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效", "downloadLoraFailed": "下载 LoRA 失败:{message}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d4386698..21c82329 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -948,7 +948,14 @@ "undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)", "viewOnCivitai": "在 CivitAI 上檢視", "openLoraDetails": "在 LoRA 庫中檢視 {name}", - "openCheckpointDetails": "在模型庫中檢視 {name}" + "openCheckpointDetails": "在模型庫中檢視 {name}", + "checkpointDeletedTooltip": "此 Checkpoint 已從來源刪除,無法再下載 - 請使用本地模型重新關聯", + "checkpointHashInvalidTooltip": "此 Checkpoint 的雜湊無法在 CivitAI 上解析 - 模型可能已更新", + "reconnectCheckpoint": "重新關聯", + "reconnectCheckpointTooltip": "與本地 Checkpoint 重新關聯", + "checkpointReconnectInstructions": "輸入 Checkpoint 名稱以重新關聯:", + "checkpointReconnectPlaceholder": "輸入 Checkpoint 名稱", + "checkpointReconnectSuggestionsEmpty": "本地庫中沒有符合的 Checkpoint" }, "controls": { "import": { @@ -2097,6 +2104,13 @@ "missingCheckpointPath": "缺少Checkpoint路徑", "missingCheckpointInfo": "缺少Checkpoint資訊", "downloadCheckpointFailed": "下載Checkpoint失敗:{message}", + "enterCheckpointName": "請輸入 Checkpoint 名稱", + "checkpointReconnectedSuccessfully": "Checkpoint 重新連結成功", + "reconnectCheckpointBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},Checkpoint:{checkpoint})——兩者架構相容", + "checkpointReconnectFailed": "Checkpoint 重新連結錯誤:{message}", + "checkpointRestored": "Checkpoint 已恢復為重新關聯前的關聯", + "checkpointRestoreFailed": "Checkpoint 恢復錯誤:{message}", + "checkpointDownloadUnavailable": "缺少 CivitAI 標識,無法下載此 Checkpoint - 請嘗試使用本地 Checkpoint 重新關聯", "missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊", "hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效", "downloadLoraFailed": "下載 LoRA 失敗:{message}", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 5c20fb8b..fb520e16 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -116,6 +116,10 @@ class RecipeHandlerSet: "restore_lora": self.management.restore_lora, "get_reconnect_suggestions": self.management.get_reconnect_suggestions, "mark_lora_hash_invalid": self.management.mark_lora_hash_invalid, + "reconnect_checkpoint": self.management.reconnect_checkpoint, + "restore_checkpoint": self.management.restore_checkpoint, + "get_checkpoint_reconnect_suggestions": self.management.get_checkpoint_reconnect_suggestions, + "mark_checkpoint_hash_invalid": self.management.mark_checkpoint_hash_invalid, "find_duplicates": self.query.find_duplicates, "move_recipes_bulk": self.management.move_recipes_bulk, "bulk_delete": self.management.bulk_delete, @@ -1683,6 +1687,116 @@ class RecipeManagementHandler: ) return web.json_response({"error": str(exc)}, status=500) + async def reconnect_checkpoint(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", "target_name"): + if field not in data: + raise RecipeValidationError(f"Missing required field: {field}") + + result = await self._persistence_service.reconnect_checkpoint( + recipe_scanner=recipe_scanner, + recipe_id=data["recipe_id"], + target_name=data["target_name"], + ) + 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 reconnecting checkpoint: %s", exc, exc_info=True + ) + return web.json_response({"error": str(exc)}, status=500) + + async def restore_checkpoint(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() + if "recipe_id" not in data: + raise RecipeValidationError("Missing required field: recipe_id") + + result = await self._persistence_service.restore_checkpoint( + recipe_scanner=recipe_scanner, + recipe_id=data["recipe_id"], + ) + 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 checkpoint: %s", exc, exc_info=True) + return web.json_response({"error": str(exc)}, status=500) + + async def get_checkpoint_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") + if not recipe_id: + raise RecipeValidationError("recipe_id is required") + + result = await self._persistence_service.get_checkpoint_reconnect_suggestions( + recipe_scanner=recipe_scanner, + recipe_id=recipe_id, + 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 checkpoint reconnect candidates: %s", + exc, + exc_info=True, + ) + return web.json_response({"error": str(exc)}, status=500) + + async def mark_checkpoint_hash_invalid(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() + if "recipe_id" not in data: + raise RecipeValidationError("Missing required field: recipe_id") + + result = await self._persistence_service.mark_checkpoint_hash_invalid( + recipe_scanner=recipe_scanner, + recipe_id=data["recipe_id"], + hash_invalid=bool(data.get("hash_invalid", True)), + ) + 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 marking checkpoint hash invalid: %s", exc, exc_info=True + ) + return web.json_response({"error": str(exc)}, status=500) + async def bulk_delete(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 20562e88..2e1b33a1 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -58,6 +58,22 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid" ), + RouteDefinition( + "POST", "/api/lm/recipe/checkpoint/reconnect", "reconnect_checkpoint" + ), + RouteDefinition( + "POST", "/api/lm/recipe/checkpoint/restore", "restore_checkpoint" + ), + RouteDefinition( + "GET", + "/api/lm/recipe/{recipe_id}/checkpoint/reconnect-suggestions", + "get_checkpoint_reconnect_suggestions", + ), + RouteDefinition( + "POST", + "/api/lm/recipe/checkpoint/mark-hash-invalid", + "mark_checkpoint_hash_invalid", + ), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition( diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index c58dec8c..217c89c0 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -265,6 +265,49 @@ class RecipeScanner: ) -> list[dict[str, Any]]: """Rank local LoRAs as reconnect candidates for a broken recipe entry. + Thin wrapper over ``_suggest_reconnect_candidates`` scoped to the + LoRA library (see it for the ranking contract). + """ + return await self._suggest_reconnect_candidates( + entry=entry, + recipe_base_model=recipe_base_model, + query=query, + limit=limit, + is_checkpoint=False, + ) + + async def suggest_checkpoint_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 checkpoints as reconnect candidates for a broken entry. + + Thin wrapper over ``_suggest_reconnect_candidates`` scoped to the + checkpoint library (see it for the ranking contract). + """ + return await self._suggest_reconnect_candidates( + entry=entry, + recipe_base_model=recipe_base_model, + query=query, + limit=limit, + is_checkpoint=True, + ) + + async def _suggest_reconnect_candidates( + self, + *, + entry: dict[str, Any], + recipe_base_model: Optional[str], + query: Optional[str] = None, + limit: int = 5, + is_checkpoint: bool, + ) -> list[dict[str, Any]]: + """Rank local models 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 @@ -286,11 +329,11 @@ class RecipeScanner: if limit <= 0 or not isinstance(entry, dict): return [] - lora_scanner = self._lora_scanner - if lora_scanner is None: + scanner = self._checkpoint_scanner if is_checkpoint else self._lora_scanner + if scanner is None: return [] - data = await lora_scanner.get_cached_data() + data = await scanner.get_cached_data() recipe_bm = (recipe_base_model or "").strip().casefold() def _base_model_known_mismatch(item: dict[str, Any]) -> bool: @@ -315,7 +358,7 @@ class RecipeScanner: # 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): + if not self._is_type_compatible(item, is_checkpoint=is_checkpoint): continue if _base_model_known_mismatch(item): continue @@ -351,14 +394,17 @@ class RecipeScanner: if ( isinstance(hit, dict) and (hit.get("sha256") or "").strip() - and self._is_type_compatible(hit, is_checkpoint=False) + and self._is_type_compatible(hit, is_checkpoint=is_checkpoint) 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 is_checkpoint: + hit = self._get_checkpoint_from_version_index(str(version_id)) + else: + hit = self._get_lora_from_version_index(str(version_id)) if ( isinstance(hit, dict) and (hit.get("sha256") or "").strip() @@ -367,7 +413,12 @@ class RecipeScanner: _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 "") + # Parser-style checkpoint entries carry the model name under ``name``, + # widget-style ones under ``modelName`` — try both for checkpoints. + if is_checkpoint: + name_source = query_text or (entry.get("name") or entry.get("modelName") or "") + else: + 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 @@ -1496,6 +1547,7 @@ class RecipeScanner: identifier key when neither identifier form exists). """ entry["isDeleted"] = False + entry["hashInvalid"] = False new_hash = (item.get("sha256") or "").lower() if new_hash: @@ -3290,6 +3342,19 @@ class RecipeScanner: return await self._lora_scanner.find_models_by_name(name, base_model=base_model) + async def find_local_checkpoints_by_name( + self, name: str, base_model: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Return every local checkpoint matching ``name`` (used to explain lookup misses).""" + + checkpoint_scanner = getattr(self, "_checkpoint_scanner", None) + if not checkpoint_scanner or not name: + return [] + + return await checkpoint_scanner.find_models_by_name( + name, base_model=base_model + ) + async def get_local_lora_by_hash(self, hash_value: str) -> Optional[Dict[str, Any]]: """Lookup a local LoRA through the scanner's hash index.""" @@ -4036,6 +4101,214 @@ class RecipeScanner: updated_lora = self._enrich_lora_entry(dict(lora_entry)) return recipe_data, updated_lora + async def update_checkpoint_entry( + self, + recipe_id: str, + *, + target_name: str, + target_checkpoint: Optional[Dict[str, Any]] = None, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Update the checkpoint entry within a recipe (manual reconnect). + + Mirrors :meth:`update_lora_entry`: the pre-update entry is snapshotted + under ``reconnectSnapshot`` so the association can be restored later, + then the matched local checkpoint is written back following the same + pinned key set as ``_write_rematch_checkpoint_entry``. ``file_name`` + keeps the user-entered ``target_name`` (the same convention as the + LoRA reconnect), while hash/name/version/baseModel/identifier are + refreshed from the local item. The fingerprint is untouched — it is + computed over LoRAs only. + + Returns: + The updated recipe data and the refreshed checkpoint metadata. + """ + if target_name is None: + raise ValueError("target_name must be provided") + + 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) + + checkpoint = recipe_data.get("checkpoint") + if not isinstance(checkpoint, dict): + raise RecipeValidationError( + "Recipe has no checkpoint entry to reconnect" + ) + + # 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 checkpoint.items() + if key != "reconnectSnapshot" + } + checkpoint["isDeleted"] = False + checkpoint["hashInvalid"] = False + checkpoint["file_name"] = target_name + + if target_checkpoint is not None: + sha_value = target_checkpoint.get("sha256") or target_checkpoint.get( + "sha" + ) + if sha_value: + checkpoint["hash"] = sha_value.lower() + + self._write_rematch_checkpoint_entry(checkpoint, target_checkpoint) + + # The write-back only refreshes keys the entry already has; + # a manual reconnect must also backfill the display keys so a + # sparse parser-style entry renders properly after the swap. + if not checkpoint.get("name") and target_checkpoint.get("model_name"): + checkpoint["name"] = target_checkpoint["model_name"] + civitai = target_checkpoint.get("civitai") or {} + civ_name = civitai.get("name") + if not checkpoint.get("version") and civ_name: + checkpoint["version"] = civ_name + if ( + not checkpoint.get("baseModel") + and target_checkpoint.get("base_model") + ): + checkpoint["baseModel"] = target_checkpoint["base_model"] + + checkpoint["reconnectSnapshot"] = snapshot + 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 + + updated_checkpoint = dict(checkpoint) + if target_checkpoint is not None: + preview_url = target_checkpoint.get("preview_url") + if preview_url: + updated_checkpoint["preview_url"] = config.get_preview_static_url( + preview_url + ) + if target_checkpoint.get("file_path"): + updated_checkpoint["localPath"] = target_checkpoint["file_path"] + + updated_checkpoint = self._enrich_checkpoint_entry(updated_checkpoint) + return recipe_data, updated_checkpoint + + async def restore_checkpoint_entry( + self, + recipe_id: str, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Restore the checkpoint entry to its pre-reconnect snapshot. + + Reverses :meth:`update_checkpoint_entry`: the entry saved under + ``reconnectSnapshot`` becomes the checkpoint again and the snapshot is + dropped. Returns the updated recipe data and the restored checkpoint + 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) + + checkpoint = recipe_data.get("checkpoint") + if not isinstance(checkpoint, dict): + raise RecipeValidationError( + "Recipe has no checkpoint entry to restore" + ) + + snapshot = checkpoint.get("reconnectSnapshot") + if not isinstance(snapshot, dict): + raise RecipeValidationError( + "Checkpoint entry has no reconnect snapshot to restore" + ) + + restored_entry = copy.deepcopy(snapshot) + restored_entry.pop("reconnectSnapshot", None) + recipe_data["checkpoint"] = restored_entry + 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_checkpoint = self._enrich_checkpoint_entry(dict(restored_entry)) + return recipe_data, restored_checkpoint + + async def set_checkpoint_entry_hash_invalid( + self, + recipe_id: str, + hash_invalid: bool, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Set the ``hashInvalid`` flag on the recipe's checkpoint entry. + + ``hashInvalid`` records that the entry's hash could not be resolved + on CivitAI (e.g. a download attempt returned "Model not found"). + Marking it makes the entry an unresolved rematch candidate without + touching its stored hash/file_name. + + Returns: + The updated recipe data and the refreshed checkpoint 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) + + checkpoint = recipe_data.get("checkpoint") + if not isinstance(checkpoint, dict): + raise RecipeValidationError("Checkpoint entry is not a dict") + + checkpoint["hashInvalid"] = bool(hash_invalid) + 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() + + if self._persistent_cache: + self._persistent_cache.update_recipe(recipe_data, recipe_json_path) + self._json_path_map[recipe_id] = recipe_json_path + + updated_checkpoint = self._enrich_checkpoint_entry(dict(checkpoint)) + return recipe_data, updated_checkpoint + async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]: """Return recipes that reference a given LoRA hash.""" diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 5a4fde99..60e28455 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -599,6 +599,172 @@ class RecipePersistenceService: } ) + async def reconnect_checkpoint( + self, + *, + recipe_scanner, + recipe_id: str, + target_name: str, + ) -> PersistenceResult: + """Reconnect the checkpoint entry within an existing recipe.""" + + 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_base_model = json.load(file_obj).get("base_model", "") + + matches = await recipe_scanner.find_local_checkpoints_by_name(target_name) + if not matches: + raise RecipeNotFoundError( + f"Local checkpoint not found with name: {target_name}" + ) + + # Same three-tier base-model guard as reconnect_lora: exact/unknown + # labels pass silently; same-architecture-family labels pass but are + # reported so the UI can warn; confident mismatches stay hard-rejected. + 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 checkpoint '{target_name}' has a different base model " + "than the recipe" + ) + if len(eligible) > 1: + raise RecipeValidationError( + f"Multiple local checkpoints match '{target_name}'; " + "include the folder path to disambiguate" + ) + target_checkpoint, target_relation = eligible[0] + + recipe_data, updated_checkpoint = await recipe_scanner.update_checkpoint_entry( + recipe_id, + target_name=target_name, + target_checkpoint=target_checkpoint, + ) + + 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_checkpoint": updated_checkpoint, + "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, + "checkpoint_base_model": target_checkpoint.get("base_model") or "", + } + return PersistenceResult(payload) + + async def restore_checkpoint( + self, + *, + recipe_scanner, + recipe_id: str, + ) -> PersistenceResult: + """Restore the checkpoint entry to the state captured before its reconnect.""" + + recipe_data, updated_checkpoint = await recipe_scanner.restore_checkpoint_entry( + recipe_id + ) + + 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) + + return PersistenceResult( + { + "success": True, + "recipe_id": recipe_id, + "updated_checkpoint": updated_checkpoint, + "matching_recipes": matching_recipes, + } + ) + + async def get_checkpoint_reconnect_suggestions( + self, + *, + recipe_scanner, + recipe_id: str, + query: str | None = None, + ) -> PersistenceResult: + """Return ranked local checkpoint 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) + + checkpoint = recipe_data.get("checkpoint") + if not isinstance(checkpoint, dict): + raise RecipeValidationError("Recipe has no checkpoint entry") + + suggestions = await recipe_scanner.suggest_checkpoint_reconnect_candidates( + entry=checkpoint, + recipe_base_model=recipe_data.get("base_model"), + query=query, + ) + + return PersistenceResult({"success": True, "suggestions": suggestions}) + + async def mark_checkpoint_hash_invalid( + self, + *, + recipe_scanner, + recipe_id: str, + hash_invalid: bool = True, + ) -> PersistenceResult: + """Mark the recipe checkpoint entry's hash as unresolvable on CivitAI. + + Called when a download attempt by hash returned "Model not found". + The flag makes the entry an unresolved rematch candidate without + altering its stored hash/file_name. + """ + + recipe_data, updated_checkpoint = ( + await recipe_scanner.set_checkpoint_entry_hash_invalid( + recipe_id, + hash_invalid=hash_invalid, + ) + ) + + return PersistenceResult( + { + "success": True, + "recipe_id": recipe_id, + "hash_invalid": bool(hash_invalid), + "updated_checkpoint": updated_checkpoint, + } + ) + async def bulk_delete( self, *, diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index ffd0db12..d9819830 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -957,8 +957,10 @@ } /* Restore icon for manually reconnected entries: its presence on the info - row doubles as the "was reconnected" marker. */ -.lora-undo-reconnect { + row doubles as the "was reconnected" marker. Shared by LoRA and + checkpoint entries, which use the same info-row flex layout. */ +.lora-undo-reconnect, +.checkpoint-undo-reconnect { margin-left: auto; background: none; border: none; @@ -973,7 +975,9 @@ } .lora-undo-reconnect:hover, -.lora-undo-reconnect:focus-visible { +.lora-undo-reconnect:focus-visible, +.checkpoint-undo-reconnect:hover, +.checkpoint-undo-reconnect:focus-visible { opacity: 1; color: var(--lora-accent); background: var(--lora-surface); diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index fff7ff08..95a32b14 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -848,6 +848,14 @@ class RecipeModal { const loras = Array.isArray(recipe.loras) ? recipe.loras : []; if (checkpointContainer) { + // The innerHTML below discards the checkpoint reconnect container; + // tear down its Combobox panel (appended to document.body) first. + const checkpointPanel = checkpointContainer.querySelector( + '.lora-reconnect-container[data-lora-index="checkpoint"]' + ); + if (checkpointPanel) { + this._destroyReconnectCombobox(checkpointPanel); + } checkpointContainer.innerHTML = ''; if (recipe.checkpoint && typeof recipe.checkpoint === 'object') { checkpointContainer.innerHTML = this.renderCheckpoint(recipe.checkpoint); @@ -1936,7 +1944,9 @@ class RecipeModal { }, // 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'), + emptyText: String(loraIndex) === 'checkpoint' + ? translate('recipes.resources.checkpointReconnectSuggestionsEmpty', {}, 'No matching checkpoints in your local library') + : translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'), onCommit: (value) => { this.reconnectLora(loraIndex, value); }, @@ -1963,7 +1973,10 @@ class RecipeModal { async _fetchReconnectSuggestions(loraIndex, query) { const suffix = query ? `?query=${encodeURIComponent(query)}` : ''; - const response = await fetch(`/api/lm/recipe/${this.recipeId}/lora/${loraIndex}/reconnect-suggestions${suffix}`); + const targetPath = String(loraIndex) === 'checkpoint' + ? 'checkpoint/reconnect-suggestions' + : `lora/${loraIndex}/reconnect-suggestions`; + const response = await fetch(`/api/lm/recipe/${this.recipeId}/${targetPath}${suffix}`); if (!response.ok) { return []; } @@ -2004,7 +2017,9 @@ class RecipeModal { 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'); + empty.textContent = String(loraIndex) === 'checkpoint' + ? translate('recipes.resources.checkpointReconnectSuggestionsEmpty', {}, 'No matching checkpoints in your local library') + : translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'); listElement.appendChild(empty); return; } @@ -2061,6 +2076,12 @@ class RecipeModal { } async reconnectLora(loraIndex, inputValue) { + // The checkpoint entry reuses the same container/combobox machinery; + // route it to the checkpoint-specific flow (no syntax, no + // lora_index in the payload). + if (String(loraIndex) === 'checkpoint') { + return this.reconnectCheckpoint(inputValue); + } const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`); if (!inputValue || !inputValue.trim()) { @@ -2176,8 +2197,153 @@ class RecipeModal { } } + async reconnectCheckpoint(inputValue) { + const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); + + if (!inputValue || !inputValue.trim()) { + this.showReconnectError(container, translate('toast.recipes.enterCheckpointName', {}, 'Please enter a checkpoint name')); + return; + } + + try { + // Remove .safetensors extension if present + const fileName = inputValue.trim().replace(/\.safetensors$/, ''); + + state.loadingManager.showSimpleLoading('Reconnecting checkpoint...'); + + // Call API to reconnect the checkpoint entry + const response = await fetch('/api/lm/recipe/checkpoint/reconnect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_id: this.recipeId, + target_name: fileName + }) + }); + + const result = await response.json(); + + if (result.success) { + // Hide the reconnect input + this.hideReconnectInput(container); + + // Update the current recipe with the updated checkpoint data + this.currentRecipe.checkpoint = result.updated_checkpoint; + + // Show success message + showToast('toast.recipes.checkpointReconnectedSuccessfully', {}, '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.reconnectCheckpointBaseModelMismatch', + { + recipe: result.base_model_mismatch.recipe_base_model, + checkpoint: result.base_model_mismatch.checkpoint_base_model, + }, + 'warning' + ); + } + + // Refresh modal to show updated content + setTimeout(() => { + this.showRecipeDetails(this.currentRecipe); + }, 500); + + state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, { + checkpoint: this.currentRecipe.checkpoint + }); + } else { + this.showReconnectError(container, translate('toast.recipes.checkpointReconnectFailed', { message: result.error }, `Error reconnecting checkpoint: ${result.error}`)); + } + } catch (error) { + console.error('Error reconnecting checkpoint:', error); + this.showReconnectError(container, translate('toast.recipes.checkpointReconnectFailed', { message: error.message }, `Error reconnecting checkpoint: ${error.message}`)); + } finally { + state.loadingManager.hide(); + } + } + + async restoreCheckpoint() { + try { + state.loadingManager.showSimpleLoading('Restoring checkpoint...'); + + const response = await fetch('/api/lm/recipe/checkpoint/restore', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_id: this.recipeId + }) + }); + + const result = await response.json(); + + if (result.success) { + // Swap the entry back to its pre-reconnect state + this.currentRecipe.checkpoint = result.updated_checkpoint; + + showToast('toast.recipes.checkpointRestored', {}, 'success'); + + setTimeout(() => { + this.showRecipeDetails(this.currentRecipe); + }, 500); + + state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, { + checkpoint: this.currentRecipe.checkpoint + }); + } else { + showToast('toast.recipes.checkpointRestoreFailed', { message: result.error }, 'error'); + } + } catch (error) { + console.error('Error restoring checkpoint:', error); + showToast('toast.recipes.checkpointRestoreFailed', { message: error.message }, 'error'); + } finally { + state.loadingManager.hide(); + } + } + + async markCheckpointHashInvalid() { + const recipeId = + this.recipeId || + extractRecipeId(this.listFilePath || this.currentRecipe?.file_path); + if (!recipeId) { + return; + } + try { + await fetch('/api/lm/recipe/checkpoint/mark-hash-invalid', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_id: recipeId, + }), + }); + if (this.currentRecipe?.checkpoint) { + this.currentRecipe.checkpoint.hashInvalid = true; + this.syncResourcesSection(this.currentRecipe); + } + } catch (error) { + console.warn('Failed to mark checkpoint hash invalid:', error); + } + } + renderCheckpoint(checkpoint) { const existsLocally = !!checkpoint.inLibrary; + const isDeleted = !!checkpoint.isDeleted; + const hashInvalid = !!checkpoint.hashInvalid; + // "Broken" = cannot be restored by downloading: explicitly marked + // deleted, an unresolvable hash, or an entry with no CivitAI + // identifiers at all (a name-only remnant that never had a version + // id to query — it can only be fixed by reconnecting a local model). + const broken = isDeleted + || hashInvalid + || (!existsLocally && !this.canDownloadCheckpoint(checkpoint)); const localPath = checkpoint.localPath || ''; const previewUrl = checkpoint.preview_url || checkpoint.thumbnailUrl || '/loras_static/images/no-preview.png'; const isPreviewVideo = typeof previewUrl === 'string' && previewUrl.toLowerCase().endsWith('.mp4'); @@ -2194,18 +2360,38 @@ class RecipeModal { ` : `Checkpoint preview`; // Status badge: pure indicator with a tooltip, mirroring the LoRA - // items and the versions-tab badge pattern. The header carries only - // the badge; every action lives in the bottom action row. - const badge = existsLocally ? ` -
- ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))} -
- ` : ` -
- ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))} -
- `; + // items and the versions-tab badge pattern. Deleted / unresolvable + // hash states render the same fixable-broken badges as LoRA entries. + let badge; + if (existsLocally) { + badge = ` +
+ ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))} +
+ `; + } else if (isDeleted) { + badge = ` +
+ ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))} +
+ `; + } else if (hashInvalid) { + badge = ` +
+ ${escapeHtml(translate('recipes.resources.hashInvalid', {}, 'Unresolvable Hash'))} +
+ `; + } else { + badge = ` +
+ ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))} +
+ `; + } + // Action row: broken (deleted / unresolvable hash) entries offer the + // reconnect affordance instead of the download button — same rule as + // the LoRA items. A local checkpoint only exposes "Send to ComfyUI". const actions = []; if (existsLocally && localPath) { actions.push(` @@ -2214,6 +2400,16 @@ class RecipeModal { ${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')} `); + } else if (broken) { + const reconnectLabel = translate('recipes.resources.reconnectCheckpoint', {}, 'Reconnect'); + const reconnectTooltip = translate('recipes.resources.reconnectCheckpointTooltip', {}, 'Reconnect with a local checkpoint'); + actions.push(` + + `); } else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) { actions.push(` + `; + } + + // Inline reconnect form for broken entries, sharing the LoRA + // container structure/classes and the combobox interaction. + const reconnectContainer = broken ? ` +
+
+

${escapeHtml(translate('recipes.resources.checkpointReconnectInstructions', {}, 'Enter checkpoint name to reconnect:'))}

+
+
+ +
+ + +
+
+
+ +
` : ''; + return ` -
+
${previewMedia}
@@ -2252,9 +2489,11 @@ class RecipeModal { ${versionLabel ? `
${versionLabel}
` : ''} ${baseModel ? `
${baseModel}
` : ''} ${modelTypeLabel ? `${modelTypeLabel}` : ''} + ${undoReconnectIcon}
${actionsRow}
+ ${reconnectContainer} `; } @@ -2275,6 +2514,27 @@ class RecipeModal { await this.downloadCheckpoint(checkpoint, downloadBtn); }); } + + // Deferred wiring can run again after a hydration re-render while the + // latest DOM is already in place; a data flag prevents stacking + // duplicate handlers (same pattern as the LoRA item actions). + const reconnectBtn = container.querySelector('.checkpoint-reconnect'); + if (reconnectBtn && reconnectBtn.dataset.wired !== 'true') { + reconnectBtn.dataset.wired = 'true'; + reconnectBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.showReconnectInput('checkpoint'); + }); + } + + const undoBtn = container.querySelector('.checkpoint-undo-reconnect'); + if (undoBtn && undoBtn.dataset.wired !== 'true') { + undoBtn.dataset.wired = 'true'; + undoBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.restoreCheckpoint(); + }); + } } setupCheckpointNavigation(container, checkpoint) { @@ -2357,7 +2617,14 @@ class RecipeModal { async downloadCheckpoint(checkpoint, button) { if (!this.canDownloadCheckpoint(checkpoint)) { - showToast('toast.recipes.missingCheckpointInfo', {}, 'error'); + // No resolvable CivitAI identifiers for this entry. A hash-only + // checkpoint is not downloadable through the version downloader — + // point the user at the reconnect flow instead. + if (this._getCheckpointHash(checkpoint)) { + showToast('toast.recipes.checkpointDownloadUnavailable', {}, 'warning'); + } else { + showToast('toast.recipes.missingCheckpointInfo', {}, 'error'); + } return; } @@ -2381,6 +2648,17 @@ class RecipeModal { ); if (success) { await this.refreshResourcesAfterDownload(); + return; + } + // Business-level download failure (the request completed but the + // backend rejected it). Enroll the entry in the rematch/reconnect + // remediation flow only when the failure is clearly unresolvable + // (model removed or version gone on CivitAI) — the same signal + // rule as the LoRA path, which marks the hash invalid only when + // resolving it on CivitAI returns "not found". Transient + // failures (network, 5xx) leave the entry untouched. + if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) { + await this.markCheckpointHashInvalid(); } } catch (error) { console.error('Error downloading checkpoint:', error); @@ -2392,6 +2670,21 @@ class RecipeModal { } } + /** + * Decide whether a download failure means the model is unrecoverable. + * + * Mirrors the LoRA behaviour: the hash invalid flag (and the resulting + * rematch/reconnect candidacy) is only set when CivitAI explicitly says + * the model cannot be resolved — never for transient transport errors. + */ + _isUnresolvableDownloadError(message) { + if (!message) { + return false; + } + const text = String(message).toLowerCase(); + return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text); + } + getResourceCivitaiUrl(resource) { if (!resource) { return null; diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index adb43a9c..21327a4c 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -1182,10 +1182,13 @@ export class DownloadManager { if (!response?.success) { this.loadingManager.setStatus(translate('modals.download.status.finalizing')); const errorMessage = response?.error || 'Unknown error'; + // Always record the latest failure so callers can distinguish + // an unresolvable model (not found / deleted) from a transient + // transport failure; the summary flow below may or may not run. + this._lastDownloadError = errorMessage; // When the caller aggregates failures itself (multi-file // loop), just record the error and return (#1058). if (suppressFailureSummary) { - this._lastDownloadError = errorMessage; return false; } // A file-level "already in library" rejection is an expected diff --git a/tests/frontend/components/recipeModal.resourceItems.test.js b/tests/frontend/components/recipeModal.resourceItems.test.js index 5cd94250..b2c7fd54 100644 --- a/tests/frontend/components/recipeModal.resourceItems.test.js +++ b/tests/frontend/components/recipeModal.resourceItems.test.js @@ -95,6 +95,7 @@ vi.mock('../../../static/js/api/apiConfig.js', () => ({ vi.mock('../../../static/js/managers/DownloadManager.js', () => ({ downloadManager: { downloadVersionWithDefaults: downloadVersionWithDefaultsMock, + _lastDownloadError: '', }, })); @@ -776,4 +777,321 @@ describe('RecipeModal resource item interactions', () => { lora_index: '0', }); }); + + describe('checkpoint reconnect', () => { + const brokenCheckpoint = { + name: 'gone-checkpoint', + file_name: 'gone', + inLibrary: false, + isDeleted: true, + hash: 'a2a12bfa01', + }; + const hashInvalidCheckpoint = { + name: 'invalid-checkpoint', + file_name: 'invalid', + inLibrary: false, + hashInvalid: true, + hash: 'deadbeefcafe', + }; + + function recipeWithCheckpoint(checkpoint) { + return { + ...JSON.parse(JSON.stringify(recipeWithResources)), + checkpoint: { ...checkpoint }, + }; + } + + // Hydration re-fetches the recipe right after render and re-renders the + // modal, so the mock must resolve the SAME broken-checkpoint recipe — + // otherwise the fetch wipes isDeleted/hashInvalid back to the fixture. + async function renderBrokenCheckpoint(recipeModal, checkpoint) { + const isolated = recipeWithCheckpoint(checkpoint); + fetchRecipeDetailsMock.mockResolvedValue(isolated); + recipeModal.showRecipeDetails(isolated); + await flushWiring(); + } + + function mockCheckpointSuggestionsFetch(payload) { + const requests = []; + global.fetch = vi.fn(async (url, options) => { + requests.push({ url: String(url), options }); + if (String(url).includes('/checkpoint/reconnect-suggestions')) { + return { ok: true, json: async () => payload }; + } + if (String(url).includes('/recipe/checkpoint/reconnect')) { + return { + ok: true, + json: async () => ({ + success: true, + updated_checkpoint: { + name: 'main-checkpoint', + file_name: 'main', + inLibrary: true, + }, + }), + }; + } + return { ok: true, json: async () => ({}) }; + }); + return requests; + } + + it('renders a deleted checkpoint with a badge and reconnect affordance', async () => { + const recipeModal = await createRecipeModal(); + await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); + + const item = document.querySelector('.checkpoint-item'); + expect(item.classList.contains('is-deleted')).toBe(true); + expect(item.querySelector('.deleted-badge')).not.toBeNull(); + const reconnectButton = item.querySelector('.checkpoint-reconnect'); + expect(reconnectButton).not.toBeNull(); + // Deleted checkpoints lose the civitai link (their source page is gone) + expect(item.querySelector('.recipe-lora-title a.recipe-civitai-link')).toBeNull(); + // The inline form is present but hidden until the button is pressed + const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); + expect(container).not.toBeNull(); + expect(container.classList.contains('active')).toBe(false); + }); + + it('renders a hash-invalid checkpoint with the unresolvable hash badge', async () => { + const recipeModal = await createRecipeModal(); + await renderBrokenCheckpoint(recipeModal, hashInvalidCheckpoint); + + const item = document.querySelector('.checkpoint-item'); + expect(item.querySelector('.invalid-hash-badge')).not.toBeNull(); + expect(item.querySelector('.checkpoint-reconnect')).not.toBeNull(); + }); + + it('renders reconnect for a name-only checkpoint with no download identifiers', async () => { + // Importers can leave a checkpoint entry with nothing but a model name + // (no hash / version id, so nothing was ever queryable on CivitAI). + // It cannot be downloaded and is not marked deleted — reconnect is the + // only remediation, so it must still surface. + const recipeModal = await createRecipeModal(); + await renderBrokenCheckpoint(recipeModal, { + type: 'checkpoint', + modelName: 'meichidarkMix_meichidarkanimxlV1', + inLibrary: false, + }); + + const item = document.querySelector('.checkpoint-item'); + expect(item.querySelector('.checkpoint-download')).toBeNull(); + const reconnectButton = item.querySelector('.checkpoint-reconnect'); + expect(reconnectButton).not.toBeNull(); + const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); + expect(container).not.toBeNull(); + + reconnectButton.click(); + expect(container.classList.contains('active')).toBe(true); + }); + + it('fetches checkpoint suggestions against the checkpoint endpoint', async () => { + const recipeModal = await createRecipeModal(); + const suggestionsPayload = { + success: true, + suggestions: [ + { + file_name: 'main-checkpoint.safetensors', + base_model: 'SD 1.5', + preview_url: '/preview/main.png', + score: 0.95, + match_reason: 'same_version', + target_name: 'main-checkpoint', + }, + ], + }; + mockCheckpointSuggestionsFetch(suggestionsPayload); + + await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); + document.querySelector('.checkpoint-reconnect').click(); + const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); + + expect(container.classList.contains('active')).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + '/api/lm/recipe/recipe-resources/checkpoint/reconnect-suggestions' + ); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1); + }); + expect(container.querySelector('.reconnect-suggestion-name').textContent) + .toBe('main-checkpoint'); + }); + + it('reconnects the checkpoint via its own endpoint when a suggestion is clicked', async () => { + const recipeModal = await createRecipeModal(); + const requests = mockCheckpointSuggestionsFetch({ + success: true, + suggestions: [ + { + file_name: 'main-checkpoint.safetensors', + target_name: 'main-checkpoint', + match_reason: 'same_version', + score: 0.95, + }, + ], + }); + + await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); + document.querySelector('.checkpoint-reconnect').click(); + const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); + + 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/checkpoint/reconnect')).toBe(true); + }); + const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/reconnect'); + expect(reconnectRequest.options.method).toBe('POST'); + expect(JSON.parse(reconnectRequest.options.body)).toEqual({ + recipe_id: 'recipe-resources', + target_name: 'main-checkpoint', + }); + await vi.waitFor(() => { + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.checkpointReconnectedSuccessfully', + {}, + 'success' + ); + }); + expect(recipeModal.currentRecipe.checkpoint.inLibrary).toBe(true); + }); + + it('warns when the checkpoint reconnect crossed base-model families', async () => { + const recipeModal = await createRecipeModal(); + global.fetch = vi.fn(async (url) => { + if (String(url).includes('/checkpoint/reconnect-suggestions')) { + return { ok: true, json: async () => ({ success: true, suggestions: [] }) }; + } + if (String(url).includes('/recipe/checkpoint/reconnect')) { + return { + ok: true, + json: async () => ({ + success: true, + updated_checkpoint: { name: 'main', inLibrary: true }, + base_model_mismatch: { recipe_base_model: 'Illustrious', checkpoint_base_model: 'Pony' }, + }), + }; + } + return { ok: true, json: async () => ({}) }; + }); + + await renderBrokenCheckpoint(recipeModal, brokenCheckpoint); + document.querySelector('.checkpoint-reconnect').click(); + const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]'); + const input = container.querySelector('.reconnect-input'); + input.value = 'main'; + container.querySelector('.reconnect-confirm-btn').click(); + + await vi.waitFor(() => { + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.reconnectCheckpointBaseModelMismatch', + { recipe: 'Illustrious', checkpoint: 'Pony' }, + 'warning' + ); + }); + }); + + it('offers undo for a reconnected checkpoint and restores via the API', async () => { + const recipeModal = await createRecipeModal(); + const isolatedRecipe = recipeWithCheckpoint(brokenCheckpoint); + isolatedRecipe.checkpoint = { + name: 'main-checkpoint', + file_name: 'main', + inLibrary: true, + reconnectSnapshot: { name: 'gone-checkpoint', 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/checkpoint/restore')) { + return { + ok: true, + json: async () => ({ + success: true, + updated_checkpoint: { name: 'gone', inLibrary: false, isDeleted: true }, + }), + }; + } + return { ok: true, json: async () => ({}) }; + }); + recipeModal.showRecipeDetails(isolatedRecipe); + await flushWiring(); + + const item = document.querySelector('.checkpoint-item'); + const undoButton = item.querySelector('.checkpoint-undo-reconnect'); + expect(undoButton).not.toBeNull(); + + undoButton.click(); + await vi.waitFor(() => { + expect(showToastMock).toHaveBeenCalledWith('toast.recipes.checkpointRestored', {}, 'success'); + }); + const restoreRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/restore'); + expect(restoreRequest.options.method).toBe('POST'); + expect(JSON.parse(restoreRequest.options.body)).toEqual({ + recipe_id: 'recipe-resources', + }); + }); + + it('marks the checkpoint hash invalid only when the failure is unresolvable', async () => { + const recipeModal = await createRecipeModal(); + const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js'); + const requests = []; + global.fetch = vi.fn(async (url, options) => { + requests.push({ url: String(url), options }); + return { ok: true, json: async () => ({ success: true }) }; + }); + + // Explicit "model removed" signal: the entry becomes a rematch/ + // reconnect candidate (same rule as the LoRA resolve "not found"). + // Use an isolated copy so the hashInvalid mutation does not leak into + // the shared recipeWithResources fixture used by later tests. + const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); + fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); + downloadVersionWithDefaultsMock.mockResolvedValue(false); + downloadManager._lastDownloadError = 'Model not found'; + recipeModal.showRecipeDetails(isolatedRecipe); + await flushWiring(); + document.querySelector('.checkpoint-download').click(); + + await vi.waitFor(() => { + expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(true); + }); + const markRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid'); + expect(markRequest.options.method).toBe('POST'); + expect(JSON.parse(markRequest.options.body)).toEqual({ recipe_id: 'recipe-resources' }); + expect(recipeModal.currentRecipe.checkpoint.hashInvalid).toBe(true); + }); + + it('does not mark the checkpoint hash invalid on transient download failures', async () => { + const recipeModal = await createRecipeModal(); + const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js'); + const requests = []; + global.fetch = vi.fn(async (url, options) => { + requests.push({ url: String(url), options }); + return { ok: true, json: async () => ({ success: true }) }; + }); + + // Transport/API exceptions must NOT enroll the entry in the + // remediation flow — transient failures are not evidence the model is + // unrecoverable (mirrors the LoRA path). + downloadVersionWithDefaultsMock.mockRejectedValue(new Error('Network timeout')); + recipeModal.showRecipeDetails(recipeWithResources); + await flushWiring(); + document.querySelector('.checkpoint-download').click(); + + await new Promise(resolve => setTimeout(resolve, 100)); + expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false); + + // Business failure without an unresolvable signal also stays untouched. + downloadVersionWithDefaultsMock.mockResolvedValue(false); + downloadManager._lastDownloadError = 'Connection refused'; + await recipeModal.downloadCheckpoint(recipeModal.currentRecipe.checkpoint); + expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false); + }); + }); }); diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 63e1afe0..52097f4b 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -311,6 +311,28 @@ class StubPersistenceService: ) -> SimpleNamespace: # pragma: no cover return SimpleNamespace(payload={"success": True}, status=200) + async def reconnect_checkpoint( + self, *, recipe_scanner, recipe_id: str, target_name: str + ) -> SimpleNamespace: # pragma: no cover + return SimpleNamespace(payload={"success": True}, status=200) + + async def restore_checkpoint( + self, *, recipe_scanner, recipe_id: str + ) -> SimpleNamespace: # pragma: no cover + return SimpleNamespace(payload={"success": True}, status=200) + + async def get_checkpoint_reconnect_suggestions( + self, *, recipe_scanner, recipe_id: str, query: str | None = None + ) -> SimpleNamespace: # pragma: no cover + return SimpleNamespace( + payload={"success": True, "suggestions": []}, status=200 + ) + + async def mark_checkpoint_hash_invalid( + self, *, recipe_scanner, recipe_id: str, hash_invalid: bool = True + ) -> SimpleNamespace: # pragma: no cover + return SimpleNamespace(payload={"success": True}, status=200) + async def bulk_delete( self, *, recipe_scanner, recipe_ids: List[str] ) -> SimpleNamespace: # pragma: no cover @@ -2050,3 +2072,99 @@ async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys( assert len(groups) == 2 assert {g["type"] for g in groups} == {"fingerprint", "source_path"} assert len({g["key"] for g in groups}) == 2 + + +# --------------------------------------------------------------------------- +# Checkpoint reconnect routes (manual remediation for recipe.checkpoint) +# --------------------------------------------------------------------------- + + +async def test_checkpoint_reconnect_route(monkeypatch, tmp_path: Path) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipe/checkpoint/reconnect", + json={"recipe_id": "r1", "target_name": "main"}, + ) + payload = await response.json() + assert response.status == 200 + assert payload["success"] is True + + +async def test_checkpoint_reconnect_route_requires_target_name( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipe/checkpoint/reconnect", + json={"recipe_id": "r1"}, + ) + assert response.status == 400 + + +async def test_checkpoint_restore_route(monkeypatch, tmp_path: Path) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipe/checkpoint/restore", + json={"recipe_id": "r1"}, + ) + payload = await response.json() + assert response.status == 200 + assert payload["success"] is True + + +async def test_checkpoint_restore_route_requires_recipe_id( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipe/checkpoint/restore", + json={}, + ) + assert response.status == 400 + + +async def test_checkpoint_reconnect_suggestions_route( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.get( + "/api/lm/recipe/r1/checkpoint/reconnect-suggestions?query=main" + ) + payload = await response.json() + assert response.status == 200 + assert payload["success"] is True + assert payload["suggestions"] == [] + + +async def test_checkpoint_reconnect_suggestions_route_without_query( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.get( + "/api/lm/recipe/r1/checkpoint/reconnect-suggestions" + ) + payload = await response.json() + assert response.status == 200 + assert payload["success"] is True + + +async def test_checkpoint_mark_hash_invalid_route(monkeypatch, tmp_path: Path) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipe/checkpoint/mark-hash-invalid", + json={"recipe_id": "r1"}, + ) + payload = await response.json() + assert response.status == 200 + assert payload["success"] is True + + +async def test_checkpoint_mark_hash_invalid_route_requires_recipe_id( + monkeypatch, tmp_path: Path +) -> None: + async with recipe_harness(monkeypatch, tmp_path) as harness: + response = await harness.client.post( + "/api/lm/recipe/checkpoint/mark-hash-invalid", + json={}, + ) + assert response.status == 400 diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 2af6eb07..35229ca6 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -768,6 +768,251 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_ assert cleared_lora["hashInvalid"] is False +async def test_update_checkpoint_entry_updates_cache_and_file( + 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-ckpt-1" + recipe_path = recipes_dir / f"{recipe_id}.recipe.json" + original_checkpoint = { + "name": "Old Model", + "version": "v1", + "id": 1, + "type": "Checkpoint", + "baseModel": "SDXL 1.0", + "file_name": "old", + "hash": "aaa", + "isDeleted": True, + } + recipe_data = { + "id": recipe_id, + "file_path": str(tmp_path / "image.png"), + "title": "Original", + "modified": 0.0, + "created_date": 0.0, + "base_model": "SDXL 1.0", + "checkpoint": dict(original_checkpoint), + } + recipe_path.write_text(json.dumps(recipe_data)) + await scanner.add_recipe(dict(recipe_data)) + + target_info = { + "sha256": "abc123", + "file_path": str(tmp_path / "checkpoints" / "main.safetensors"), + "preview_url": "preview.png", + "model_name": "Main Model", + "base_model": "SDXL 1.0", + "civitai": {"id": 42, "name": "v2"}, + } + + updated_recipe, updated_checkpoint = await scanner.update_checkpoint_entry( + recipe_id, + target_name="main", + target_checkpoint=target_info, + ) + + # Write-back follows the pinned checkpoint key set, keeping the + # user-entered file_name. + assert updated_checkpoint["file_name"] == "main" + assert updated_checkpoint["hash"] == "abc123" + assert updated_checkpoint["isDeleted"] is False + assert updated_checkpoint["hashInvalid"] is False + assert updated_checkpoint["name"] == "Main Model" + assert updated_checkpoint["version"] == "v2" + assert updated_checkpoint["baseModel"] == "SDXL 1.0" + assert updated_checkpoint["id"] == 42 + # The pre-reconnect state is snapshotted for undo + assert updated_checkpoint["reconnectSnapshot"] == original_checkpoint + assert "reconnectSnapshot" not in updated_checkpoint["reconnectSnapshot"] + + with recipe_path.open("r", encoding="utf-8") as file_obj: + persisted = json.load(file_obj) + assert persisted["checkpoint"]["hash"] == "abc123" + assert persisted["checkpoint"]["reconnectSnapshot"] == original_checkpoint + + cache = await scanner.get_cached_data() + cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id) + assert cached_recipe["checkpoint"]["hash"] == "abc123" + + +async def test_update_checkpoint_entry_backfills_missing_display_keys( + 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-ckpt-sparse" + recipe_path = recipes_dir / f"{recipe_id}.recipe.json" + # Parser-style sparse entry without name/version/baseModel keys + recipe_data = { + "id": recipe_id, + "file_path": str(tmp_path / "image.png"), + "title": "Sparse", + "modified": 0.0, + "created_date": 0.0, + "checkpoint": {"file_name": "old", "hash": "aaa", "isDeleted": True}, + } + recipe_path.write_text(json.dumps(recipe_data)) + await scanner.add_recipe(dict(recipe_data)) + + target_info = { + "sha256": "abc123", + "file_path": "/models/checkpoints/main.safetensors", + "model_name": "Main Model", + "base_model": "SDXL 1.0", + "civitai": {"id": 42, "name": "v2"}, + } + + _, updated_checkpoint = await scanner.update_checkpoint_entry( + recipe_id, + target_name="main", + target_checkpoint=target_info, + ) + + assert updated_checkpoint["name"] == "Main Model" + assert updated_checkpoint["version"] == "v2" + assert updated_checkpoint["baseModel"] == "SDXL 1.0" + assert updated_checkpoint["modelVersionId"] == 42 + + +async def test_restore_checkpoint_entry_round_trip(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-ckpt-restore" + recipe_path = recipes_dir / f"{recipe_id}.recipe.json" + original_checkpoint = { + "name": "Old Model", + "file_name": "old", + "hash": "aaa", + "isDeleted": True, + } + recipe_data = { + "id": recipe_id, + "file_path": str(tmp_path / "image.png"), + "title": "Original", + "modified": 0.0, + "created_date": 0.0, + "checkpoint": dict(original_checkpoint), + } + recipe_path.write_text(json.dumps(recipe_data)) + await scanner.add_recipe(dict(recipe_data)) + + target_info = { + "sha256": "abc123", + "file_path": "/models/checkpoints/main.safetensors", + "model_name": "Main Model", + "civitai": {"id": 42, "name": "v2"}, + } + await scanner.update_checkpoint_entry( + recipe_id, target_name="main", target_checkpoint=target_info + ) + + restored_recipe, restored_checkpoint = await scanner.restore_checkpoint_entry( + recipe_id + ) + + assert restored_recipe["checkpoint"] == original_checkpoint + assert "reconnectSnapshot" not in restored_recipe["checkpoint"] + assert restored_checkpoint["file_name"] == "old" + + with recipe_path.open("r", encoding="utf-8") as file_obj: + persisted = json.load(file_obj) + assert persisted["checkpoint"] == original_checkpoint + + +async def test_restore_checkpoint_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-ckpt-no-snapshot" + recipe_path = recipes_dir / f"{recipe_id}.recipe.json" + recipe_path.write_text( + json.dumps({"id": recipe_id, "checkpoint": {"file_name": "plain"}}) + ) + + with pytest.raises(RecipeValidationError): + await scanner.restore_checkpoint_entry(recipe_id) + + +async def test_set_checkpoint_entry_hash_invalid_persists_flag( + 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 = "hash-invalid-ckpt" + recipe_path = recipes_dir / f"{recipe_id}.recipe.json" + recipe_data = { + "id": recipe_id, + "file_path": str(tmp_path / "image.png"), + "title": "Hash invalid", + "modified": 0.0, + "created_date": 0.0, + "checkpoint": {"name": "Old", "file_name": "old", "hash": "a2a12bfa01"}, + } + recipe_path.write_text(json.dumps(recipe_data)) + await scanner.add_recipe(dict(recipe_data)) + + updated_recipe, updated_checkpoint = await scanner.set_checkpoint_entry_hash_invalid( + recipe_id, True + ) + + assert updated_checkpoint["hashInvalid"] is True + assert updated_recipe["checkpoint"]["hashInvalid"] is True + with recipe_path.open("r", encoding="utf-8") as file_obj: + persisted = json.load(file_obj) + assert persisted["checkpoint"]["hashInvalid"] is True + assert persisted["checkpoint"]["hash"] == "a2a12bfa01" + + cache = await scanner.get_cached_data() + cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id) + assert cached_recipe["checkpoint"]["hashInvalid"] is True + + _, cleared_checkpoint = await scanner.set_checkpoint_entry_hash_invalid( + recipe_id, False + ) + assert cleared_checkpoint["hashInvalid"] is False + + +async def test_find_local_checkpoints_by_name_uses_checkpoint_scanner( + tmp_path: Path, monkeypatch +): + from py.services.recipe_scanner import RecipeScanner as RecipeScannerCls + + class StubCheckpointScanner: + async def find_models_by_name(self, name, *, base_model=None): + return [ + {"file_name": f"{name}.safetensors", "base_model": base_model or ""} + ] + + class StubLoraScannerForCkpt: + async def get_cached_data(self): + return SimpleNamespace(raw_data=[], version_index={}) + + RecipeScannerCls._instance = None + scanner = RecipeScannerCls( + lora_scanner=StubLoraScannerForCkpt(), + checkpoint_scanner=StubCheckpointScanner(), # pyright: ignore[reportArgumentType] + ) + + matches = await scanner.find_local_checkpoints_by_name("main") + assert matches == [{"file_name": "main.safetensors", "base_model": ""}] + + assert await scanner.find_local_checkpoints_by_name("") == [] + scanner._checkpoint_scanner = None + assert await scanner.find_local_checkpoints_by_name("main") == [] + + @pytest.mark.asyncio async def test_get_recipe_syntax_tokens_skips_unobtainable_loras(tmp_path: Path, recipe_scanner): scanner, _ = recipe_scanner diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py index 367c116e..9e899a0b 100644 --- a/tests/services/test_recipe_services.py +++ b/tests/services/test_recipe_services.py @@ -1676,3 +1676,306 @@ async def test_analyze_remote_image_meta_null_keeps_exif_loras(tmp_path, monkeyp assert loras[0]["hash"] == LORA_SHA256 assert loras[0].get("isDeleted") in (None, False) assert "Daphne" in str(payload.get("gen_params", {}).get("prompt")) + + +# --------------------------------------------------------------------------- +# Checkpoint reconnect chain (manual remediation for recipe.checkpoint) +# --------------------------------------------------------------------------- + + +def _make_persistence_service(): + return RecipePersistenceService( + exif_utils=DummyExifUtils(), + card_preview_width=512, + logger=logging.getLogger("test"), + ) + + +@pytest.mark.asyncio +async def test_reconnect_checkpoint_distinguishes_ambiguous_mismatched_and_missing(tmp_path): + service = _make_persistence_service() + + models = [ + { + "file_name": "realistic.safetensors", + "folder": "sdxl", + "file_path": "/models/checkpoints/sdxl/realistic.safetensors", + "base_model": "SDXL 1.0", + }, + { + "file_name": "realistic.safetensors", + "folder": "sd15", + "file_path": "/models/checkpoints/sd15/realistic.safetensors", + "base_model": "SD 1.5", + }, + ] + + class DummyScanner: + def __init__(self, recipe_path): + self._recipe_path = recipe_path + + async def get_recipe_json_path(self, recipe_id): + return str(self._recipe_path) + + async def find_local_checkpoints_by_name(self, name, base_model=None): + return ModelScanner.find_matching_models(models, name, base_model=base_model) + + def write_recipe(base_model): + recipe_path = tmp_path / "recipe.json" + recipe_path.write_text( + json.dumps({"id": "r1", "base_model": base_model, "checkpoint": {}}) + ) + return DummyScanner(recipe_path) + + # Ambiguous bare name: two candidates survive (recipe base model unknown) + scanner = write_recipe("") + with pytest.raises(RecipeValidationError, match="include the folder path"): + await service.reconnect_checkpoint( + recipe_scanner=scanner, recipe_id="r1", target_name="realistic" + ) + + # Confident base-model mismatch: the only candidate belongs to another family + scanner = write_recipe("SD 1.5") + with pytest.raises(RecipeValidationError, match="different base model"): + await service.reconnect_checkpoint( + recipe_scanner=scanner, recipe_id="r1", target_name="sdxl/realistic" + ) + + # No candidate at all + scanner = write_recipe("SDXL 1.0") + with pytest.raises(RecipeNotFoundError, match="not found"): + await service.reconnect_checkpoint( + recipe_scanner=scanner, recipe_id="r1", target_name="missing" + ) + + +@pytest.mark.asyncio +async def test_reconnect_checkpoint_family_compatible_succeeds_with_warning(tmp_path): + service = _make_persistence_service() + + pony_item = { + "file_name": "main.safetensors", + "folder": "", + "file_path": "/models/checkpoints/main.safetensors", + "base_model": "Pony", + "sha256": "ab" * 32, + } + + recipe_path = tmp_path / "recipe.json" + recipe_path.write_text( + json.dumps({"id": "r1", "base_model": "Illustrious", "checkpoint": {}}) + ) + + class DummyScanner: + async def get_recipe_json_path(self, recipe_id): + return str(recipe_path) + + async def find_local_checkpoints_by_name(self, name, base_model=None): + return [pony_item] + + async def update_checkpoint_entry(self, recipe_id, *, target_name, target_checkpoint): + assert target_checkpoint is pony_item + return ({"id": "r1"}, {"file_name": target_checkpoint["file_name"]}) + + async def find_recipes_by_fingerprint(self, fingerprint): + return [] + + result = await service.reconnect_checkpoint( + recipe_scanner=DummyScanner(), recipe_id="r1", target_name="main" + ) + + assert result.payload["success"] is True + assert result.payload["base_model_mismatch"] == { + "recipe_base_model": "Illustrious", + "checkpoint_base_model": "Pony", + } + + +@pytest.mark.asyncio +async def test_reconnect_checkpoint_exact_base_model_has_no_warning(tmp_path): + service = _make_persistence_service() + + item = { + "file_name": "main.safetensors", + "folder": "", + "file_path": "/models/checkpoints/main.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", "checkpoint": {}}) + ) + + class DummyScanner: + async def get_recipe_json_path(self, recipe_id): + return str(recipe_path) + + async def find_local_checkpoints_by_name(self, name, base_model=None): + return [item] + + async def update_checkpoint_entry(self, recipe_id, *, target_name, target_checkpoint): + return ({"id": "r1"}, {"file_name": target_checkpoint["file_name"]}) + + async def find_recipes_by_fingerprint(self, fingerprint): + return [] + + result = await service.reconnect_checkpoint( + recipe_scanner=DummyScanner(), recipe_id="r1", target_name="main" + ) + + assert result.payload["success"] is True + assert "base_model_mismatch" not in result.payload + + +@pytest.mark.asyncio +async def test_restore_checkpoint_delegates_and_reports(tmp_path): + service = _make_persistence_service() + + class DummyScanner: + async def restore_checkpoint_entry(self, recipe_id): + assert recipe_id == "r1" + return ( + {"id": "r1", "checkpoint": {"file_name": "old.safetensors"}}, + {"file_name": "old.safetensors"}, + ) + + async def find_recipes_by_fingerprint(self, fingerprint): + return [] + + result = await service.restore_checkpoint( + recipe_scanner=DummyScanner(), recipe_id="r1" + ) + + assert result.payload["success"] is True + assert result.payload["updated_checkpoint"]["file_name"] == "old.safetensors" + + +@pytest.mark.asyncio +async def test_get_checkpoint_reconnect_suggestions_loads_entry_and_delegates(tmp_path): + service = _make_persistence_service() + + recipe_path = tmp_path / "recipe.json" + recipe_path.write_text( + json.dumps( + { + "id": "r1", + "base_model": "SD 1.5", + "checkpoint": {"file_name": "old.safetensors", "hash": "aaa"}, + } + ) + ) + + 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_checkpoint_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": "new.safetensors", + "score": 1.0, + "match_reason": "same_hash", + "target_name": "new", + } + ] + + scanner = DummyScanner() + result = await service.get_checkpoint_reconnect_suggestions( + recipe_scanner=scanner, recipe_id="r1", query="new" + ) + + assert result.payload["success"] is True + assert result.payload["suggestions"][0]["target_name"] == "new" + assert scanner.calls == [ + { + "entry": {"file_name": "old.safetensors", "hash": "aaa"}, + "recipe_base_model": "SD 1.5", + "query": "new", + } + ] + + +@pytest.mark.asyncio +async def test_get_checkpoint_reconnect_suggestions_validates_recipe(tmp_path): + service = _make_persistence_service() + + class MissingScanner: + async def get_recipe_json_path(self, recipe_id): + return str(tmp_path / "missing.json") + + with pytest.raises(RecipeNotFoundError): + await service.get_checkpoint_reconnect_suggestions( + recipe_scanner=MissingScanner(), recipe_id="nope" + ) + + recipe_path = tmp_path / "recipe.json" + recipe_path.write_text(json.dumps({"id": "r1"})) + + class EmptyScanner: + async def get_recipe_json_path(self, recipe_id): + return str(recipe_path) + + with pytest.raises(RecipeValidationError, match="checkpoint"): + await service.get_checkpoint_reconnect_suggestions( + recipe_scanner=EmptyScanner(), recipe_id="r1" + ) + + +@pytest.mark.asyncio +async def test_mark_checkpoint_hash_invalid_delegates_and_reports(tmp_path): + service = _make_persistence_service() + + class DummyScanner: + async def set_checkpoint_entry_hash_invalid(self, recipe_id, hash_invalid): + assert recipe_id == "r1" + assert hash_invalid is True + return ( + {"id": "r1", "checkpoint": {"file_name": "m", "hashInvalid": True}}, + {"file_name": "m", "hashInvalid": True}, + ) + + result = await service.mark_checkpoint_hash_invalid( + recipe_scanner=DummyScanner(), recipe_id="r1" + ) + + assert result.payload["success"] is True + assert result.payload["recipe_id"] == "r1" + assert result.payload["hash_invalid"] is True + assert result.payload["updated_checkpoint"]["hashInvalid"] is True + + +@pytest.mark.asyncio +async def test_mark_checkpoint_hash_invalid_can_clear_flag(tmp_path): + service = _make_persistence_service() + + class DummyScanner: + async def set_checkpoint_entry_hash_invalid(self, recipe_id, hash_invalid): + assert hash_invalid is False + return ( + {"id": "r1", "checkpoint": {"file_name": "m", "hashInvalid": False}}, + {"file_name": "m", "hashInvalid": False}, + ) + + result = await service.mark_checkpoint_hash_invalid( + recipe_scanner=DummyScanner(), + recipe_id="r1", + hash_invalid=False, + ) + + assert result.payload["hash_invalid"] is False + assert result.payload["updated_checkpoint"]["hashInvalid"] is False