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:
Will Miao
2026-08-30 18:02:15 +08:00
parent bce7d1d30c
commit c8b9db5bf4
21 changed files with 2031 additions and 38 deletions
+15 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}",
+114
View File
@@ -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()
+16
View File
@@ -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(
+278 -5
View File
@@ -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."""
+166
View File
@@ -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,
*, *,
+7 -3
View File
@@ -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);
+302 -9
View File
@@ -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;
+4 -1
View File
@@ -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);
});
});
}); });
+118
View File
@@ -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
+245
View File
@@ -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
+303
View File
@@ -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