mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
feat(recipes): add manual checkpoint reconnect for broken recipe entries
Checkpoint entries that cannot be restored by download (deleted, unresolvable hash, or name-only remnants with no CivitAI identifiers) now get the same remediation chain LoRAs already had: - scanner: parameterized reconnect-suggestion ranking, update/restore/ set-hash-invalid for the checkpoint entry, and clear hashInvalid on rematch write-back (was only done for LoRAs) - persistence/handlers/routes: reconnect/restore/reconnect-suggestions/ mark-hash-invalid endpoints under /api/lm/recipe/checkpoint/* - modal: checkpoint reconnect UI (deleted/hash-invalid badges, inline form with suggestions, undo for reconnected entries); download failures mark the hash invalid only on explicit unresolvable signals (not found/deleted/404/410), matching the LoRA rule - css: checkpoint undo button shares the LoRA undo styles - i18n: the 14 new keys translated in all 9 locales
This commit is contained in:
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "Stellt {name} wieder her (die Verknüpfung vor dem Neuverknüpfen)",
|
"undoReconnectTooltipNamed": "Stellt {name} wieder her (die Verknüpfung vor dem Neuverknüpfen)",
|
||||||
"viewOnCivitai": "Auf CivitAI anzeigen",
|
"viewOnCivitai": "Auf CivitAI anzeigen",
|
||||||
"openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen",
|
"openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen",
|
||||||
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen"
|
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen",
|
||||||
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
||||||
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
||||||
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
|
"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",
|
"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",
|
"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}",
|
"downloadLoraFailed": "LoRA-Download fehlgeschlagen: {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "Restore to {name} (the association before reconnecting)",
|
"undoReconnectTooltipNamed": "Restore to {name} (the association before reconnecting)",
|
||||||
"viewOnCivitai": "View on CivitAI",
|
"viewOnCivitai": "View on CivitAI",
|
||||||
"openLoraDetails": "View {name} in the LoRA library",
|
"openLoraDetails": "View {name} in the LoRA library",
|
||||||
"openCheckpointDetails": "View {name} in the model library"
|
"openCheckpointDetails": "View {name} in the model library",
|
||||||
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "Checkpoint path not available",
|
"missingCheckpointPath": "Checkpoint path not available",
|
||||||
"missingCheckpointInfo": "Missing checkpoint information",
|
"missingCheckpointInfo": "Missing checkpoint information",
|
||||||
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
|
"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",
|
"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",
|
"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}",
|
"downloadLoraFailed": "Failed to download LoRA: {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "Restaurar a {name} (la asociación antes de reconectar)",
|
"undoReconnectTooltipNamed": "Restaurar a {name} (la asociación antes de reconectar)",
|
||||||
"viewOnCivitai": "Ver en CivitAI",
|
"viewOnCivitai": "Ver en CivitAI",
|
||||||
"openLoraDetails": "Ver {name} en la biblioteca de LoRAs",
|
"openLoraDetails": "Ver {name} en la biblioteca de LoRAs",
|
||||||
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos"
|
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos",
|
||||||
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
||||||
"missingCheckpointInfo": "Falta información del checkpoint",
|
"missingCheckpointInfo": "Falta información del checkpoint",
|
||||||
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
|
"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",
|
"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",
|
"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}",
|
"downloadLoraFailed": "Error al descargar el LoRA: {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "Restaurer vers {name} (l'association avant la reconnexion)",
|
"undoReconnectTooltipNamed": "Restaurer vers {name} (l'association avant la reconnexion)",
|
||||||
"viewOnCivitai": "Voir sur CivitAI",
|
"viewOnCivitai": "Voir sur CivitAI",
|
||||||
"openLoraDetails": "Voir {name} dans la bibliothèque LoRA",
|
"openLoraDetails": "Voir {name} dans la bibliothèque LoRA",
|
||||||
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles"
|
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles",
|
||||||
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
||||||
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
||||||
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
|
"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",
|
"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",
|
"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}",
|
"downloadLoraFailed": "Échec du téléchargement du LoRA : {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "שחזר ל-{name} (השיוך לפני החיבור מחדש)",
|
"undoReconnectTooltipNamed": "שחזר ל-{name} (השיוך לפני החיבור מחדש)",
|
||||||
"viewOnCivitai": "הצג ב-CivitAI",
|
"viewOnCivitai": "הצג ב-CivitAI",
|
||||||
"openLoraDetails": "הצג את {name} בספריית ה-LoRA",
|
"openLoraDetails": "הצג את {name} בספריית ה-LoRA",
|
||||||
"openCheckpointDetails": "הצג את {name} בספריית המודלים"
|
"openCheckpointDetails": "הצג את {name} בספריית המודלים",
|
||||||
|
"checkpointDeletedTooltip": "Checkpoint זה נמחק מהמקור ואינו זמין עוד להורדה - חבר אותו מחדש עם מודל מקומי",
|
||||||
|
"checkpointHashInvalidTooltip": "לא ניתן לפתור את ה-hash של Checkpoint זה ב-CivitAI - ייתכן שהמודל עודכן",
|
||||||
|
"reconnectCheckpoint": "חבר מחדש",
|
||||||
|
"reconnectCheckpointTooltip": "חבר מחדש עם Checkpoint מקומי",
|
||||||
|
"checkpointReconnectInstructions": "הזן שם של Checkpoint לחיבור מחדש:",
|
||||||
|
"checkpointReconnectPlaceholder": "הזן שם של Checkpoint",
|
||||||
|
"checkpointReconnectSuggestionsEmpty": "לא נמצאו Checkpoints תואמים בספרייה המקומית שלך"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
|
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
|
||||||
"missingCheckpointInfo": "חסרים פרטי checkpoint",
|
"missingCheckpointInfo": "חסרים פרטי checkpoint",
|
||||||
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
|
"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 זה",
|
"missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה",
|
||||||
"hashNotFoundOnCivitai": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן או שה-hash אינו תקין",
|
"hashNotFoundOnCivitai": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן או שה-hash אינו תקין",
|
||||||
"downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
|
"downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)",
|
"undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)",
|
||||||
"viewOnCivitai": "CivitAI で表示",
|
"viewOnCivitai": "CivitAI で表示",
|
||||||
"openLoraDetails": "LoRA ライブラリで {name} を表示",
|
"openLoraDetails": "LoRA ライブラリで {name} を表示",
|
||||||
"openCheckpointDetails": "モデルライブラリで {name} を表示"
|
"openCheckpointDetails": "モデルライブラリで {name} を表示",
|
||||||
|
"checkpointDeletedTooltip": "この Checkpoint はソースから削除されたため、ダウンロードできません - ローカルモデルで再接続してください",
|
||||||
|
"checkpointHashInvalidTooltip": "この Checkpoint のハッシュは CivitAI で解決できません - モデルが更新された可能性があります",
|
||||||
|
"reconnectCheckpoint": "再接続",
|
||||||
|
"reconnectCheckpointTooltip": "ローカルの Checkpoint と再接続",
|
||||||
|
"checkpointReconnectInstructions": "再接続する Checkpoint の名前を入力してください:",
|
||||||
|
"checkpointReconnectPlaceholder": "Checkpoint 名を入力",
|
||||||
|
"checkpointReconnectSuggestionsEmpty": "ローカルライブラリに一致するCheckpointがありません"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "Checkpointのパスがありません",
|
"missingCheckpointPath": "Checkpointのパスがありません",
|
||||||
"missingCheckpointInfo": "Checkpoint情報が不足しています",
|
"missingCheckpointInfo": "Checkpoint情報が不足しています",
|
||||||
"downloadCheckpointFailed": "Checkpointのダウンロードに失敗しました: {message}",
|
"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 のダウンロード情報がありません",
|
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
|
||||||
"hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります",
|
"hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります",
|
||||||
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
|
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}",
|
"undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}",
|
||||||
"viewOnCivitai": "CivitAI에서 보기",
|
"viewOnCivitai": "CivitAI에서 보기",
|
||||||
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
|
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
|
||||||
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기"
|
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기",
|
||||||
|
"checkpointDeletedTooltip": "이 Checkpoint는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다 - 로컬 모델로 다시 연결하세요",
|
||||||
|
"checkpointHashInvalidTooltip": "이 Checkpoint의 해시를 CivitAI에서 확인할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||||
|
"reconnectCheckpoint": "다시 연결",
|
||||||
|
"reconnectCheckpointTooltip": "로컬 Checkpoint와 다시 연결",
|
||||||
|
"checkpointReconnectInstructions": "다시 연결할 Checkpoint 이름을 입력하세요:",
|
||||||
|
"checkpointReconnectPlaceholder": "Checkpoint 이름 입력",
|
||||||
|
"checkpointReconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 Checkpoint가 없습니다"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "Checkpoint 경로를 사용할 수 없습니다",
|
"missingCheckpointPath": "Checkpoint 경로를 사용할 수 없습니다",
|
||||||
"missingCheckpointInfo": "Checkpoint 정보가 부족합니다",
|
"missingCheckpointInfo": "Checkpoint 정보가 부족합니다",
|
||||||
"downloadCheckpointFailed": "Checkpoint 다운로드 실패: {message}",
|
"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의 다운로드 정보가 없습니다",
|
"missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
|
||||||
"hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다",
|
"hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다",
|
||||||
"downloadLoraFailed": "LoRA 다운로드 실패: {message}",
|
"downloadLoraFailed": "LoRA 다운로드 실패: {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "Восстановить {name} (привязка до переподключения)",
|
"undoReconnectTooltipNamed": "Восстановить {name} (привязка до переподключения)",
|
||||||
"viewOnCivitai": "Открыть на CivitAI",
|
"viewOnCivitai": "Открыть на CivitAI",
|
||||||
"openLoraDetails": "Открыть {name} в библиотеке LoRA",
|
"openLoraDetails": "Открыть {name} в библиотеке LoRA",
|
||||||
"openCheckpointDetails": "Открыть {name} в библиотеке моделей"
|
"openCheckpointDetails": "Открыть {name} в библиотеке моделей",
|
||||||
|
"checkpointDeletedTooltip": "Этот чекпойнт был удалён из источника и больше не может быть скачан - переподключите его к локальной модели",
|
||||||
|
"checkpointHashInvalidTooltip": "Хеш этого чекпойнта не удаётся разрешить на CivitAI - возможно, модель была обновлена",
|
||||||
|
"reconnectCheckpoint": "Переподключить",
|
||||||
|
"reconnectCheckpointTooltip": "Переподключить к локальному чекпойнту",
|
||||||
|
"checkpointReconnectInstructions": "Введите имя чекпойнта для переподключения:",
|
||||||
|
"checkpointReconnectPlaceholder": "Введите имя чекпойнта",
|
||||||
|
"checkpointReconnectSuggestionsEmpty": "В локальной библиотеке нет подходящих чекпойнтов"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
||||||
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
||||||
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
||||||
|
"enterCheckpointName": "Введите имя чекпойнта",
|
||||||
|
"checkpointReconnectedSuccessfully": "Чекпойнт успешно переподключён",
|
||||||
|
"reconnectCheckpointBaseModelMismatch": "Переподключение выполнено, но базовые модели различаются (рецепт: {recipe}, чекпойнт: {checkpoint}) — они совместимы по архитектуре",
|
||||||
|
"checkpointReconnectFailed": "Ошибка переподключения чекпойнта: {message}",
|
||||||
|
"checkpointRestored": "Чекпойнт восстановлен к прежней привязке",
|
||||||
|
"checkpointRestoreFailed": "Ошибка восстановления чекпойнта: {message}",
|
||||||
|
"checkpointDownloadUnavailable": "Этот чекпойнт нельзя скачать без идентификаторов CivitAI - попробуйте переподключить его к локальному чекпойнту",
|
||||||
"missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
|
"missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
|
||||||
"hashNotFoundOnCivitai": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена или хеш недействителен",
|
"hashNotFoundOnCivitai": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена или хеш недействителен",
|
||||||
"downloadLoraFailed": "Не удалось скачать LoRA: {message}",
|
"downloadLoraFailed": "Не удалось скачать LoRA: {message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)",
|
"undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)",
|
||||||
"viewOnCivitai": "在 CivitAI 上查看",
|
"viewOnCivitai": "在 CivitAI 上查看",
|
||||||
"openLoraDetails": "在 LoRA 库中查看 {name}",
|
"openLoraDetails": "在 LoRA 库中查看 {name}",
|
||||||
"openCheckpointDetails": "在模型库中查看 {name}"
|
"openCheckpointDetails": "在模型库中查看 {name}",
|
||||||
|
"checkpointDeletedTooltip": "此 Checkpoint 已从来源删除,无法再下载 - 请使用本地模型重新关联",
|
||||||
|
"checkpointHashInvalidTooltip": "此 Checkpoint 的哈希无法在 CivitAI 上解析 - 模型可能已更新",
|
||||||
|
"reconnectCheckpoint": "重新关联",
|
||||||
|
"reconnectCheckpointTooltip": "与本地 Checkpoint 重新关联",
|
||||||
|
"checkpointReconnectInstructions": "输入 Checkpoint 名称以重新关联:",
|
||||||
|
"checkpointReconnectPlaceholder": "输入 Checkpoint 名称",
|
||||||
|
"checkpointReconnectSuggestionsEmpty": "本地库中没有匹配的 Checkpoint"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "缺少Checkpoint路径",
|
"missingCheckpointPath": "缺少Checkpoint路径",
|
||||||
"missingCheckpointInfo": "缺少Checkpoint信息",
|
"missingCheckpointInfo": "缺少Checkpoint信息",
|
||||||
"downloadCheckpointFailed": "下载Checkpoint失败:{message}",
|
"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 的下载信息",
|
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
|
||||||
"hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效",
|
"hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效",
|
||||||
"downloadLoraFailed": "下载 LoRA 失败:{message}",
|
"downloadLoraFailed": "下载 LoRA 失败:{message}",
|
||||||
|
|||||||
+15
-1
@@ -948,7 +948,14 @@
|
|||||||
"undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)",
|
"undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)",
|
||||||
"viewOnCivitai": "在 CivitAI 上檢視",
|
"viewOnCivitai": "在 CivitAI 上檢視",
|
||||||
"openLoraDetails": "在 LoRA 庫中檢視 {name}",
|
"openLoraDetails": "在 LoRA 庫中檢視 {name}",
|
||||||
"openCheckpointDetails": "在模型庫中檢視 {name}"
|
"openCheckpointDetails": "在模型庫中檢視 {name}",
|
||||||
|
"checkpointDeletedTooltip": "此 Checkpoint 已從來源刪除,無法再下載 - 請使用本地模型重新關聯",
|
||||||
|
"checkpointHashInvalidTooltip": "此 Checkpoint 的雜湊無法在 CivitAI 上解析 - 模型可能已更新",
|
||||||
|
"reconnectCheckpoint": "重新關聯",
|
||||||
|
"reconnectCheckpointTooltip": "與本地 Checkpoint 重新關聯",
|
||||||
|
"checkpointReconnectInstructions": "輸入 Checkpoint 名稱以重新關聯:",
|
||||||
|
"checkpointReconnectPlaceholder": "輸入 Checkpoint 名稱",
|
||||||
|
"checkpointReconnectSuggestionsEmpty": "本地庫中沒有符合的 Checkpoint"
|
||||||
},
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
@@ -2097,6 +2104,13 @@
|
|||||||
"missingCheckpointPath": "缺少Checkpoint路徑",
|
"missingCheckpointPath": "缺少Checkpoint路徑",
|
||||||
"missingCheckpointInfo": "缺少Checkpoint資訊",
|
"missingCheckpointInfo": "缺少Checkpoint資訊",
|
||||||
"downloadCheckpointFailed": "下載Checkpoint失敗:{message}",
|
"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 的下載資訊",
|
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
|
||||||
"hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效",
|
"hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效",
|
||||||
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
|
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ class RecipeHandlerSet:
|
|||||||
"restore_lora": self.management.restore_lora,
|
"restore_lora": self.management.restore_lora,
|
||||||
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
|
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
|
||||||
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
|
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
|
||||||
|
"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,
|
"find_duplicates": self.query.find_duplicates,
|
||||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||||
"bulk_delete": self.management.bulk_delete,
|
"bulk_delete": self.management.bulk_delete,
|
||||||
@@ -1683,6 +1687,116 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
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:
|
async def bulk_delete(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
|
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
|
||||||
),
|
),
|
||||||
|
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("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
|
|||||||
@@ -265,6 +265,49 @@ class RecipeScanner:
|
|||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Rank local LoRAs as reconnect candidates for a broken recipe entry.
|
"""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
|
Identity signals (same hash / same CivitAI model version) outrank
|
||||||
similarity signals (filename / model name fuzzy match). A confident
|
similarity signals (filename / model name fuzzy match). A confident
|
||||||
base-model mismatch (both sides known and different) is a hard
|
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):
|
if limit <= 0 or not isinstance(entry, dict):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
lora_scanner = self._lora_scanner
|
scanner = self._checkpoint_scanner if is_checkpoint else self._lora_scanner
|
||||||
if lora_scanner is None:
|
if scanner is None:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
data = await lora_scanner.get_cached_data()
|
data = await scanner.get_cached_data()
|
||||||
recipe_bm = (recipe_base_model or "").strip().casefold()
|
recipe_bm = (recipe_base_model or "").strip().casefold()
|
||||||
|
|
||||||
def _base_model_known_mismatch(item: dict[str, Any]) -> bool:
|
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.
|
# entry without a usable hash — same rule as the filename cache.
|
||||||
if not (item.get("sha256") or "").strip():
|
if not (item.get("sha256") or "").strip():
|
||||||
continue
|
continue
|
||||||
if not self._is_type_compatible(item, is_checkpoint=False):
|
if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
|
||||||
continue
|
continue
|
||||||
if _base_model_known_mismatch(item):
|
if _base_model_known_mismatch(item):
|
||||||
continue
|
continue
|
||||||
@@ -351,13 +394,16 @@ class RecipeScanner:
|
|||||||
if (
|
if (
|
||||||
isinstance(hit, dict)
|
isinstance(hit, dict)
|
||||||
and (hit.get("sha256") or "").strip()
|
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)
|
and not _base_model_known_mismatch(hit)
|
||||||
):
|
):
|
||||||
_consider(hit, 1.0 + _base_model_adjustment(hit), "same_hash")
|
_consider(hit, 1.0 + _base_model_adjustment(hit), "same_hash")
|
||||||
|
|
||||||
version_id = entry.get("modelVersionId") or entry.get("id")
|
version_id = entry.get("modelVersionId") or entry.get("id")
|
||||||
if version_id is not None:
|
if version_id is not None:
|
||||||
|
if is_checkpoint:
|
||||||
|
hit = self._get_checkpoint_from_version_index(str(version_id))
|
||||||
|
else:
|
||||||
hit = self._get_lora_from_version_index(str(version_id))
|
hit = self._get_lora_from_version_index(str(version_id))
|
||||||
if (
|
if (
|
||||||
isinstance(hit, dict)
|
isinstance(hit, dict)
|
||||||
@@ -367,6 +413,11 @@ class RecipeScanner:
|
|||||||
_consider(hit, 0.95 + _base_model_adjustment(hit), "same_version")
|
_consider(hit, 0.95 + _base_model_adjustment(hit), "same_version")
|
||||||
|
|
||||||
filename_source = query_text or (entry.get("file_name") or "")
|
filename_source = query_text or (entry.get("file_name") 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 "")
|
name_source = query_text or (entry.get("modelName") or "")
|
||||||
norm_filename_source = self._normalize_filename_key(filename_source)
|
norm_filename_source = self._normalize_filename_key(filename_source)
|
||||||
name_source_cf = name_source.casefold()
|
name_source_cf = name_source.casefold()
|
||||||
@@ -1496,6 +1547,7 @@ class RecipeScanner:
|
|||||||
identifier key when neither identifier form exists).
|
identifier key when neither identifier form exists).
|
||||||
"""
|
"""
|
||||||
entry["isDeleted"] = False
|
entry["isDeleted"] = False
|
||||||
|
entry["hashInvalid"] = False
|
||||||
|
|
||||||
new_hash = (item.get("sha256") or "").lower()
|
new_hash = (item.get("sha256") or "").lower()
|
||||||
if new_hash:
|
if new_hash:
|
||||||
@@ -3290,6 +3342,19 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return await self._lora_scanner.find_models_by_name(name, base_model=base_model)
|
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]]:
|
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."""
|
"""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))
|
updated_lora = self._enrich_lora_entry(dict(lora_entry))
|
||||||
return recipe_data, updated_lora
|
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]]:
|
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
|
||||||
"""Return recipes that reference a given LoRA hash."""
|
"""Return recipes that reference a given LoRA hash."""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
async def bulk_delete(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -957,8 +957,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Restore icon for manually reconnected entries: its presence on the info
|
/* Restore icon for manually reconnected entries: its presence on the info
|
||||||
row doubles as the "was reconnected" marker. */
|
row doubles as the "was reconnected" marker. Shared by LoRA and
|
||||||
.lora-undo-reconnect {
|
checkpoint entries, which use the same info-row flex layout. */
|
||||||
|
.lora-undo-reconnect,
|
||||||
|
.checkpoint-undo-reconnect {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -973,7 +975,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lora-undo-reconnect:hover,
|
.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;
|
opacity: 1;
|
||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
|
|||||||
@@ -848,6 +848,14 @@ class RecipeModal {
|
|||||||
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
|
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
|
||||||
|
|
||||||
if (checkpointContainer) {
|
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 = '';
|
checkpointContainer.innerHTML = '';
|
||||||
if (recipe.checkpoint && typeof recipe.checkpoint === 'object') {
|
if (recipe.checkpoint && typeof recipe.checkpoint === 'object') {
|
||||||
checkpointContainer.innerHTML = this.renderCheckpoint(recipe.checkpoint);
|
checkpointContainer.innerHTML = this.renderCheckpoint(recipe.checkpoint);
|
||||||
@@ -1936,7 +1944,9 @@ class RecipeModal {
|
|||||||
},
|
},
|
||||||
// emptyText only labels the dropdown empty state; the input keeps
|
// emptyText only labels the dropdown empty state; the input keeps
|
||||||
// its own translated placeholder from the markup.
|
// 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) => {
|
onCommit: (value) => {
|
||||||
this.reconnectLora(loraIndex, value);
|
this.reconnectLora(loraIndex, value);
|
||||||
},
|
},
|
||||||
@@ -1963,7 +1973,10 @@ class RecipeModal {
|
|||||||
|
|
||||||
async _fetchReconnectSuggestions(loraIndex, query) {
|
async _fetchReconnectSuggestions(loraIndex, query) {
|
||||||
const suffix = query ? `?query=${encodeURIComponent(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) {
|
if (!response.ok) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -2004,7 +2017,9 @@ class RecipeModal {
|
|||||||
if (!suggestions.length) {
|
if (!suggestions.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'reconnect-suggestions-empty';
|
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);
|
listElement.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2061,6 +2076,12 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reconnectLora(loraIndex, inputValue) {
|
async reconnectLora(loraIndex, inputValue) {
|
||||||
|
// The checkpoint entry reuses the same container/combobox machinery;
|
||||||
|
// route it to the checkpoint-specific flow (no <lora:...> 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}"]`);
|
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
|
||||||
|
|
||||||
if (!inputValue || !inputValue.trim()) {
|
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) {
|
renderCheckpoint(checkpoint) {
|
||||||
const existsLocally = !!checkpoint.inLibrary;
|
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 localPath = checkpoint.localPath || '';
|
||||||
const previewUrl = checkpoint.preview_url || checkpoint.thumbnailUrl || '/loras_static/images/no-preview.png';
|
const previewUrl = checkpoint.preview_url || checkpoint.thumbnailUrl || '/loras_static/images/no-preview.png';
|
||||||
const isPreviewVideo = typeof previewUrl === 'string' && previewUrl.toLowerCase().endsWith('.mp4');
|
const isPreviewVideo = typeof previewUrl === 'string' && previewUrl.toLowerCase().endsWith('.mp4');
|
||||||
@@ -2194,18 +2360,38 @@ class RecipeModal {
|
|||||||
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||||
|
|
||||||
// Status badge: pure indicator with a tooltip, mirroring the LoRA
|
// Status badge: pure indicator with a tooltip, mirroring the LoRA
|
||||||
// items and the versions-tab badge pattern. The header carries only
|
// items and the versions-tab badge pattern. Deleted / unresolvable
|
||||||
// the badge; every action lives in the bottom action row.
|
// hash states render the same fixable-broken badges as LoRA entries.
|
||||||
const badge = existsLocally ? `
|
let badge;
|
||||||
|
if (existsLocally) {
|
||||||
|
badge = `
|
||||||
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
|
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
|
||||||
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
|
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
|
||||||
</div>
|
</div>
|
||||||
` : `
|
`;
|
||||||
|
} else if (isDeleted) {
|
||||||
|
badge = `
|
||||||
|
<div class="deleted-badge" title="${escapeHtml(translate('recipes.resources.checkpointDeletedTooltip', {}, 'This checkpoint was deleted from the source and can no longer be downloaded - reconnect it with a local model'))}">
|
||||||
|
<i class="fas fa-trash-alt" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (hashInvalid) {
|
||||||
|
badge = `
|
||||||
|
<div class="invalid-hash-badge" title="${escapeHtml(translate('recipes.resources.checkpointHashInvalidTooltip', {}, 'This checkpoint hash cannot be resolved on CivitAI - the model may have been updated'))}">
|
||||||
|
<i class="fas fa-question-circle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.hashInvalid', {}, 'Unresolvable Hash'))}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
badge = `
|
||||||
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
|
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
|
||||||
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
|
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = [];
|
const actions = [];
|
||||||
if (existsLocally && localPath) {
|
if (existsLocally && localPath) {
|
||||||
actions.push(`
|
actions.push(`
|
||||||
@@ -2214,6 +2400,16 @@ class RecipeModal {
|
|||||||
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
||||||
</button>
|
</button>
|
||||||
`);
|
`);
|
||||||
|
} else if (broken) {
|
||||||
|
const reconnectLabel = translate('recipes.resources.reconnectCheckpoint', {}, 'Reconnect');
|
||||||
|
const reconnectTooltip = translate('recipes.resources.reconnectCheckpointTooltip', {}, 'Reconnect with a local checkpoint');
|
||||||
|
actions.push(`
|
||||||
|
<button type="button" class="resource-action ghost compact checkpoint-reconnect"
|
||||||
|
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
|
||||||
|
<i class="fas fa-link" aria-hidden="true"></i>
|
||||||
|
<span>${escapeHtml(reconnectLabel)}</span>
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
} else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) {
|
} else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) {
|
||||||
actions.push(`
|
actions.push(`
|
||||||
<button class="resource-action primary compact checkpoint-download">
|
<button class="resource-action primary compact checkpoint-download">
|
||||||
@@ -2228,15 +2424,56 @@ class RecipeModal {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Civitai link lives inline with the title, same as LoRA items.
|
// Civitai link lives inline with the title, same as LoRA items.
|
||||||
const titleLink = this.renderCivitaiLink(this.getResourceCivitaiUrl(checkpoint));
|
// Skipped for deleted models: their source page is gone.
|
||||||
|
const titleLink = isDeleted
|
||||||
|
? ''
|
||||||
|
: this.renderCivitaiLink(this.getResourceCivitaiUrl(checkpoint));
|
||||||
|
|
||||||
// Only in-library checkpoints are row-navigable; make it keyboard-accessible.
|
// Only in-library checkpoints are row-navigable; make it keyboard-accessible.
|
||||||
const rowA11yAttributes = existsLocally
|
const rowA11yAttributes = existsLocally
|
||||||
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openCheckpointDetails', { name: checkpointName }, `View ${checkpointName} in the model library`))}"`
|
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openCheckpointDetails', { name: checkpointName }, `View ${checkpointName} in the model library`))}"`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// A reconnect snapshot marks a manually reconnected entry. The restore
|
||||||
|
// icon on the info row doubles as that marker (mirrors LoRA entries).
|
||||||
|
let undoReconnectIcon = '';
|
||||||
|
if (existsLocally && checkpoint.reconnectSnapshot) {
|
||||||
|
const previousName = checkpoint.reconnectSnapshot.name
|
||||||
|
|| checkpoint.reconnectSnapshot.file_name
|
||||||
|
|| checkpoint.reconnectSnapshot.modelName
|
||||||
|
|| '';
|
||||||
|
const undoLabel = translate('recipes.resources.undoReconnect', {}, 'Undo');
|
||||||
|
const undoTooltip = previousName
|
||||||
|
? translate('recipes.resources.undoReconnectTooltipNamed', { name: previousName }, `Restore to ${previousName} (the association before reconnecting)`)
|
||||||
|
: translate('recipes.resources.undoReconnectTooltip', {}, 'Restore the association this entry had before reconnecting');
|
||||||
|
undoReconnectIcon = `
|
||||||
|
<button type="button" class="checkpoint-undo-reconnect"
|
||||||
|
title="${escapeHtml(undoTooltip)}" aria-label="${escapeHtml(undoTooltip)}">
|
||||||
|
<i class="fas fa-rotate-left" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline reconnect form for broken entries, sharing the LoRA
|
||||||
|
// container structure/classes and the combobox interaction.
|
||||||
|
const reconnectContainer = broken ? `
|
||||||
|
<div class="lora-reconnect-container" data-lora-index="checkpoint">
|
||||||
|
<div class="reconnect-instructions">
|
||||||
|
<p>${escapeHtml(translate('recipes.resources.checkpointReconnectInstructions', {}, 'Enter checkpoint name to reconnect:'))}</p>
|
||||||
|
</div>
|
||||||
|
<div class="reconnect-form">
|
||||||
|
<input type="text" class="reconnect-input" placeholder="${escapeHtml(translate('recipes.resources.checkpointReconnectPlaceholder', {}, 'Enter checkpoint name'))}">
|
||||||
|
<div class="reconnect-actions">
|
||||||
|
<button class="reconnect-cancel-btn">${escapeHtml(translate('common.cancel', {}, 'Cancel'))}</button>
|
||||||
|
<button class="reconnect-confirm-btn">${escapeHtml(translate('recipes.resources.reconnect', {}, 'Reconnect'))}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="reconnect-suggestions"></div>
|
||||||
|
<p class="reconnect-error" role="alert"></p>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : 'missing-locally'}"${rowA11yAttributes}>
|
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : (isDeleted ? 'is-deleted' : 'missing-locally')}"${rowA11yAttributes}>
|
||||||
<div class="recipe-lora-thumbnail">
|
<div class="recipe-lora-thumbnail">
|
||||||
${previewMedia}
|
${previewMedia}
|
||||||
</div>
|
</div>
|
||||||
@@ -2252,9 +2489,11 @@ class RecipeModal {
|
|||||||
${versionLabel ? `<div class="recipe-lora-version">${versionLabel}</div>` : ''}
|
${versionLabel ? `<div class="recipe-lora-version">${versionLabel}</div>` : ''}
|
||||||
${baseModel ? `<div class="base-model">${baseModel}</div>` : ''}
|
${baseModel ? `<div class="base-model">${baseModel}</div>` : ''}
|
||||||
${modelTypeLabel ? `<span class="checkpoint-type-text">${modelTypeLabel}</span>` : ''}
|
${modelTypeLabel ? `<span class="checkpoint-type-text">${modelTypeLabel}</span>` : ''}
|
||||||
|
${undoReconnectIcon}
|
||||||
</div>
|
</div>
|
||||||
${actionsRow}
|
${actionsRow}
|
||||||
</div>
|
</div>
|
||||||
|
${reconnectContainer}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -2275,6 +2514,27 @@ class RecipeModal {
|
|||||||
await this.downloadCheckpoint(checkpoint, downloadBtn);
|
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) {
|
setupCheckpointNavigation(container, checkpoint) {
|
||||||
@@ -2357,7 +2617,14 @@ class RecipeModal {
|
|||||||
|
|
||||||
async downloadCheckpoint(checkpoint, button) {
|
async downloadCheckpoint(checkpoint, button) {
|
||||||
if (!this.canDownloadCheckpoint(checkpoint)) {
|
if (!this.canDownloadCheckpoint(checkpoint)) {
|
||||||
|
// 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');
|
showToast('toast.recipes.missingCheckpointInfo', {}, 'error');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2381,6 +2648,17 @@ class RecipeModal {
|
|||||||
);
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
await this.refreshResourcesAfterDownload();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error downloading checkpoint:', 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) {
|
getResourceCivitaiUrl(resource) {
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1182,10 +1182,13 @@ export class DownloadManager {
|
|||||||
if (!response?.success) {
|
if (!response?.success) {
|
||||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||||
const errorMessage = response?.error || 'Unknown error';
|
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
|
// When the caller aggregates failures itself (multi-file
|
||||||
// loop), just record the error and return (#1058).
|
// loop), just record the error and return (#1058).
|
||||||
if (suppressFailureSummary) {
|
if (suppressFailureSummary) {
|
||||||
this._lastDownloadError = errorMessage;
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// A file-level "already in library" rejection is an expected
|
// A file-level "already in library" rejection is an expected
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
|||||||
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||||
downloadManager: {
|
downloadManager: {
|
||||||
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
||||||
|
_lastDownloadError: '',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -776,4 +777,321 @@ describe('RecipeModal resource item interactions', () => {
|
|||||||
lora_index: '0',
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -311,6 +311,28 @@ class StubPersistenceService:
|
|||||||
) -> SimpleNamespace: # pragma: no cover
|
) -> SimpleNamespace: # pragma: no cover
|
||||||
return SimpleNamespace(payload={"success": True}, status=200)
|
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(
|
async def bulk_delete(
|
||||||
self, *, recipe_scanner, recipe_ids: List[str]
|
self, *, recipe_scanner, recipe_ids: List[str]
|
||||||
) -> SimpleNamespace: # pragma: no cover
|
) -> 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 len(groups) == 2
|
||||||
assert {g["type"] for g in groups} == {"fingerprint", "source_path"}
|
assert {g["type"] for g in groups} == {"fingerprint", "source_path"}
|
||||||
assert len({g["key"] for g in groups}) == 2
|
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
|
||||||
|
|||||||
@@ -768,6 +768,251 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_
|
|||||||
assert cleared_lora["hashInvalid"] is False
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_recipe_syntax_tokens_skips_unobtainable_loras(tmp_path: Path, recipe_scanner):
|
async def test_get_recipe_syntax_tokens_skips_unobtainable_loras(tmp_path: Path, recipe_scanner):
|
||||||
scanner, _ = recipe_scanner
|
scanner, _ = recipe_scanner
|
||||||
|
|||||||
@@ -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]["hash"] == LORA_SHA256
|
||||||
assert loras[0].get("isDeleted") in (None, False)
|
assert loras[0].get("isDeleted") in (None, False)
|
||||||
assert "Daphne" in str(payload.get("gen_params", {}).get("prompt"))
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user