mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
4 Commits
3fd29f6943
...
d761ac77f7
| Author | SHA1 | Date | |
|---|---|---|---|
| d761ac77f7 | |||
| c8b9db5bf4 | |||
| bce7d1d30c | |||
| bccd494a56 |
@@ -72,6 +72,11 @@ python scripts/sync_translation_keys.py
|
||||
|
||||
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
|
||||
|
||||
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
|
||||
the other locales are the expected end state during feature development. Do NOT translate
|
||||
proactively — translate only when the feature owner explicitly asks (see
|
||||
`docs/i18n-translation-guidelines.md` §7).
|
||||
|
||||
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
|
||||
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
|
||||
brand names are never translated), per-locale preferred renderings, placeholder rules, and
|
||||
|
||||
@@ -23,7 +23,9 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
same nested key set. `tests/i18n/test_i18n.py` enforces this.
|
||||
- When a new UI string is added to `en.json`, run
|
||||
`python scripts/sync_translation_keys.py` (adds the missing keys to all locales with
|
||||
placeholder copies), then translate the newly added keys in every locale.
|
||||
`[TODO: Translate]` placeholder copies) — **then stop**. Do NOT translate proactively:
|
||||
placeholders are the expected end state during feature development, and translations are
|
||||
filled in only when the feature owner explicitly asks (workflow details in §7).
|
||||
- Never reorder, re-indent, or reformat a locale file "for tidiness". The sync script
|
||||
preserves formatting; manual reformatting creates noisy diffs.
|
||||
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Dieses Modell ist nicht in Ihrer Bibliothek",
|
||||
"deletedTooltip": "Dieses LoRA wurde an der Quelle gelöscht und kann nicht mehr heruntergeladen werden",
|
||||
"hashInvalidTooltip": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert",
|
||||
"noLorasAssociated": "Keine LoRAs mit diesem Rezept verknüpft",
|
||||
"noLorasWhyToggle": "Warum keine LoRAs?",
|
||||
"noLorasImportMethod": "Importmethode",
|
||||
"noLorasInferredNote": "Mögliche Ursache (abgeleitet) — dieses Rezept wurde importiert, bevor Importdiagnosen aufgezeichnet wurden.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Massenimport (Bild-URL)",
|
||||
"batch_import_local": "Massenimport (lokale Datei)",
|
||||
"url": "Bild-URL-Import",
|
||||
"local": "Import lokaler Datei",
|
||||
"upload": "Bild-Upload",
|
||||
"widget": "Aus Workflow gespeichert",
|
||||
"reimport_url": "Neuimport (Bild-URL)",
|
||||
"reimport_local": "Neuimport (lokale Datei)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Die Generierungsmetadaten sind vollständig und verweisen auf keine LoRAs.",
|
||||
"api_meta_no_lora_resources": "Die Quell-API hat für dieses Bild keine LoRA-Ressourcendaten zurückgegeben. Auf der CivitAI-Seite angezeigte LoRAs stammen möglicherweise aus internen Daten, die die öffentliche API nicht bereitstellt.",
|
||||
"api_meta_missing": "Die Quell-API hat für dieses Bild keine Generierungsmetadaten zurückgegeben.",
|
||||
"no_embedded_metadata": "Das Bild enthält keine eingebetteten Generierungsmetadaten, sodass LoRA-Informationen nicht wiederhergestellt werden konnten.",
|
||||
"workflow_metadata_limited": "Die eingebetteten Metadaten des Bildes sind ein ComfyUI-Workflow; das Extrahieren von LoRA-Informationen aus Workflows ist eingeschränkt.",
|
||||
"video_no_metadata": "Videodateien enthalten keine eingebetteten Generierungsmetadaten.",
|
||||
"metadata_unsupported": "Das Bild enthält Metadaten in einem Format, das nicht analysiert werden konnte.",
|
||||
"unknown": "Die Ursache konnte aus den gespeicherten Rezeptdaten nicht ermittelt werden."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API-Metadatenfelder",
|
||||
"modelVersionIds": "Gemeldete Modellversions-IDs",
|
||||
"embeddedMetadata": "Eingebettete Metadaten",
|
||||
"present": "gefunden",
|
||||
"absent": "keine"
|
||||
},
|
||||
"download": "Herunterladen",
|
||||
"downloadLoraTooltip": "Dieses LoRA herunterladen",
|
||||
"preparingDownload": "Download wird vorbereitet...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "Stellt {name} wieder her (die Verknüpfung vor dem Neuverknüpfen)",
|
||||
"viewOnCivitai": "Auf CivitAI anzeigen",
|
||||
"openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen",
|
||||
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen"
|
||||
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen",
|
||||
"checkpointDeletedTooltip": "Dieser Checkpoint wurde aus der Quelle gelöscht und kann nicht mehr heruntergeladen werden - verknüpfen Sie ihn mit einem lokalen Modell neu",
|
||||
"checkpointHashInvalidTooltip": "Dieser Checkpoint-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert",
|
||||
"reconnectCheckpoint": "Neu verknüpfen",
|
||||
"reconnectCheckpointTooltip": "Mit einem lokalen Checkpoint neu verknüpfen",
|
||||
"checkpointReconnectInstructions": "Geben Sie den Namen des Checkpoints zum Neuverknüpfen ein:",
|
||||
"checkpointReconnectPlaceholder": "Name des Checkpoints eingeben",
|
||||
"checkpointReconnectSuggestionsEmpty": "Keine passenden Checkpoints in Ihrer lokalen Bibliothek"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
||||
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
||||
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
|
||||
"enterCheckpointName": "Bitte geben Sie einen Checkpoint-Namen ein",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint erfolgreich neu verbunden",
|
||||
"reconnectCheckpointBaseModelMismatch": "Neuverbindung erfolgreich, aber die Basismodelle unterscheiden sich (Rezept: {recipe}, Checkpoint: {checkpoint}) — sie sind architekturkompatibel",
|
||||
"checkpointReconnectFailed": "Fehler beim Neuverbinden des Checkpoints: {message}",
|
||||
"checkpointRestored": "Checkpoint auf die vorherige Verknüpfung zurückgesetzt",
|
||||
"checkpointRestoreFailed": "Fehler beim Wiederherstellen des Checkpoints: {message}",
|
||||
"checkpointDownloadUnavailable": "Dieser Checkpoint kann ohne CivitAI-Kennungen nicht heruntergeladen werden - versuchen Sie, ihn mit einem lokalen Checkpoint neu zu verknüpfen",
|
||||
"missingLoraDownloadInfo": "Download-Informationen für dieses LoRA fehlen",
|
||||
"hashNotFoundOnCivitai": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert oder der Hash ist ungültig",
|
||||
"downloadLoraFailed": "LoRA-Download fehlgeschlagen: {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "This model is not in your library",
|
||||
"deletedTooltip": "This LoRA was deleted from the source and is no longer available for download",
|
||||
"hashInvalidTooltip": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated",
|
||||
"noLorasAssociated": "No LoRAs associated with this recipe",
|
||||
"noLorasWhyToggle": "Why no LoRAs?",
|
||||
"noLorasImportMethod": "Import method",
|
||||
"noLorasInferredNote": "Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Batch import (image URL)",
|
||||
"batch_import_local": "Batch import (local file)",
|
||||
"url": "Image URL import",
|
||||
"local": "Local file import",
|
||||
"upload": "Image upload",
|
||||
"widget": "Saved from workflow",
|
||||
"reimport_url": "Re-import (image URL)",
|
||||
"reimport_local": "Re-import (local file)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "The generation metadata is complete and does not reference any LoRAs.",
|
||||
"api_meta_no_lora_resources": "The source API returned no LoRA resource data for this image. LoRAs shown on the CivitAI page may come from internal data that the public API does not expose.",
|
||||
"api_meta_missing": "The source API returned no generation metadata for this image.",
|
||||
"no_embedded_metadata": "The image has no embedded generation metadata, so LoRA information could not be recovered.",
|
||||
"workflow_metadata_limited": "The image's embedded metadata is a ComfyUI workflow; extracting LoRA information from workflows is limited.",
|
||||
"video_no_metadata": "Video files do not carry embedded generation metadata.",
|
||||
"metadata_unsupported": "The image contains metadata in a format that could not be parsed.",
|
||||
"unknown": "The reason could not be determined from the stored recipe data."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API metadata fields",
|
||||
"modelVersionIds": "Model version IDs reported",
|
||||
"embeddedMetadata": "Embedded metadata",
|
||||
"present": "found",
|
||||
"absent": "none"
|
||||
},
|
||||
"download": "Download",
|
||||
"downloadLoraTooltip": "Download this LoRA",
|
||||
"preparingDownload": "Preparing download...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "Restore to {name} (the association before reconnecting)",
|
||||
"viewOnCivitai": "View on CivitAI",
|
||||
"openLoraDetails": "View {name} in the LoRA library",
|
||||
"openCheckpointDetails": "View {name} in the model library"
|
||||
"openCheckpointDetails": "View {name} in the model library",
|
||||
"checkpointDeletedTooltip": "This checkpoint was deleted from the source and can no longer be downloaded - reconnect it with a local model",
|
||||
"checkpointHashInvalidTooltip": "This checkpoint hash cannot be resolved on CivitAI - the model may have been updated",
|
||||
"reconnectCheckpoint": "Reconnect",
|
||||
"reconnectCheckpointTooltip": "Reconnect with a local checkpoint",
|
||||
"checkpointReconnectInstructions": "Enter checkpoint name to reconnect:",
|
||||
"checkpointReconnectPlaceholder": "Enter checkpoint name",
|
||||
"checkpointReconnectSuggestionsEmpty": "No matching checkpoints in your local library"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "Checkpoint path not available",
|
||||
"missingCheckpointInfo": "Missing checkpoint information",
|
||||
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
|
||||
"enterCheckpointName": "Please enter a checkpoint name",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint reconnected successfully",
|
||||
"reconnectCheckpointBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, checkpoint: {checkpoint}) — they are architecture-compatible",
|
||||
"checkpointReconnectFailed": "Error reconnecting checkpoint: {message}",
|
||||
"checkpointRestored": "Checkpoint restored to its previous association",
|
||||
"checkpointRestoreFailed": "Error restoring checkpoint: {message}",
|
||||
"checkpointDownloadUnavailable": "This checkpoint cannot be downloaded without CivitAI identifiers - try reconnecting it with a local checkpoint",
|
||||
"missingLoraDownloadInfo": "Missing download information for this LoRA",
|
||||
"hashNotFoundOnCivitai": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated or the hash is invalid",
|
||||
"downloadLoraFailed": "Failed to download LoRA: {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Este modelo no está en tu biblioteca",
|
||||
"deletedTooltip": "Este LoRA fue eliminado de la fuente y ya no se puede descargar",
|
||||
"hashInvalidTooltip": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado",
|
||||
"noLorasAssociated": "No hay LoRAs asociados con esta receta",
|
||||
"noLorasWhyToggle": "¿Por qué no hay LoRAs?",
|
||||
"noLorasImportMethod": "Método de importación",
|
||||
"noLorasInferredNote": "Posible motivo (inferido): esta receta se importó antes de que se registraran los diagnósticos de importación.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Importación por lotes (URL de imagen)",
|
||||
"batch_import_local": "Importación por lotes (archivo local)",
|
||||
"url": "Importación desde URL de imagen",
|
||||
"local": "Importación de archivo local",
|
||||
"upload": "Carga de imagen",
|
||||
"widget": "Guardada desde el workflow",
|
||||
"reimport_url": "Reimportación (URL de imagen)",
|
||||
"reimport_local": "Reimportación (archivo local)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Los metadatos de generación están completos y no hacen referencia a ningún LoRA.",
|
||||
"api_meta_no_lora_resources": "La API de origen no devolvió datos de recursos LoRA para esta imagen. Los LoRAs que se muestran en la página de CivitAI pueden proceder de datos internos que la API pública no expone.",
|
||||
"api_meta_missing": "La API de origen no devolvió metadatos de generación para esta imagen.",
|
||||
"no_embedded_metadata": "La imagen no tiene metadatos de generación incrustados, por lo que no se pudo recuperar la información de LoRAs.",
|
||||
"workflow_metadata_limited": "Los metadatos incrustados en la imagen son un workflow de ComfyUI; la extracción de información de LoRAs a partir de workflows es limitada.",
|
||||
"video_no_metadata": "Los archivos de vídeo no contienen metadatos de generación incrustados.",
|
||||
"metadata_unsupported": "La imagen contiene metadatos en un formato que no se pudo analizar.",
|
||||
"unknown": "No se pudo determinar el motivo a partir de los datos de la receta almacenados."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Campos de metadatos de la API",
|
||||
"modelVersionIds": "IDs de versión de modelo informados",
|
||||
"embeddedMetadata": "Metadatos incrustados",
|
||||
"present": "encontrados",
|
||||
"absent": "ninguno"
|
||||
},
|
||||
"download": "Descargar",
|
||||
"downloadLoraTooltip": "Descargar este LoRA",
|
||||
"preparingDownload": "Preparando descarga...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "Restaurar a {name} (la asociación antes de reconectar)",
|
||||
"viewOnCivitai": "Ver en CivitAI",
|
||||
"openLoraDetails": "Ver {name} en la biblioteca de LoRAs",
|
||||
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos"
|
||||
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos",
|
||||
"checkpointDeletedTooltip": "Este checkpoint fue eliminado de la fuente y ya no se puede descargar - reconéctalo con un modelo local",
|
||||
"checkpointHashInvalidTooltip": "El hash de este checkpoint no se puede resolver en CivitAI - el modelo puede haber sido actualizado",
|
||||
"reconnectCheckpoint": "Reconectar",
|
||||
"reconnectCheckpointTooltip": "Reconectar con un checkpoint local",
|
||||
"checkpointReconnectInstructions": "Introduce el nombre del checkpoint para reconectar:",
|
||||
"checkpointReconnectPlaceholder": "Introduce el nombre del checkpoint",
|
||||
"checkpointReconnectSuggestionsEmpty": "No hay checkpoints coincidentes en tu biblioteca local"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
||||
"missingCheckpointInfo": "Falta información del checkpoint",
|
||||
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
|
||||
"enterCheckpointName": "Introduce un nombre de checkpoint",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint reconectado exitosamente",
|
||||
"reconnectCheckpointBaseModelMismatch": "Reconectado, pero los modelos base difieren (receta: {recipe}, checkpoint: {checkpoint}) — son compatibles a nivel de arquitectura",
|
||||
"checkpointReconnectFailed": "Error reconectando checkpoint: {message}",
|
||||
"checkpointRestored": "Checkpoint restaurado a su asociación anterior",
|
||||
"checkpointRestoreFailed": "Error restaurando checkpoint: {message}",
|
||||
"checkpointDownloadUnavailable": "Este checkpoint no se puede descargar sin identificadores de CivitAI - intenta reconectarlo con un checkpoint local",
|
||||
"missingLoraDownloadInfo": "Falta la información de descarga de este LoRA",
|
||||
"hashNotFoundOnCivitai": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado o el hash no es válido",
|
||||
"downloadLoraFailed": "Error al descargar el LoRA: {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Ce modèle n'est pas dans votre bibliothèque",
|
||||
"deletedTooltip": "Ce LoRA a été supprimé de la source et ne peut plus être téléchargé",
|
||||
"hashInvalidTooltip": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour",
|
||||
"noLorasAssociated": "Aucune LoRA associée à cette Recipe",
|
||||
"noLorasWhyToggle": "Pourquoi aucune LoRA ?",
|
||||
"noLorasImportMethod": "Méthode d'import",
|
||||
"noLorasInferredNote": "Raison possible (déduite) — cette Recipe a été importée avant l'enregistrement des diagnostics d'import.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Import groupé (URL d'image)",
|
||||
"batch_import_local": "Import groupé (fichier local)",
|
||||
"url": "Import d'une URL d'image",
|
||||
"local": "Import d'un fichier local",
|
||||
"upload": "Téléversement d'image",
|
||||
"widget": "Enregistrée depuis le Workflow",
|
||||
"reimport_url": "Réimport (URL d'image)",
|
||||
"reimport_local": "Réimport (fichier local)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Les métadonnées de génération sont complètes et ne référencent aucune LoRA.",
|
||||
"api_meta_no_lora_resources": "L'API source n'a renvoyé aucune donnée de ressource LoRA pour cette image. Les LoRAs affichées sur la page CivitAI peuvent provenir de données internes que l'API publique n'expose pas.",
|
||||
"api_meta_missing": "L'API source n'a renvoyé aucune métadonnée de génération pour cette image.",
|
||||
"no_embedded_metadata": "L'image ne contient aucune métadonnée de génération intégrée ; les informations LoRA n'ont donc pas pu être récupérées.",
|
||||
"workflow_metadata_limited": "Les métadonnées intégrées à l'image sont un Workflow ComfyUI ; l'extraction des informations LoRA à partir des Workflows est limitée.",
|
||||
"video_no_metadata": "Les fichiers vidéo ne contiennent pas de métadonnées de génération intégrées.",
|
||||
"metadata_unsupported": "L'image contient des métadonnées dans un format non analysable.",
|
||||
"unknown": "La raison n'a pas pu être déterminée à partir des données de la Recipe enregistrée."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Champs de métadonnées de l'API",
|
||||
"modelVersionIds": "IDs de version de modèle signalés",
|
||||
"embeddedMetadata": "Métadonnées intégrées",
|
||||
"present": "trouvées",
|
||||
"absent": "aucune"
|
||||
},
|
||||
"download": "Télécharger",
|
||||
"downloadLoraTooltip": "Télécharger ce LoRA",
|
||||
"preparingDownload": "Préparation du téléchargement...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "Restaurer vers {name} (l'association avant la reconnexion)",
|
||||
"viewOnCivitai": "Voir sur CivitAI",
|
||||
"openLoraDetails": "Voir {name} dans la bibliothèque LoRA",
|
||||
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles"
|
||||
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles",
|
||||
"checkpointDeletedTooltip": "Ce checkpoint a été supprimé de la source et ne peut plus être téléchargé - reconnectez-le avec un modèle local",
|
||||
"checkpointHashInvalidTooltip": "Le hash de ce checkpoint ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour",
|
||||
"reconnectCheckpoint": "Reconnecter",
|
||||
"reconnectCheckpointTooltip": "Reconnecter avec un checkpoint local",
|
||||
"checkpointReconnectInstructions": "Entrez le nom du checkpoint à reconnecter:",
|
||||
"checkpointReconnectPlaceholder": "Entrez le nom du checkpoint",
|
||||
"checkpointReconnectSuggestionsEmpty": "Aucun checkpoint correspondant dans votre bibliothèque locale"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
||||
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
||||
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
|
||||
"enterCheckpointName": "Veuillez saisir un nom de checkpoint",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint reconnecté avec succès",
|
||||
"reconnectCheckpointBaseModelMismatch": "Reconnexion effectuée, mais les modèles de base diffèrent (Recipe : {recipe}, checkpoint : {checkpoint}) — ils sont compatibles au niveau architectural",
|
||||
"checkpointReconnectFailed": "Erreur lors de la reconnexion du checkpoint : {message}",
|
||||
"checkpointRestored": "Checkpoint restauré à son association précédente",
|
||||
"checkpointRestoreFailed": "Erreur lors de la restauration du checkpoint : {message}",
|
||||
"checkpointDownloadUnavailable": "Ce checkpoint ne peut pas être téléchargé sans identifiants CivitAI - essayez de le reconnecter avec un checkpoint local",
|
||||
"missingLoraDownloadInfo": "Informations de téléchargement manquantes pour ce LoRA",
|
||||
"hashNotFoundOnCivitai": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour ou le hash est invalide",
|
||||
"downloadLoraFailed": "Échec du téléchargement du LoRA : {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "מודל זה לא נמצא בספרייה שלך",
|
||||
"deletedTooltip": "LoRA זה נמחק מהמקור ואינו זמין יותר להורדה",
|
||||
"hashInvalidTooltip": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן",
|
||||
"noLorasAssociated": "אין LoRAs המשויכים למתכון זה",
|
||||
"noLorasWhyToggle": "למה אין LoRAs?",
|
||||
"noLorasImportMethod": "שיטת ייבוא",
|
||||
"noLorasInferredNote": "סיבה אפשרית (משוערת) — מתכון זה יובא לפני שנרשמו אבחוני ייבוא.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "ייבוא בכמות גדולה (URL של תמונה)",
|
||||
"batch_import_local": "ייבוא בכמות גדולה (קובץ מקומי)",
|
||||
"url": "ייבוא מ-URL של תמונה",
|
||||
"local": "ייבוא קובץ מקומי",
|
||||
"upload": "העלאת תמונה",
|
||||
"widget": "נשמר מה-workflow",
|
||||
"reimport_url": "ייבוא מחדש (URL של תמונה)",
|
||||
"reimport_local": "ייבוא מחדש (קובץ מקומי)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "מטא-הנתונים של היצירה שלמים ואינם מפנים ל-LoRAs כלשהם.",
|
||||
"api_meta_no_lora_resources": "ה-API של המקור לא החזיר נתוני משאבי LoRA עבור תמונה זו. LoRAs המוצגים בעמוד CivitAI עשויים להגיע מנתונים פנימיים שה-API הציבורי אינו חושף.",
|
||||
"api_meta_missing": "ה-API של המקור לא החזיר מטא-נתוני יצירה עבור תמונה זו.",
|
||||
"no_embedded_metadata": "לתמונה אין מטא-נתוני יצירה מוטבעים, ולכן לא ניתן היה לשחזר את מידע ה-LoRA.",
|
||||
"workflow_metadata_limited": "המטא-נתונים המוטבעים של התמונה הם workflow של ComfyUI; חילוץ מידע LoRA מתוך workflows מוגבל.",
|
||||
"video_no_metadata": "קבצי וידאו אינם נושאים מטא-נתוני יצירה מוטבעים.",
|
||||
"metadata_unsupported": "התמונה מכילה מטא-נתונים בפורמט שלא ניתן לנתח.",
|
||||
"unknown": "לא ניתן היה לקבוע את הסיבה מנתוני המתכון השמורים."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "שדות מטא-נתונים של API",
|
||||
"modelVersionIds": "מספר מזהי גרסת מודל שדווחו",
|
||||
"embeddedMetadata": "מטא-נתונים מוטבעים",
|
||||
"present": "נמצאו",
|
||||
"absent": "אין"
|
||||
},
|
||||
"download": "הורדה",
|
||||
"downloadLoraTooltip": "הורד את ה-LoRA הזה",
|
||||
"preparingDownload": "מכין את ההורדה...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "שחזר ל-{name} (השיוך לפני החיבור מחדש)",
|
||||
"viewOnCivitai": "הצג ב-CivitAI",
|
||||
"openLoraDetails": "הצג את {name} בספריית ה-LoRA",
|
||||
"openCheckpointDetails": "הצג את {name} בספריית המודלים"
|
||||
"openCheckpointDetails": "הצג את {name} בספריית המודלים",
|
||||
"checkpointDeletedTooltip": "Checkpoint זה נמחק מהמקור ואינו זמין עוד להורדה - חבר אותו מחדש עם מודל מקומי",
|
||||
"checkpointHashInvalidTooltip": "לא ניתן לפתור את ה-hash של Checkpoint זה ב-CivitAI - ייתכן שהמודל עודכן",
|
||||
"reconnectCheckpoint": "חבר מחדש",
|
||||
"reconnectCheckpointTooltip": "חבר מחדש עם Checkpoint מקומי",
|
||||
"checkpointReconnectInstructions": "הזן שם של Checkpoint לחיבור מחדש:",
|
||||
"checkpointReconnectPlaceholder": "הזן שם של Checkpoint",
|
||||
"checkpointReconnectSuggestionsEmpty": "לא נמצאו Checkpoints תואמים בספרייה המקומית שלך"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
|
||||
"missingCheckpointInfo": "חסרים פרטי checkpoint",
|
||||
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
|
||||
"enterCheckpointName": "הזן שם של Checkpoint",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint קושר מחדש בהצלחה",
|
||||
"reconnectCheckpointBaseModelMismatch": "הקישור מחדש הצליח, אך מודלי הבסיס שונים (מתכון: {recipe}, Checkpoint: {checkpoint}) — הם תואמים מבחינת הארכיטקטורה",
|
||||
"checkpointReconnectFailed": "שגיאה בקישור מחדש של Checkpoint: {message}",
|
||||
"checkpointRestored": "Checkpoint שוחזר לשיוך הקודם",
|
||||
"checkpointRestoreFailed": "שגיאה בשחזור Checkpoint: {message}",
|
||||
"checkpointDownloadUnavailable": "לא ניתן להוריד Checkpoint זה ללא מזהי CivitAI - נסה לחבר אותו מחדש עם Checkpoint מקומי",
|
||||
"missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה",
|
||||
"hashNotFoundOnCivitai": "לא ניתן לפתור את ה-hash של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן או שה-hash אינו תקין",
|
||||
"downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "このモデルはライブラリにありません",
|
||||
"deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません",
|
||||
"hashInvalidTooltip": "このLoRAハッシュはCivitAIで解決できません - モデルが更新された可能性があります",
|
||||
"noLorasAssociated": "このレシピに関連付けられた LoRA はありません",
|
||||
"noLorasWhyToggle": "LoRA がない理由",
|
||||
"noLorasImportMethod": "インポート方法",
|
||||
"noLorasInferredNote": "考えられる理由(推定)— このレシピはインポート診断が記録される前にインポートされました。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "一括インポート(画像 URL)",
|
||||
"batch_import_local": "一括インポート(ローカルファイル)",
|
||||
"url": "画像 URL からのインポート",
|
||||
"local": "ローカルファイルのインポート",
|
||||
"upload": "画像のアップロード",
|
||||
"widget": "ワークフローから保存",
|
||||
"reimport_url": "再インポート(画像 URL)",
|
||||
"reimport_local": "再インポート(ローカルファイル)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成メタデータは完全で、LoRA への参照は含まれていません。",
|
||||
"api_meta_no_lora_resources": "ソース API がこの画像の LoRA リソースデータを返しませんでした。CivitAI ページに表示される LoRA は、公開 API では公開されない内部データに由来する場合があります。",
|
||||
"api_meta_missing": "ソース API がこの画像の生成メタデータを返しませんでした。",
|
||||
"no_embedded_metadata": "画像に埋め込まれた生成メタデータがないため、LoRA 情報を復元できませんでした。",
|
||||
"workflow_metadata_limited": "画像に埋め込まれたメタデータは ComfyUI ワークフローです。ワークフローからの LoRA 情報の抽出には限界があります。",
|
||||
"video_no_metadata": "動画ファイルには埋め込み生成メタデータがありません。",
|
||||
"metadata_unsupported": "画像に解析できない形式のメタデータが含まれています。",
|
||||
"unknown": "保存されたレシピデータから理由を特定できませんでした。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API メタデータフィールド",
|
||||
"modelVersionIds": "報告されたモデルバージョン ID 数",
|
||||
"embeddedMetadata": "埋め込みメタデータ",
|
||||
"present": "あり",
|
||||
"absent": "なし"
|
||||
},
|
||||
"download": "ダウンロード",
|
||||
"downloadLoraTooltip": "この LoRA をダウンロード",
|
||||
"preparingDownload": "ダウンロードを準備中...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)",
|
||||
"viewOnCivitai": "CivitAI で表示",
|
||||
"openLoraDetails": "LoRA ライブラリで {name} を表示",
|
||||
"openCheckpointDetails": "モデルライブラリで {name} を表示"
|
||||
"openCheckpointDetails": "モデルライブラリで {name} を表示",
|
||||
"checkpointDeletedTooltip": "この Checkpoint はソースから削除されたため、ダウンロードできません - ローカルモデルで再接続してください",
|
||||
"checkpointHashInvalidTooltip": "この Checkpoint のハッシュは CivitAI で解決できません - モデルが更新された可能性があります",
|
||||
"reconnectCheckpoint": "再接続",
|
||||
"reconnectCheckpointTooltip": "ローカルの Checkpoint と再接続",
|
||||
"checkpointReconnectInstructions": "再接続する Checkpoint の名前を入力してください:",
|
||||
"checkpointReconnectPlaceholder": "Checkpoint 名を入力",
|
||||
"checkpointReconnectSuggestionsEmpty": "ローカルライブラリに一致するCheckpointがありません"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "Checkpointのパスがありません",
|
||||
"missingCheckpointInfo": "Checkpoint情報が不足しています",
|
||||
"downloadCheckpointFailed": "Checkpointのダウンロードに失敗しました: {message}",
|
||||
"enterCheckpointName": "Checkpoint 名を入力してください",
|
||||
"checkpointReconnectedSuccessfully": "Checkpointが正常に再接続されました",
|
||||
"reconnectCheckpointBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、Checkpoint:{checkpoint})— アーキテクチャ互換です",
|
||||
"checkpointReconnectFailed": "Checkpoint再接続エラー:{message}",
|
||||
"checkpointRestored": "Checkpoint が以前の関連付けに復元されました",
|
||||
"checkpointRestoreFailed": "Checkpoint復元エラー:{message}",
|
||||
"checkpointDownloadUnavailable": "CivitAI の識別子がないため、この Checkpoint をダウンロードできません - ローカルの Checkpoint と再接続してみてください",
|
||||
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
|
||||
"hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります",
|
||||
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "이 모델은 라이브러리에 없습니다",
|
||||
"deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다",
|
||||
"hashInvalidTooltip": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||
"noLorasAssociated": "이 레시피에 연결된 LoRA가 없습니다",
|
||||
"noLorasWhyToggle": "LoRA가 없는 이유",
|
||||
"noLorasImportMethod": "가져오기 방법",
|
||||
"noLorasInferredNote": "가능한 이유(추정) — 이 레시피는 가져오기 진단이 기록되기 전에 가져온 것입니다.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "일괄 가져오기(이미지 URL)",
|
||||
"batch_import_local": "일괄 가져오기(로컬 파일)",
|
||||
"url": "이미지 URL 가져오기",
|
||||
"local": "로컬 파일 가져오기",
|
||||
"upload": "이미지 업로드",
|
||||
"widget": "워크플로에서 저장",
|
||||
"reimport_url": "다시 가져오기(이미지 URL)",
|
||||
"reimport_local": "다시 가져오기(로컬 파일)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "생성 메타데이터가 완전하며 LoRA를 참조하지 않습니다.",
|
||||
"api_meta_no_lora_resources": "소스 API가 이 이미지에 대한 LoRA 리소스 데이터를 반환하지 않았습니다. CivitAI 페이지에 표시되는 LoRA는 공개 API가 노출하지 않는 내부 데이터에서 비롯될 수 있습니다.",
|
||||
"api_meta_missing": "소스 API가 이 이미지에 대한 생성 메타데이터를 반환하지 않았습니다.",
|
||||
"no_embedded_metadata": "이미지에 내장된 생성 메타데이터가 없어 LoRA 정보를 복구할 수 없습니다.",
|
||||
"workflow_metadata_limited": "이미지에 내장된 메타데이터는 ComfyUI 워크플로입니다. 워크플로에서 LoRA 정보를 추출하는 것은 제한적입니다.",
|
||||
"video_no_metadata": "동영상 파일에는 내장 생성 메타데이터가 없습니다.",
|
||||
"metadata_unsupported": "이미지에 파싱할 수 없는 형식의 메타데이터가 포함되어 있습니다.",
|
||||
"unknown": "저장된 레시피 데이터에서 이유를 확인할 수 없습니다."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 메타데이터 필드",
|
||||
"modelVersionIds": "보고된 모델 버전 ID 수",
|
||||
"embeddedMetadata": "내장 메타데이터",
|
||||
"present": "있음",
|
||||
"absent": "없음"
|
||||
},
|
||||
"download": "다운로드",
|
||||
"downloadLoraTooltip": "이 LoRA 다운로드",
|
||||
"preparingDownload": "다운로드 준비 중...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}",
|
||||
"viewOnCivitai": "CivitAI에서 보기",
|
||||
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
|
||||
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기"
|
||||
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기",
|
||||
"checkpointDeletedTooltip": "이 Checkpoint는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다 - 로컬 모델로 다시 연결하세요",
|
||||
"checkpointHashInvalidTooltip": "이 Checkpoint의 해시를 CivitAI에서 확인할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||
"reconnectCheckpoint": "다시 연결",
|
||||
"reconnectCheckpointTooltip": "로컬 Checkpoint와 다시 연결",
|
||||
"checkpointReconnectInstructions": "다시 연결할 Checkpoint 이름을 입력하세요:",
|
||||
"checkpointReconnectPlaceholder": "Checkpoint 이름 입력",
|
||||
"checkpointReconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 Checkpoint가 없습니다"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "Checkpoint 경로를 사용할 수 없습니다",
|
||||
"missingCheckpointInfo": "Checkpoint 정보가 부족합니다",
|
||||
"downloadCheckpointFailed": "Checkpoint 다운로드 실패: {message}",
|
||||
"enterCheckpointName": "Checkpoint 이름을 입력하세요",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint가 성공적으로 다시 연결되었습니다",
|
||||
"reconnectCheckpointBaseModelMismatch": "다시 연결했지만 베이스 모델이 다릅니다(레시피: {recipe}, Checkpoint: {checkpoint}) — 아키텍처 호환입니다",
|
||||
"checkpointReconnectFailed": "Checkpoint 다시 연결 오류: {message}",
|
||||
"checkpointRestored": "Checkpoint가 이전 연결 상태로 복원되었습니다",
|
||||
"checkpointRestoreFailed": "Checkpoint 복원 오류: {message}",
|
||||
"checkpointDownloadUnavailable": "CivitAI 식별자가 없어 이 Checkpoint를 다운로드할 수 없습니다 - 로컬 Checkpoint로 다시 연결해 보세요",
|
||||
"missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
|
||||
"hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다",
|
||||
"downloadLoraFailed": "LoRA 다운로드 실패: {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "Этой модели нет в вашей библиотеке",
|
||||
"deletedTooltip": "Этот LoRA был удалён из источника и больше недоступен для скачивания",
|
||||
"hashInvalidTooltip": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена",
|
||||
"noLorasAssociated": "С этим рецептом не связаны LoRA",
|
||||
"noLorasWhyToggle": "Почему нет LoRA?",
|
||||
"noLorasImportMethod": "Способ импорта",
|
||||
"noLorasInferredNote": "Возможная причина (выведена) — этот рецепт был импортирован до того, как стала записываться диагностика импорта.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Пакетный импорт (URL изображения)",
|
||||
"batch_import_local": "Пакетный импорт (локальный файл)",
|
||||
"url": "Импорт по URL изображения",
|
||||
"local": "Импорт локального файла",
|
||||
"upload": "Загрузка изображения",
|
||||
"widget": "Сохранён из Workflow",
|
||||
"reimport_url": "Повторный импорт (URL изображения)",
|
||||
"reimport_local": "Повторный импорт (локальный файл)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "Метаданные генерации полны и не содержат ссылок на LoRA.",
|
||||
"api_meta_no_lora_resources": "Исходный API не вернул данные о ресурсах LoRA для этого изображения. LoRA, отображаемые на странице CivitAI, могут поступать из внутренних данных, которые публичный API не раскрывает.",
|
||||
"api_meta_missing": "Исходный API не вернул метаданные генерации для этого изображения.",
|
||||
"no_embedded_metadata": "Изображение не содержит встроенных метаданных генерации, поэтому восстановить информацию о LoRA невозможно.",
|
||||
"workflow_metadata_limited": "Встроенные метаданные изображения представляют собой Workflow ComfyUI; извлечение информации о LoRA из Workflow ограничено.",
|
||||
"video_no_metadata": "Видеофайлы не содержат встроенных метаданных генерации.",
|
||||
"metadata_unsupported": "Изображение содержит метаданные в формате, который не удалось разобрать.",
|
||||
"unknown": "Причину не удалось определить по сохранённым данным рецепта."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "Поля метаданных API",
|
||||
"modelVersionIds": "Сообщено ID версий моделей",
|
||||
"embeddedMetadata": "Встроенные метаданные",
|
||||
"present": "найдены",
|
||||
"absent": "нет"
|
||||
},
|
||||
"download": "Скачать",
|
||||
"downloadLoraTooltip": "Скачать этот LoRA",
|
||||
"preparingDownload": "Подготовка к скачиванию...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "Восстановить {name} (привязка до переподключения)",
|
||||
"viewOnCivitai": "Открыть на CivitAI",
|
||||
"openLoraDetails": "Открыть {name} в библиотеке LoRA",
|
||||
"openCheckpointDetails": "Открыть {name} в библиотеке моделей"
|
||||
"openCheckpointDetails": "Открыть {name} в библиотеке моделей",
|
||||
"checkpointDeletedTooltip": "Этот чекпойнт был удалён из источника и больше не может быть скачан - переподключите его к локальной модели",
|
||||
"checkpointHashInvalidTooltip": "Хеш этого чекпойнта не удаётся разрешить на CivitAI - возможно, модель была обновлена",
|
||||
"reconnectCheckpoint": "Переподключить",
|
||||
"reconnectCheckpointTooltip": "Переподключить к локальному чекпойнту",
|
||||
"checkpointReconnectInstructions": "Введите имя чекпойнта для переподключения:",
|
||||
"checkpointReconnectPlaceholder": "Введите имя чекпойнта",
|
||||
"checkpointReconnectSuggestionsEmpty": "В локальной библиотеке нет подходящих чекпойнтов"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
||||
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
||||
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
||||
"enterCheckpointName": "Введите имя чекпойнта",
|
||||
"checkpointReconnectedSuccessfully": "Чекпойнт успешно переподключён",
|
||||
"reconnectCheckpointBaseModelMismatch": "Переподключение выполнено, но базовые модели различаются (рецепт: {recipe}, чекпойнт: {checkpoint}) — они совместимы по архитектуре",
|
||||
"checkpointReconnectFailed": "Ошибка переподключения чекпойнта: {message}",
|
||||
"checkpointRestored": "Чекпойнт восстановлен к прежней привязке",
|
||||
"checkpointRestoreFailed": "Ошибка восстановления чекпойнта: {message}",
|
||||
"checkpointDownloadUnavailable": "Этот чекпойнт нельзя скачать без идентификаторов CivitAI - попробуйте переподключить его к локальному чекпойнту",
|
||||
"missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
|
||||
"hashNotFoundOnCivitai": "Этот хеш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена или хеш недействителен",
|
||||
"downloadLoraFailed": "Не удалось скачать LoRA: {message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "该模型不在你的本地库中",
|
||||
"deletedTooltip": "该 LoRA 已从来源站删除,无法下载",
|
||||
"hashInvalidTooltip": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方没有关联任何 LoRA",
|
||||
"noLorasWhyToggle": "为什么没有 LoRA?",
|
||||
"noLorasImportMethod": "导入方式",
|
||||
"noLorasInferredNote": "可能的原因(推断)——该配方是在记录导入诊断信息之前导入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量导入(图片 URL)",
|
||||
"batch_import_local": "批量导入(本地文件)",
|
||||
"url": "图片 URL 导入",
|
||||
"local": "本地文件导入",
|
||||
"upload": "图片上传",
|
||||
"widget": "从工作流保存",
|
||||
"reimport_url": "重新导入(图片 URL)",
|
||||
"reimport_local": "重新导入(本地文件)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成元数据完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "来源 API 未返回此图片的 LoRA 资源数据。CivitAI 页面上显示的 LoRA 可能来自公开 API 未开放的内部数据。",
|
||||
"api_meta_missing": "来源 API 未返回此图片的生成元数据。",
|
||||
"no_embedded_metadata": "图片没有内嵌生成元数据,因此无法恢复 LoRA 信息。",
|
||||
"workflow_metadata_limited": "图片内嵌的元数据是 ComfyUI 工作流;从工作流中提取 LoRA 信息的能力有限。",
|
||||
"video_no_metadata": "视频文件不携带内嵌生成元数据。",
|
||||
"metadata_unsupported": "图片包含的元数据格式无法解析。",
|
||||
"unknown": "无法从存储的配方数据中确定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 元数据字段",
|
||||
"modelVersionIds": "报告的模型版本 ID 数",
|
||||
"embeddedMetadata": "内嵌元数据",
|
||||
"present": "已找到",
|
||||
"absent": "无"
|
||||
},
|
||||
"download": "下载",
|
||||
"downloadLoraTooltip": "下载此 LoRA",
|
||||
"preparingDownload": "正在准备下载...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)",
|
||||
"viewOnCivitai": "在 CivitAI 上查看",
|
||||
"openLoraDetails": "在 LoRA 库中查看 {name}",
|
||||
"openCheckpointDetails": "在模型库中查看 {name}"
|
||||
"openCheckpointDetails": "在模型库中查看 {name}",
|
||||
"checkpointDeletedTooltip": "此 Checkpoint 已从来源删除,无法再下载 - 请使用本地模型重新关联",
|
||||
"checkpointHashInvalidTooltip": "此 Checkpoint 的哈希无法在 CivitAI 上解析 - 模型可能已更新",
|
||||
"reconnectCheckpoint": "重新关联",
|
||||
"reconnectCheckpointTooltip": "与本地 Checkpoint 重新关联",
|
||||
"checkpointReconnectInstructions": "输入 Checkpoint 名称以重新关联:",
|
||||
"checkpointReconnectPlaceholder": "输入 Checkpoint 名称",
|
||||
"checkpointReconnectSuggestionsEmpty": "本地库中没有匹配的 Checkpoint"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "缺少Checkpoint路径",
|
||||
"missingCheckpointInfo": "缺少Checkpoint信息",
|
||||
"downloadCheckpointFailed": "下载Checkpoint失败:{message}",
|
||||
"enterCheckpointName": "请输入 Checkpoint 名称",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint 重新连接成功",
|
||||
"reconnectCheckpointBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},Checkpoint:{checkpoint})——两者架构兼容",
|
||||
"checkpointReconnectFailed": "Checkpoint 重新连接出错:{message}",
|
||||
"checkpointRestored": "Checkpoint 已恢复为重新关联前的关联",
|
||||
"checkpointRestoreFailed": "Checkpoint 恢复出错:{message}",
|
||||
"checkpointDownloadUnavailable": "缺少 CivitAI 标识,无法下载此 Checkpoint - 请尝试使用本地 Checkpoint 重新关联",
|
||||
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
|
||||
"hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效",
|
||||
"downloadLoraFailed": "下载 LoRA 失败:{message}",
|
||||
|
||||
+46
-1
@@ -898,6 +898,37 @@
|
||||
"notInLibraryTooltip": "此模型不在您的本地庫中",
|
||||
"deletedTooltip": "此 LoRA 已從來源站刪除,無法下載",
|
||||
"hashInvalidTooltip": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方未關聯任何 LoRA",
|
||||
"noLorasWhyToggle": "為什麼沒有 LoRA?",
|
||||
"noLorasImportMethod": "匯入方式",
|
||||
"noLorasInferredNote": "可能的原因(推斷)——此配方是在記錄匯入診斷資訊之前匯入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量匯入(圖片 URL)",
|
||||
"batch_import_local": "批量匯入(本機檔案)",
|
||||
"url": "圖片 URL 匯入",
|
||||
"local": "本機檔案匯入",
|
||||
"upload": "圖片上傳",
|
||||
"widget": "從工作流儲存",
|
||||
"reimport_url": "重新匯入(圖片 URL)",
|
||||
"reimport_local": "重新匯入(本機檔案)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成中繼資料完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "來源 API 未回傳此圖片的 LoRA 資源資料。CivitAI 頁面上顯示的 LoRA 可能來自公開 API 未開放的內部資料。",
|
||||
"api_meta_missing": "來源 API 未回傳此圖片的生成中繼資料。",
|
||||
"no_embedded_metadata": "圖片沒有內嵌生成中繼資料,因此無法復原 LoRA 資訊。",
|
||||
"workflow_metadata_limited": "圖片內嵌的中繼資料是 ComfyUI 工作流;從工作流中提取 LoRA 資訊的能力有限。",
|
||||
"video_no_metadata": "影片檔案不攜帶內嵌生成中繼資料。",
|
||||
"metadata_unsupported": "圖片包含的中繼資料格式無法解析。",
|
||||
"unknown": "無法從儲存的配方資料中確定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 中繼資料欄位",
|
||||
"modelVersionIds": "回報的模型版本 ID 數",
|
||||
"embeddedMetadata": "內嵌中繼資料",
|
||||
"present": "已找到",
|
||||
"absent": "無"
|
||||
},
|
||||
"download": "下載",
|
||||
"downloadLoraTooltip": "下載此 LoRA",
|
||||
"preparingDownload": "正在準備下載...",
|
||||
@@ -917,7 +948,14 @@
|
||||
"undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)",
|
||||
"viewOnCivitai": "在 CivitAI 上檢視",
|
||||
"openLoraDetails": "在 LoRA 庫中檢視 {name}",
|
||||
"openCheckpointDetails": "在模型庫中檢視 {name}"
|
||||
"openCheckpointDetails": "在模型庫中檢視 {name}",
|
||||
"checkpointDeletedTooltip": "此 Checkpoint 已從來源刪除,無法再下載 - 請使用本地模型重新關聯",
|
||||
"checkpointHashInvalidTooltip": "此 Checkpoint 的雜湊無法在 CivitAI 上解析 - 模型可能已更新",
|
||||
"reconnectCheckpoint": "重新關聯",
|
||||
"reconnectCheckpointTooltip": "與本地 Checkpoint 重新關聯",
|
||||
"checkpointReconnectInstructions": "輸入 Checkpoint 名稱以重新關聯:",
|
||||
"checkpointReconnectPlaceholder": "輸入 Checkpoint 名稱",
|
||||
"checkpointReconnectSuggestionsEmpty": "本地庫中沒有符合的 Checkpoint"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
@@ -2066,6 +2104,13 @@
|
||||
"missingCheckpointPath": "缺少Checkpoint路徑",
|
||||
"missingCheckpointInfo": "缺少Checkpoint資訊",
|
||||
"downloadCheckpointFailed": "下載Checkpoint失敗:{message}",
|
||||
"enterCheckpointName": "請輸入 Checkpoint 名稱",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint 重新連結成功",
|
||||
"reconnectCheckpointBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},Checkpoint:{checkpoint})——兩者架構相容",
|
||||
"checkpointReconnectFailed": "Checkpoint 重新連結錯誤:{message}",
|
||||
"checkpointRestored": "Checkpoint 已恢復為重新關聯前的關聯",
|
||||
"checkpointRestoreFailed": "Checkpoint 恢復錯誤:{message}",
|
||||
"checkpointDownloadUnavailable": "缺少 CivitAI 標識,無法下載此 Checkpoint - 請嘗試使用本地 Checkpoint 重新關聯",
|
||||
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
|
||||
"hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效",
|
||||
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
|
||||
|
||||
@@ -116,6 +116,10 @@ class RecipeHandlerSet:
|
||||
"restore_lora": self.management.restore_lora,
|
||||
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
|
||||
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
|
||||
"reconnect_checkpoint": self.management.reconnect_checkpoint,
|
||||
"restore_checkpoint": self.management.restore_checkpoint,
|
||||
"get_checkpoint_reconnect_suggestions": self.management.get_checkpoint_reconnect_suggestions,
|
||||
"mark_checkpoint_hash_invalid": self.management.mark_checkpoint_hash_invalid,
|
||||
"find_duplicates": self.query.find_duplicates,
|
||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||
"bulk_delete": self.management.bulk_delete,
|
||||
@@ -1683,6 +1687,116 @@ class RecipeManagementHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def reconnect_checkpoint(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
for field in ("recipe_id", "target_name"):
|
||||
if field not in data:
|
||||
raise RecipeValidationError(f"Missing required field: {field}")
|
||||
|
||||
result = await self._persistence_service.reconnect_checkpoint(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
target_name=data["target_name"],
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error reconnecting checkpoint: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def restore_checkpoint(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
if "recipe_id" not in data:
|
||||
raise RecipeValidationError("Missing required field: recipe_id")
|
||||
|
||||
result = await self._persistence_service.restore_checkpoint(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error restoring checkpoint: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self, request: web.Request
|
||||
) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
raise RecipeValidationError("recipe_id is required")
|
||||
|
||||
result = await self._persistence_service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=recipe_id,
|
||||
query=request.query.get("query") or None,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error suggesting checkpoint reconnect candidates: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def mark_checkpoint_hash_invalid(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
if "recipe_id" not in data:
|
||||
raise RecipeValidationError("Missing required field: recipe_id")
|
||||
|
||||
result = await self._persistence_service.mark_checkpoint_hash_invalid(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
hash_invalid=bool(data.get("hash_invalid", True)),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error marking checkpoint hash invalid: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def bulk_delete(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -2115,6 +2229,23 @@ class RecipeManagementHandler:
|
||||
await self._download_remote_media(image_url)
|
||||
)
|
||||
|
||||
# Diagnostics for the recipe modal's "Why no LoRAs?" panel. This path
|
||||
# always comes from a CivitAI image URL (import_from_url validates the
|
||||
# image id), so civitai_image is True.
|
||||
diagnostics: Dict[str, Any] = {
|
||||
"civitai_image": True,
|
||||
"is_video": extension in (".mp4", ".webm"),
|
||||
}
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
raw_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(raw_mvids) if isinstance(raw_mvids, list) else 0
|
||||
)
|
||||
inner_meta_for_diag = civitai_meta_raw.get("meta")
|
||||
if isinstance(inner_meta_for_diag, dict):
|
||||
diagnostics["api_meta_present"] = True
|
||||
diagnostics["api_meta_keys"] = sorted(inner_meta_for_diag.keys())
|
||||
|
||||
# Build a version-cached map of local model hashes to cache items so
|
||||
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||
# exist on disk. Built once and shared by every parse pass below.
|
||||
@@ -2135,6 +2266,7 @@ class RecipeManagementHandler:
|
||||
raw_embedded = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_img_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_embedded)
|
||||
if raw_embedded:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2142,6 +2274,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded,
|
||||
@@ -2182,6 +2315,7 @@ class RecipeManagementHandler:
|
||||
raw_orig = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, orig_tmp_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2189,6 +2323,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
@@ -2310,6 +2445,20 @@ class RecipeManagementHandler:
|
||||
else:
|
||||
name = f"Civitai Image {image_id}"
|
||||
|
||||
# Record why this import ended up with no LoRAs so the recipe modal
|
||||
# can explain it (collapsed by default).
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_URL if recipe_id else CHANNEL_URL,
|
||||
diagnostics,
|
||||
metadata.get("loras"),
|
||||
)
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
@@ -2369,6 +2518,17 @@ class RecipeManagementHandler:
|
||||
if checkpoint:
|
||||
metadata["checkpoint"] = checkpoint
|
||||
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
analysis_payload.get("diagnostics"),
|
||||
loras,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
gen_params.get("prompt")
|
||||
or gen_params.get("positivePrompt")
|
||||
|
||||
@@ -58,6 +58,22 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/checkpoint/reconnect", "reconnect_checkpoint"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/checkpoint/restore", "restore_checkpoint"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/recipe/{recipe_id}/checkpoint/reconnect-suggestions",
|
||||
"get_checkpoint_reconnect_suggestions",
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST",
|
||||
"/api/lm/recipe/checkpoint/mark-hash-invalid",
|
||||
"mark_checkpoint_hash_invalid",
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
RouteDefinition(
|
||||
|
||||
@@ -20,6 +20,11 @@ from .recipes import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
)
|
||||
from .recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
|
||||
class ImportItemType(Enum):
|
||||
@@ -624,6 +629,17 @@ class BatchImportService:
|
||||
"loras": loras,
|
||||
"gen_params": payload.get("gen_params", {}),
|
||||
"source_path": item.source,
|
||||
# Record why this import ended up with no LoRAs so the
|
||||
# recipe modal can explain it (collapsed by default).
|
||||
"import_info": build_import_info(
|
||||
(
|
||||
CHANNEL_BATCH_IMPORT_URL
|
||||
if item.item_type == ImportItemType.URL
|
||||
else CHANNEL_BATCH_IMPORT_LOCAL
|
||||
),
|
||||
payload.get("diagnostics"),
|
||||
loras,
|
||||
),
|
||||
}
|
||||
|
||||
if payload.get("checkpoint"):
|
||||
|
||||
@@ -59,6 +59,7 @@ class PersistentRecipeCache:
|
||||
"gen_params_json",
|
||||
"tags_json",
|
||||
"has_workflow",
|
||||
"import_info_json",
|
||||
)
|
||||
_instances: Dict[str, "PersistentRecipeCache"] = {}
|
||||
_instance_lock = threading.Lock()
|
||||
@@ -447,7 +448,8 @@ class PersistentRecipeCache:
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
has_workflow INTEGER DEFAULT 0,
|
||||
import_info_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
|
||||
@@ -473,6 +475,13 @@ class PersistentRecipeCache:
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
# Migration: add import_info_json column to existing databases
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN import_info_json TEXT"
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
except Exception as exc:
|
||||
@@ -504,6 +513,9 @@ class PersistentRecipeCache:
|
||||
tags = recipe.get("tags")
|
||||
tags_json = json.dumps(tags) if tags else None
|
||||
|
||||
import_info = recipe.get("import_info")
|
||||
import_info_json = json.dumps(import_info) if import_info else None
|
||||
|
||||
# Get file stats if json_path exists
|
||||
file_mtime = 0.0
|
||||
file_size = 0
|
||||
@@ -536,6 +548,7 @@ class PersistentRecipeCache:
|
||||
gen_params_json,
|
||||
tags_json,
|
||||
1 if recipe.get("has_workflow") else 0,
|
||||
import_info_json,
|
||||
)
|
||||
|
||||
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||
@@ -568,6 +581,13 @@ class PersistentRecipeCache:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
import_info = None
|
||||
if row["import_info_json"]:
|
||||
try:
|
||||
import_info = json.loads(row["import_info_json"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
recipe = {
|
||||
"id": row["recipe_id"],
|
||||
"file_path": row["file_path"] or "",
|
||||
@@ -592,6 +612,9 @@ class PersistentRecipeCache:
|
||||
if checkpoint:
|
||||
recipe["checkpoint"] = checkpoint
|
||||
|
||||
if import_info:
|
||||
recipe["import_info"] = import_info
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
|
||||
@@ -265,6 +265,49 @@ class RecipeScanner:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank local LoRAs as reconnect candidates for a broken recipe entry.
|
||||
|
||||
Thin wrapper over ``_suggest_reconnect_candidates`` scoped to the
|
||||
LoRA library (see it for the ranking contract).
|
||||
"""
|
||||
return await self._suggest_reconnect_candidates(
|
||||
entry=entry,
|
||||
recipe_base_model=recipe_base_model,
|
||||
query=query,
|
||||
limit=limit,
|
||||
is_checkpoint=False,
|
||||
)
|
||||
|
||||
async def suggest_checkpoint_reconnect_candidates(
|
||||
self,
|
||||
*,
|
||||
entry: dict[str, Any],
|
||||
recipe_base_model: Optional[str],
|
||||
query: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank local checkpoints as reconnect candidates for a broken entry.
|
||||
|
||||
Thin wrapper over ``_suggest_reconnect_candidates`` scoped to the
|
||||
checkpoint library (see it for the ranking contract).
|
||||
"""
|
||||
return await self._suggest_reconnect_candidates(
|
||||
entry=entry,
|
||||
recipe_base_model=recipe_base_model,
|
||||
query=query,
|
||||
limit=limit,
|
||||
is_checkpoint=True,
|
||||
)
|
||||
|
||||
async def _suggest_reconnect_candidates(
|
||||
self,
|
||||
*,
|
||||
entry: dict[str, Any],
|
||||
recipe_base_model: Optional[str],
|
||||
query: Optional[str] = None,
|
||||
limit: int = 5,
|
||||
is_checkpoint: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Rank local models as reconnect candidates for a broken recipe entry.
|
||||
|
||||
Identity signals (same hash / same CivitAI model version) outrank
|
||||
similarity signals (filename / model name fuzzy match). A confident
|
||||
base-model mismatch (both sides known and different) is a hard
|
||||
@@ -286,11 +329,11 @@ class RecipeScanner:
|
||||
if limit <= 0 or not isinstance(entry, dict):
|
||||
return []
|
||||
|
||||
lora_scanner = self._lora_scanner
|
||||
if lora_scanner is None:
|
||||
scanner = self._checkpoint_scanner if is_checkpoint else self._lora_scanner
|
||||
if scanner is None:
|
||||
return []
|
||||
|
||||
data = await lora_scanner.get_cached_data()
|
||||
data = await scanner.get_cached_data()
|
||||
recipe_bm = (recipe_base_model or "").strip().casefold()
|
||||
|
||||
def _base_model_known_mismatch(item: dict[str, Any]) -> bool:
|
||||
@@ -315,7 +358,7 @@ class RecipeScanner:
|
||||
# entry without a usable hash — same rule as the filename cache.
|
||||
if not (item.get("sha256") or "").strip():
|
||||
continue
|
||||
if not self._is_type_compatible(item, is_checkpoint=False):
|
||||
if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
|
||||
continue
|
||||
if _base_model_known_mismatch(item):
|
||||
continue
|
||||
@@ -351,14 +394,17 @@ class RecipeScanner:
|
||||
if (
|
||||
isinstance(hit, dict)
|
||||
and (hit.get("sha256") or "").strip()
|
||||
and self._is_type_compatible(hit, is_checkpoint=False)
|
||||
and self._is_type_compatible(hit, is_checkpoint=is_checkpoint)
|
||||
and not _base_model_known_mismatch(hit)
|
||||
):
|
||||
_consider(hit, 1.0 + _base_model_adjustment(hit), "same_hash")
|
||||
|
||||
version_id = entry.get("modelVersionId") or entry.get("id")
|
||||
if version_id is not None:
|
||||
hit = self._get_lora_from_version_index(str(version_id))
|
||||
if is_checkpoint:
|
||||
hit = self._get_checkpoint_from_version_index(str(version_id))
|
||||
else:
|
||||
hit = self._get_lora_from_version_index(str(version_id))
|
||||
if (
|
||||
isinstance(hit, dict)
|
||||
and (hit.get("sha256") or "").strip()
|
||||
@@ -367,7 +413,12 @@ class RecipeScanner:
|
||||
_consider(hit, 0.95 + _base_model_adjustment(hit), "same_version")
|
||||
|
||||
filename_source = query_text or (entry.get("file_name") or "")
|
||||
name_source = query_text or (entry.get("modelName") or "")
|
||||
# Parser-style checkpoint entries carry the model name under ``name``,
|
||||
# widget-style ones under ``modelName`` — try both for checkpoints.
|
||||
if is_checkpoint:
|
||||
name_source = query_text or (entry.get("name") or entry.get("modelName") or "")
|
||||
else:
|
||||
name_source = query_text or (entry.get("modelName") or "")
|
||||
norm_filename_source = self._normalize_filename_key(filename_source)
|
||||
name_source_cf = name_source.casefold()
|
||||
# Substring hits floor the similarity ratio, but only for meaningful
|
||||
@@ -1496,6 +1547,7 @@ class RecipeScanner:
|
||||
identifier key when neither identifier form exists).
|
||||
"""
|
||||
entry["isDeleted"] = False
|
||||
entry["hashInvalid"] = False
|
||||
|
||||
new_hash = (item.get("sha256") or "").lower()
|
||||
if new_hash:
|
||||
@@ -3290,6 +3342,19 @@ class RecipeScanner:
|
||||
|
||||
return await self._lora_scanner.find_models_by_name(name, base_model=base_model)
|
||||
|
||||
async def find_local_checkpoints_by_name(
|
||||
self, name: str, base_model: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return every local checkpoint matching ``name`` (used to explain lookup misses)."""
|
||||
|
||||
checkpoint_scanner = getattr(self, "_checkpoint_scanner", None)
|
||||
if not checkpoint_scanner or not name:
|
||||
return []
|
||||
|
||||
return await checkpoint_scanner.find_models_by_name(
|
||||
name, base_model=base_model
|
||||
)
|
||||
|
||||
async def get_local_lora_by_hash(self, hash_value: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lookup a local LoRA through the scanner's hash index."""
|
||||
|
||||
@@ -4036,6 +4101,214 @@ class RecipeScanner:
|
||||
updated_lora = self._enrich_lora_entry(dict(lora_entry))
|
||||
return recipe_data, updated_lora
|
||||
|
||||
async def update_checkpoint_entry(
|
||||
self,
|
||||
recipe_id: str,
|
||||
*,
|
||||
target_name: str,
|
||||
target_checkpoint: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Update the checkpoint entry within a recipe (manual reconnect).
|
||||
|
||||
Mirrors :meth:`update_lora_entry`: the pre-update entry is snapshotted
|
||||
under ``reconnectSnapshot`` so the association can be restored later,
|
||||
then the matched local checkpoint is written back following the same
|
||||
pinned key set as ``_write_rematch_checkpoint_entry``. ``file_name``
|
||||
keeps the user-entered ``target_name`` (the same convention as the
|
||||
LoRA reconnect), while hash/name/version/baseModel/identifier are
|
||||
refreshed from the local item. The fingerprint is untouched — it is
|
||||
computed over LoRAs only.
|
||||
|
||||
Returns:
|
||||
The updated recipe data and the refreshed checkpoint metadata.
|
||||
"""
|
||||
if target_name is None:
|
||||
raise ValueError("target_name must be provided")
|
||||
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError(
|
||||
"Recipe has no checkpoint entry to reconnect"
|
||||
)
|
||||
|
||||
# Snapshot the pre-update state so the association can be restored
|
||||
# later (undo reconnect). Never nest snapshots.
|
||||
snapshot = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in checkpoint.items()
|
||||
if key != "reconnectSnapshot"
|
||||
}
|
||||
checkpoint["isDeleted"] = False
|
||||
checkpoint["hashInvalid"] = False
|
||||
checkpoint["file_name"] = target_name
|
||||
|
||||
if target_checkpoint is not None:
|
||||
sha_value = target_checkpoint.get("sha256") or target_checkpoint.get(
|
||||
"sha"
|
||||
)
|
||||
if sha_value:
|
||||
checkpoint["hash"] = sha_value.lower()
|
||||
|
||||
self._write_rematch_checkpoint_entry(checkpoint, target_checkpoint)
|
||||
|
||||
# The write-back only refreshes keys the entry already has;
|
||||
# a manual reconnect must also backfill the display keys so a
|
||||
# sparse parser-style entry renders properly after the swap.
|
||||
if not checkpoint.get("name") and target_checkpoint.get("model_name"):
|
||||
checkpoint["name"] = target_checkpoint["model_name"]
|
||||
civitai = target_checkpoint.get("civitai") or {}
|
||||
civ_name = civitai.get("name")
|
||||
if not checkpoint.get("version") and civ_name:
|
||||
checkpoint["version"] = civ_name
|
||||
if (
|
||||
not checkpoint.get("baseModel")
|
||||
and target_checkpoint.get("base_model")
|
||||
):
|
||||
checkpoint["baseModel"] = target_checkpoint["base_model"]
|
||||
|
||||
checkpoint["reconnectSnapshot"] = snapshot
|
||||
recipe_data["modified"] = time.time()
|
||||
|
||||
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
|
||||
if not replaced:
|
||||
await cache.add_recipe(recipe_data, resort=False)
|
||||
self._schedule_resort()
|
||||
|
||||
# Update FTS index
|
||||
self._update_fts_index_for_recipe(recipe_data, "update")
|
||||
|
||||
# Update persistent SQLite cache
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
|
||||
self._json_path_map[recipe_id] = recipe_json_path
|
||||
|
||||
updated_checkpoint = dict(checkpoint)
|
||||
if target_checkpoint is not None:
|
||||
preview_url = target_checkpoint.get("preview_url")
|
||||
if preview_url:
|
||||
updated_checkpoint["preview_url"] = config.get_preview_static_url(
|
||||
preview_url
|
||||
)
|
||||
if target_checkpoint.get("file_path"):
|
||||
updated_checkpoint["localPath"] = target_checkpoint["file_path"]
|
||||
|
||||
updated_checkpoint = self._enrich_checkpoint_entry(updated_checkpoint)
|
||||
return recipe_data, updated_checkpoint
|
||||
|
||||
async def restore_checkpoint_entry(
|
||||
self,
|
||||
recipe_id: str,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Restore the checkpoint entry to its pre-reconnect snapshot.
|
||||
|
||||
Reverses :meth:`update_checkpoint_entry`: the entry saved under
|
||||
``reconnectSnapshot`` becomes the checkpoint again and the snapshot is
|
||||
dropped. Returns the updated recipe data and the restored checkpoint
|
||||
metadata.
|
||||
"""
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError(
|
||||
"Recipe has no checkpoint entry to restore"
|
||||
)
|
||||
|
||||
snapshot = checkpoint.get("reconnectSnapshot")
|
||||
if not isinstance(snapshot, dict):
|
||||
raise RecipeValidationError(
|
||||
"Checkpoint entry has no reconnect snapshot to restore"
|
||||
)
|
||||
|
||||
restored_entry = copy.deepcopy(snapshot)
|
||||
restored_entry.pop("reconnectSnapshot", None)
|
||||
recipe_data["checkpoint"] = restored_entry
|
||||
recipe_data["modified"] = time.time()
|
||||
|
||||
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
|
||||
if not replaced:
|
||||
await cache.add_recipe(recipe_data, resort=False)
|
||||
self._schedule_resort()
|
||||
|
||||
# Update FTS index
|
||||
self._update_fts_index_for_recipe(recipe_data, "update")
|
||||
|
||||
# Update persistent SQLite cache
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
|
||||
self._json_path_map[recipe_id] = recipe_json_path
|
||||
|
||||
restored_checkpoint = self._enrich_checkpoint_entry(dict(restored_entry))
|
||||
return recipe_data, restored_checkpoint
|
||||
|
||||
async def set_checkpoint_entry_hash_invalid(
|
||||
self,
|
||||
recipe_id: str,
|
||||
hash_invalid: bool,
|
||||
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
"""Set the ``hashInvalid`` flag on the recipe's checkpoint entry.
|
||||
|
||||
``hashInvalid`` records that the entry's hash could not be resolved
|
||||
on CivitAI (e.g. a download attempt returned "Model not found").
|
||||
Marking it makes the entry an unresolved rematch candidate without
|
||||
touching its stored hash/file_name.
|
||||
|
||||
Returns:
|
||||
The updated recipe data and the refreshed checkpoint metadata.
|
||||
"""
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError("Checkpoint entry is not a dict")
|
||||
|
||||
checkpoint["hashInvalid"] = bool(hash_invalid)
|
||||
recipe_data["modified"] = time.time()
|
||||
|
||||
with open(recipe_json_path, "w", encoding="utf-8") as file_obj:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False)
|
||||
if not replaced:
|
||||
await cache.add_recipe(recipe_data, resort=False)
|
||||
self._schedule_resort()
|
||||
|
||||
if self._persistent_cache:
|
||||
self._persistent_cache.update_recipe(recipe_data, recipe_json_path)
|
||||
self._json_path_map[recipe_id] = recipe_json_path
|
||||
|
||||
updated_checkpoint = self._enrich_checkpoint_entry(dict(checkpoint))
|
||||
return recipe_data, updated_checkpoint
|
||||
|
||||
async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
|
||||
"""Return recipes that reference a given LoRA hash."""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Recipe service layer implementations."""
|
||||
|
||||
from .analysis_service import RecipeAnalysisService
|
||||
from .import_info import build_import_info, compute_no_loras_reason
|
||||
from .persistence_service import RecipePersistenceService
|
||||
from .sharing_service import RecipeSharingService
|
||||
from .errors import (
|
||||
@@ -15,6 +16,8 @@ __all__ = [
|
||||
"RecipeAnalysisService",
|
||||
"RecipePersistenceService",
|
||||
"RecipeSharingService",
|
||||
"build_import_info",
|
||||
"compute_no_loras_reason",
|
||||
"RecipeServiceError",
|
||||
"RecipeValidationError",
|
||||
"RecipeNotFoundError",
|
||||
|
||||
@@ -72,15 +72,28 @@ class RecipeAnalysisService:
|
||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||
if not metadata:
|
||||
return AnalysisResult(
|
||||
{"error": "No metadata found in this image", "loras": []}
|
||||
{
|
||||
"error": "No metadata found in this image",
|
||||
"loras": [],
|
||||
"diagnostics": {
|
||||
"channel": "upload",
|
||||
"exif_present": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return await self._parse_metadata(
|
||||
result = await self._parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=None,
|
||||
include_image_base64=False,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "upload",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
finally:
|
||||
self._safe_cleanup(temp_path)
|
||||
|
||||
@@ -104,9 +117,13 @@ class RecipeAnalysisService:
|
||||
image_info: Optional[dict[str, Any]] = None
|
||||
is_video = False
|
||||
extension = ".jpg" # Default
|
||||
# Diagnostics collected during analysis; surfaced in the payload so
|
||||
# callers can persist an import_info block explaining empty LoRA lists.
|
||||
diagnostics: dict[str, Any] = {"channel": "url"}
|
||||
|
||||
try:
|
||||
civitai_image_id = extract_civitai_image_id(url)
|
||||
diagnostics["civitai_image"] = bool(civitai_image_id)
|
||||
if civitai_image_id:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=url
|
||||
@@ -147,11 +164,23 @@ class RecipeAnalysisService:
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# Diagnostics: capture the API meta shape before injecting
|
||||
# modelVersionIds / browsingLevel so the recipe modal can
|
||||
# explain why an import ended up without LoRAs.
|
||||
diagnostics["api_meta_present"] = isinstance(metadata, dict)
|
||||
if isinstance(metadata, dict):
|
||||
diagnostics["api_meta_keys"] = sorted(metadata.keys())
|
||||
|
||||
# Include modelVersionIds from root level if available.
|
||||
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||
# When meta is null (None), create a minimal dict so downstream
|
||||
# parsers can still discover LoRAs and checkpoints.
|
||||
model_version_ids = image_info.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(model_version_ids)
|
||||
if isinstance(model_version_ids, list)
|
||||
else 0
|
||||
)
|
||||
if model_version_ids:
|
||||
if isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
@@ -229,6 +258,8 @@ class RecipeAnalysisService:
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
diagnostics["exif_present"] = bool(exif_metadata)
|
||||
|
||||
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||
@@ -237,6 +268,7 @@ class RecipeAnalysisService:
|
||||
if isinstance(exif_metadata, str):
|
||||
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||
if exif_parser:
|
||||
diagnostics["exif_parser"] = exif_parser.__class__.__name__
|
||||
exif_data = await exif_parser.parse_metadata(
|
||||
exif_metadata, recipe_scanner=recipe_scanner,
|
||||
)
|
||||
@@ -324,6 +356,8 @@ class RecipeAnalysisService:
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
result.payload["preview_nsfw_level"] = bl
|
||||
|
||||
diagnostics["is_video"] = is_video
|
||||
result.payload["diagnostics"] = diagnostics
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -348,14 +382,25 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata, normalized_path
|
||||
)
|
||||
if not metadata:
|
||||
return self._metadata_not_found_response(normalized_path)
|
||||
result = self._metadata_not_found_response(normalized_path)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": False,
|
||||
}
|
||||
return result
|
||||
|
||||
return await self._parse_metadata(
|
||||
result = await self._parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=normalized_path,
|
||||
include_image_base64=True,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
|
||||
async def analyze_widget_metadata(self, *, recipe_scanner) -> AnalysisResult:
|
||||
"""Analyse the most recent generation metadata for widget saves."""
|
||||
@@ -452,6 +497,10 @@ class RecipeAnalysisService:
|
||||
metadata, recipe_scanner=recipe_scanner
|
||||
)
|
||||
|
||||
# Record which parser handled the metadata so import diagnostics
|
||||
# can distinguish e.g. ComfyUI workflow sources.
|
||||
result["parser"] = parser.__class__.__name__
|
||||
|
||||
if include_image_base64 and image_path:
|
||||
result["image_base64"] = self._encode_file(image_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Import provenance helpers for recipes.
|
||||
|
||||
Builds the ``import_info`` block persisted on a recipe: the import channel
|
||||
(batch import / single URL / local file / upload / widget) and, when the
|
||||
recipe ended up with no LoRAs, a machine-readable reason plus the diagnostic
|
||||
details that led to it. The recipe modal renders this block in a collapsed
|
||||
"Why no LoRAs?" panel; legacy recipes without ``import_info`` fall back to a
|
||||
frontend heuristic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Import channels (how the recipe entered the library).
|
||||
CHANNEL_BATCH_IMPORT_URL = "batch_import_url"
|
||||
CHANNEL_BATCH_IMPORT_LOCAL = "batch_import_local"
|
||||
CHANNEL_URL = "url"
|
||||
CHANNEL_LOCAL = "local"
|
||||
CHANNEL_UPLOAD = "upload"
|
||||
CHANNEL_WIDGET = "widget"
|
||||
CHANNEL_REIMPORT_URL = "reimport_url"
|
||||
CHANNEL_REIMPORT_LOCAL = "reimport_local"
|
||||
|
||||
_URL_CHANNELS = frozenset(
|
||||
{CHANNEL_BATCH_IMPORT_URL, CHANNEL_URL, CHANNEL_REIMPORT_URL}
|
||||
)
|
||||
|
||||
# No-LoRA reason codes (persisted, consumed by the recipe modal).
|
||||
REASON_NO_LORAS_USED = "no_loras_used"
|
||||
REASON_API_NO_LORA_RESOURCES = "api_meta_no_lora_resources"
|
||||
REASON_API_META_MISSING = "api_meta_missing"
|
||||
REASON_NO_EMBEDDED_METADATA = "no_embedded_metadata"
|
||||
REASON_WORKFLOW_METADATA_LIMITED = "workflow_metadata_limited"
|
||||
REASON_VIDEO_NO_METADATA = "video_no_metadata"
|
||||
REASON_METADATA_UNSUPPORTED = "metadata_unsupported"
|
||||
REASON_UNKNOWN = "unknown"
|
||||
|
||||
_COMFY_PARSER_NAME = "ComfyMetadataParser"
|
||||
|
||||
# Cap for api_meta_keys kept in details — enough for the UI bullet without
|
||||
# bloating the recipe JSON.
|
||||
_MAX_DETAIL_KEYS = 12
|
||||
|
||||
|
||||
def compute_no_loras_reason(
|
||||
channel: str, diagnostics: Optional[Dict[str, Any]]
|
||||
) -> str:
|
||||
"""Classify why an import produced no LoRA entries.
|
||||
|
||||
Args:
|
||||
channel: One of the CHANNEL_* constants.
|
||||
diagnostics: Signals collected during analysis (see
|
||||
``RecipeAnalysisService``), or None for channels without analysis
|
||||
(e.g. widget saves).
|
||||
"""
|
||||
diag = diagnostics or {}
|
||||
|
||||
if diag.get("is_video"):
|
||||
return REASON_VIDEO_NO_METADATA
|
||||
|
||||
# Embedded metadata that is a ComfyUI workflow: LoRA extraction from
|
||||
# workflows is limited, so report that specifically.
|
||||
parser = diag.get("exif_parser") or diag.get("parser")
|
||||
if parser == _COMFY_PARSER_NAME:
|
||||
return REASON_WORKFLOW_METADATA_LIMITED
|
||||
|
||||
if channel in _URL_CHANNELS:
|
||||
if not diag.get("civitai_image"):
|
||||
# Generic (non-CivitAI) URL: only embedded metadata is available.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return (
|
||||
REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
# NOTE: no "parsed EXIF means no LoRAs were used" shortcut here.
|
||||
# CivitAI's onsite generator writes A1111-style EXIF (prompt, seed,
|
||||
# steps, ...) WITHOUT LoRA references — LoRA usage lives only in
|
||||
# CivitAI-internal data — so cleanly parsed EXIF cannot prove the
|
||||
# generation used no LoRAs. Report the API meta shape instead.
|
||||
api_keys = diag.get("api_meta_keys") or []
|
||||
api_mvids = diag.get("api_model_version_ids") or 0
|
||||
if api_keys or api_mvids:
|
||||
return REASON_API_NO_LORA_RESOURCES
|
||||
return REASON_API_META_MISSING
|
||||
|
||||
if channel == CHANNEL_WIDGET:
|
||||
return REASON_NO_LORAS_USED
|
||||
|
||||
# Local file / upload / local re-import: embedded metadata only.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
|
||||
|
||||
def build_import_info(
|
||||
channel: str,
|
||||
diagnostics: Optional[Dict[str, Any]],
|
||||
loras: Optional[List[Dict[str, Any]]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the ``import_info`` block persisted on a recipe.
|
||||
|
||||
Always records the import channel; adds ``reason`` and ``details`` only
|
||||
when the recipe has no LoRAs.
|
||||
"""
|
||||
info: Dict[str, Any] = {"channel": channel}
|
||||
if loras:
|
||||
return info
|
||||
|
||||
info["reason"] = compute_no_loras_reason(channel, diagnostics)
|
||||
|
||||
diag = diagnostics or {}
|
||||
details: Dict[str, Any] = {}
|
||||
api_keys = diag.get("api_meta_keys")
|
||||
if api_keys:
|
||||
details["api_meta_keys"] = list(api_keys)[:_MAX_DETAIL_KEYS]
|
||||
api_mvids = diag.get("api_model_version_ids")
|
||||
if api_mvids is not None:
|
||||
details["api_model_version_ids"] = api_mvids
|
||||
if "exif_present" in diag:
|
||||
details["exif_present"] = bool(diag.get("exif_present"))
|
||||
if diag.get("exif_parser"):
|
||||
details["exif_parser"] = diag["exif_parser"]
|
||||
if diag.get("is_video"):
|
||||
details["is_video"] = True
|
||||
if details:
|
||||
info["details"] = details
|
||||
|
||||
return info
|
||||
@@ -21,6 +21,7 @@ from ...utils.base_model import (
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ..pending_delete_service import get_pending_delete_service
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
from .import_info import CHANNEL_UPLOAD, CHANNEL_WIDGET, build_import_info
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -134,6 +135,22 @@ class RecipePersistenceService:
|
||||
if metadata.get("source_path"):
|
||||
recipe_data["source_path"] = metadata.get("source_path")
|
||||
|
||||
# Persist import provenance. Batch import / re-import paths pass a
|
||||
# prebuilt import_info; frontend-driven saves (upload, single URL,
|
||||
# local path) carry the analysis payload's diagnostics, from which
|
||||
# import_info is derived here.
|
||||
import_info = metadata.get("import_info")
|
||||
if not isinstance(import_info, dict):
|
||||
diagnostics = metadata.get("diagnostics")
|
||||
if isinstance(diagnostics, dict):
|
||||
import_info = build_import_info(
|
||||
diagnostics.get("channel") or CHANNEL_UPLOAD,
|
||||
diagnostics,
|
||||
loras_data,
|
||||
)
|
||||
if isinstance(import_info, dict) and import_info:
|
||||
recipe_data["import_info"] = import_info
|
||||
|
||||
nsfw_level = metadata.get("preview_nsfw_level")
|
||||
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||
@@ -582,6 +599,172 @@ class RecipePersistenceService:
|
||||
}
|
||||
)
|
||||
|
||||
async def reconnect_checkpoint(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
target_name: str,
|
||||
) -> PersistenceResult:
|
||||
"""Reconnect the checkpoint entry within an existing recipe."""
|
||||
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_base_model = json.load(file_obj).get("base_model", "")
|
||||
|
||||
matches = await recipe_scanner.find_local_checkpoints_by_name(target_name)
|
||||
if not matches:
|
||||
raise RecipeNotFoundError(
|
||||
f"Local checkpoint not found with name: {target_name}"
|
||||
)
|
||||
|
||||
# Same three-tier base-model guard as reconnect_lora: exact/unknown
|
||||
# labels pass silently; same-architecture-family labels pass but are
|
||||
# reported so the UI can warn; confident mismatches stay hard-rejected.
|
||||
eligible: list[tuple[dict, str]] = []
|
||||
for match in matches:
|
||||
relation = base_model_relation(recipe_base_model, match.get("base_model"))
|
||||
if relation != RELATION_INCOMPATIBLE:
|
||||
eligible.append((match, relation))
|
||||
|
||||
if not eligible:
|
||||
raise RecipeValidationError(
|
||||
f"Local checkpoint '{target_name}' has a different base model "
|
||||
"than the recipe"
|
||||
)
|
||||
if len(eligible) > 1:
|
||||
raise RecipeValidationError(
|
||||
f"Multiple local checkpoints match '{target_name}'; "
|
||||
"include the folder path to disambiguate"
|
||||
)
|
||||
target_checkpoint, target_relation = eligible[0]
|
||||
|
||||
recipe_data, updated_checkpoint = await recipe_scanner.update_checkpoint_entry(
|
||||
recipe_id,
|
||||
target_name=target_name,
|
||||
target_checkpoint=target_checkpoint,
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
matching_recipes = []
|
||||
if "fingerprint" in recipe_data:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||
recipe_data["fingerprint"]
|
||||
)
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
if target_relation == RELATION_COMPATIBLE:
|
||||
# Structured data, not prose — the frontend localizes the warning.
|
||||
payload["base_model_mismatch"] = {
|
||||
"recipe_base_model": recipe_base_model,
|
||||
"checkpoint_base_model": target_checkpoint.get("base_model") or "",
|
||||
}
|
||||
return PersistenceResult(payload)
|
||||
|
||||
async def restore_checkpoint(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
) -> PersistenceResult:
|
||||
"""Restore the checkpoint entry to the state captured before its reconnect."""
|
||||
|
||||
recipe_data, updated_checkpoint = await recipe_scanner.restore_checkpoint_entry(
|
||||
recipe_id
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
matching_recipes = []
|
||||
if "fingerprint" in recipe_data:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||
recipe_data["fingerprint"]
|
||||
)
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
query: str | None = None,
|
||||
) -> PersistenceResult:
|
||||
"""Return ranked local checkpoint candidates for reconnecting a recipe entry."""
|
||||
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError("Recipe has no checkpoint entry")
|
||||
|
||||
suggestions = await recipe_scanner.suggest_checkpoint_reconnect_candidates(
|
||||
entry=checkpoint,
|
||||
recipe_base_model=recipe_data.get("base_model"),
|
||||
query=query,
|
||||
)
|
||||
|
||||
return PersistenceResult({"success": True, "suggestions": suggestions})
|
||||
|
||||
async def mark_checkpoint_hash_invalid(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
hash_invalid: bool = True,
|
||||
) -> PersistenceResult:
|
||||
"""Mark the recipe checkpoint entry's hash as unresolvable on CivitAI.
|
||||
|
||||
Called when a download attempt by hash returned "Model not found".
|
||||
The flag makes the entry an unresolved rematch candidate without
|
||||
altering its stored hash/file_name.
|
||||
"""
|
||||
|
||||
recipe_data, updated_checkpoint = (
|
||||
await recipe_scanner.set_checkpoint_entry_hash_invalid(
|
||||
recipe_id,
|
||||
hash_invalid=hash_invalid,
|
||||
)
|
||||
)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"hash_invalid": bool(hash_invalid),
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
}
|
||||
)
|
||||
|
||||
async def bulk_delete(
|
||||
self,
|
||||
*,
|
||||
@@ -731,6 +914,9 @@ class RecipePersistenceService:
|
||||
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
|
||||
# embedded metadata chunks, so a workflow can never be present.
|
||||
"has_workflow": False,
|
||||
# Widget saves read LoRAs straight from the current workflow; an
|
||||
# empty list means the workflow used no LoRAs.
|
||||
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
|
||||
@@ -715,6 +715,72 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Empty LoRA list + collapsible "Why no LoRAs?" explanation */
|
||||
.no-loras {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.no-loras-reason {
|
||||
margin: var(--space-1) 0 var(--space-2);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.no-loras-reason summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Hide the native disclosure triangle; rotate the icon instead. */
|
||||
.no-loras-reason summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-loras-reason summary i {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.no-loras-reason[open] summary i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.no-loras-reason summary:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.no-loras-reason-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-loras-reason-body ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.no-loras-reason-body li {
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.no-loras-bullet-label {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-loras-inferred-note {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recipe-checkpoint-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -891,8 +957,10 @@
|
||||
}
|
||||
|
||||
/* Restore icon for manually reconnected entries: its presence on the info
|
||||
row doubles as the "was reconnected" marker. */
|
||||
.lora-undo-reconnect {
|
||||
row doubles as the "was reconnected" marker. Shared by LoRA and
|
||||
checkpoint entries, which use the same info-row flex layout. */
|
||||
.lora-undo-reconnect,
|
||||
.checkpoint-undo-reconnect {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -907,7 +975,9 @@
|
||||
}
|
||||
|
||||
.lora-undo-reconnect:hover,
|
||||
.lora-undo-reconnect:focus-visible {
|
||||
.lora-undo-reconnect:focus-visible,
|
||||
.checkpoint-undo-reconnect:hover,
|
||||
.checkpoint-undo-reconnect:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
background: var(--lora-surface);
|
||||
|
||||
@@ -66,6 +66,29 @@ function escapeHtml(value) {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Fallback English strings for the collapsed "Why no LoRAs?" panel.
|
||||
// Translations live in locales/*.json under recipes.resources.
|
||||
const NO_LORAS_REASON_FALLBACKS = {
|
||||
api_meta_no_lora_resources: 'The source API returned no LoRA resource data for this image. LoRAs shown on the CivitAI page may come from internal data that the public API does not expose.',
|
||||
api_meta_missing: 'The source API returned no generation metadata for this image.',
|
||||
no_embedded_metadata: 'The image has no embedded generation metadata, so LoRA information could not be recovered.',
|
||||
workflow_metadata_limited: "The image's embedded metadata is a ComfyUI workflow; extracting LoRA information from workflows is limited.",
|
||||
video_no_metadata: 'Video files do not carry embedded generation metadata.',
|
||||
metadata_unsupported: 'The image contains metadata in a format that could not be parsed.',
|
||||
unknown: 'The reason could not be determined from the stored recipe data.',
|
||||
};
|
||||
|
||||
const NO_LORAS_CHANNEL_FALLBACKS = {
|
||||
batch_import_url: 'Batch import (image URL)',
|
||||
batch_import_local: 'Batch import (local file)',
|
||||
url: 'Image URL import',
|
||||
local: 'Local file import',
|
||||
upload: 'Image upload',
|
||||
widget: 'Saved from workflow',
|
||||
reimport_url: 'Re-import (image URL)',
|
||||
reimport_local: 'Re-import (local file)',
|
||||
};
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
@@ -825,6 +848,14 @@ class RecipeModal {
|
||||
const loras = Array.isArray(recipe.loras) ? recipe.loras : [];
|
||||
|
||||
if (checkpointContainer) {
|
||||
// The innerHTML below discards the checkpoint reconnect container;
|
||||
// tear down its Combobox panel (appended to document.body) first.
|
||||
const checkpointPanel = checkpointContainer.querySelector(
|
||||
'.lora-reconnect-container[data-lora-index="checkpoint"]'
|
||||
);
|
||||
if (checkpointPanel) {
|
||||
this._destroyReconnectCombobox(checkpointPanel);
|
||||
}
|
||||
checkpointContainer.innerHTML = '';
|
||||
if (recipe.checkpoint && typeof recipe.checkpoint === 'object') {
|
||||
checkpointContainer.innerHTML = this.renderCheckpoint(recipe.checkpoint);
|
||||
@@ -891,6 +922,13 @@ class RecipeModal {
|
||||
const isDeleted = lora.isDeleted;
|
||||
const loraIndex = loras.indexOf(lora);
|
||||
|
||||
// Mirror the checkpoint "broken" rule: deleted, an
|
||||
// unresolvable hash, or a name-only remnant with no CivitAI
|
||||
// identifiers at all cannot be fixed by downloading —
|
||||
// reconnecting a local LoRA is the only remediation.
|
||||
const needsReconnect = !existsLocally
|
||||
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
||||
|
||||
// Status badges are pure indicators (consistent with the
|
||||
// versions-tab pattern): they never carry click behavior,
|
||||
// only a tooltip. Remediation lives in the action row below.
|
||||
@@ -917,7 +955,7 @@ class RecipeModal {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const actionsRow = this.renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted });
|
||||
const actionsRow = this.renderLoraItemActions(loraIndex, { existsLocally, needsReconnect });
|
||||
|
||||
// The Civitai link belongs to the model name (it answers
|
||||
// "what is this"), so it sits inline in the title — the same
|
||||
@@ -988,7 +1026,7 @@ class RecipeModal {
|
||||
</div>
|
||||
${actionsRow}
|
||||
</div>
|
||||
${isDeleted || lora.hashInvalid ? `
|
||||
${needsReconnect ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
@@ -1017,7 +1055,7 @@ class RecipeModal {
|
||||
this.recipeLorasSyntax = '';
|
||||
} else if (lorasListElement) {
|
||||
this._destroyAllReconnectComboboxes();
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
lorasListElement.innerHTML = this.renderNoLorasState(recipe);
|
||||
this.recipeLorasSyntax = '';
|
||||
}
|
||||
|
||||
@@ -1028,6 +1066,156 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the empty LoRA list, including a collapsed "Why no LoRAs?"
|
||||
* explanation panel when the cause is known or can be inferred.
|
||||
* @param {Object} recipe
|
||||
* @returns {string}
|
||||
*/
|
||||
renderNoLorasState(recipe) {
|
||||
const emptyText = translate(
|
||||
'recipes.resources.noLorasAssociated',
|
||||
{},
|
||||
'No LoRAs associated with this recipe'
|
||||
);
|
||||
const reason = this.resolveNoLorasReason(recipe);
|
||||
let html = `<div class="no-loras">${escapeHtml(emptyText)}</div>`;
|
||||
|
||||
// 'no_loras_used' is the normal case — the generation simply used no
|
||||
// LoRAs, nothing to explain.
|
||||
if (!reason || reason.code === 'no_loras_used') {
|
||||
return html;
|
||||
}
|
||||
|
||||
const toggle = translate('recipes.resources.noLorasWhyToggle', {}, 'Why no LoRAs?');
|
||||
const reasonText = translate(
|
||||
`recipes.resources.noLorasReasons.${reason.code}`,
|
||||
{},
|
||||
NO_LORAS_REASON_FALLBACKS[reason.code] || NO_LORAS_REASON_FALLBACKS.unknown
|
||||
);
|
||||
|
||||
const bullets = [];
|
||||
if (reason.channel) {
|
||||
const channelText = translate(
|
||||
`recipes.resources.noLorasChannels.${reason.channel}`,
|
||||
{},
|
||||
NO_LORAS_CHANNEL_FALLBACKS[reason.channel] || reason.channel
|
||||
);
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(translate('recipes.resources.noLorasImportMethod', {}, 'Import method'))}:</span> ${escapeHtml(channelText)}</li>`
|
||||
);
|
||||
}
|
||||
bullets.push(`<li>${escapeHtml(reasonText)}</li>`);
|
||||
bullets.push(...this.renderNoLorasDetailBullets(reason));
|
||||
if (reason.inferred) {
|
||||
bullets.push(
|
||||
`<li class="no-loras-inferred-note">${escapeHtml(translate('recipes.resources.noLorasInferredNote', {}, 'Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.'))}</li>`
|
||||
);
|
||||
}
|
||||
|
||||
html += `
|
||||
<details class="no-loras-reason">
|
||||
<summary><i class="fas fa-circle-question" aria-hidden="true"></i> ${escapeHtml(toggle)}</summary>
|
||||
<div class="no-loras-reason-body"><ul>${bullets.join('')}</ul></div>
|
||||
</details>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the no-LoRA reason: recorded import_info takes precedence;
|
||||
* legacy recipes without it fall back to heuristics on the stored data.
|
||||
* @param {Object} recipe
|
||||
* @returns {{code: string, channel: ?string, details: ?Object, inferred: boolean}|null}
|
||||
*/
|
||||
resolveNoLorasReason(recipe) {
|
||||
const importInfo =
|
||||
recipe && typeof recipe.import_info === 'object' && recipe.import_info !== null
|
||||
? recipe.import_info
|
||||
: null;
|
||||
if (importInfo && typeof importInfo.reason === 'string' && importInfo.reason) {
|
||||
return {
|
||||
code: importInfo.reason,
|
||||
channel: typeof importInfo.channel === 'string' ? importInfo.channel : null,
|
||||
details:
|
||||
typeof importInfo.details === 'object' && importInfo.details !== null
|
||||
? importInfo.details
|
||||
: null,
|
||||
inferred: false,
|
||||
};
|
||||
}
|
||||
return this.inferNoLorasReason(recipe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic reason for legacy recipes that predate import_info.
|
||||
* @param {Object} recipe
|
||||
* @returns {{code: string, channel: ?string, details: ?Object, inferred: boolean}}
|
||||
*/
|
||||
inferNoLorasReason(recipe) {
|
||||
const sourcePath = recipe && recipe.source_path ? String(recipe.source_path).trim() : '';
|
||||
const genParams =
|
||||
recipe && recipe.gen_params && typeof recipe.gen_params === 'object'
|
||||
? recipe.gen_params
|
||||
: {};
|
||||
const paramKeys = Object.keys(genParams).filter(
|
||||
(key) => genParams[key] !== '' && genParams[key] !== null && genParams[key] !== undefined
|
||||
);
|
||||
|
||||
if (recipe && recipe.has_workflow) {
|
||||
return { code: 'workflow_metadata_limited', channel: null, details: null, inferred: true };
|
||||
}
|
||||
if (/^https?:\/\//i.test(sourcePath)) {
|
||||
// URL imports come from CivitAI; a missing LoRA list there almost
|
||||
// always means the public API did not report LoRA resources.
|
||||
return { code: 'api_meta_no_lora_resources', channel: 'url', details: null, inferred: true };
|
||||
}
|
||||
if (sourcePath) {
|
||||
return paramKeys.length === 0
|
||||
? { code: 'no_embedded_metadata', channel: 'local', details: null, inferred: true }
|
||||
: { code: 'no_loras_used', channel: 'local', details: null, inferred: true };
|
||||
}
|
||||
if (paramKeys.length > 0) {
|
||||
return { code: 'no_loras_used', channel: null, details: null, inferred: true };
|
||||
}
|
||||
return { code: 'unknown', channel: null, details: null, inferred: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the recorded diagnostic detail bullets (API meta shape, EXIF
|
||||
* presence). Only shown for recorded (non-inferred) import_info.
|
||||
* @param {{details: ?Object}} reason
|
||||
* @returns {string[]}
|
||||
*/
|
||||
renderNoLorasDetailBullets(reason) {
|
||||
const details = reason.details;
|
||||
if (!details) {
|
||||
return [];
|
||||
}
|
||||
const bullets = [];
|
||||
if (Array.isArray(details.api_meta_keys) && details.api_meta_keys.length > 0) {
|
||||
const label = translate('recipes.resources.noLorasDetails.apiMetaFields', {}, 'API metadata fields');
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(label)}:</span> ${escapeHtml(details.api_meta_keys.join(', '))}</li>`
|
||||
);
|
||||
}
|
||||
if (typeof details.api_model_version_ids === 'number') {
|
||||
const label = translate('recipes.resources.noLorasDetails.modelVersionIds', {}, 'Model version IDs reported');
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(label)}:</span> ${details.api_model_version_ids}</li>`
|
||||
);
|
||||
}
|
||||
if (typeof details.exif_present === 'boolean') {
|
||||
const label = translate('recipes.resources.noLorasDetails.embeddedMetadata', {}, 'Embedded metadata');
|
||||
const value = details.exif_present
|
||||
? translate('recipes.resources.noLorasDetails.present', {}, 'found')
|
||||
: translate('recipes.resources.noLorasDetails.absent', {}, 'none');
|
||||
bullets.push(
|
||||
`<li><span class="no-loras-bullet-label">${escapeHtml(label)}:</span> ${escapeHtml(value)}</li>`
|
||||
);
|
||||
}
|
||||
return bullets;
|
||||
}
|
||||
|
||||
updateSourceUrlDisplay(sourcePath, options = {}) {
|
||||
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||
@@ -1763,7 +1951,9 @@ class RecipeModal {
|
||||
},
|
||||
// emptyText only labels the dropdown empty state; the input keeps
|
||||
// its own translated placeholder from the markup.
|
||||
emptyText: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'),
|
||||
emptyText: String(loraIndex) === 'checkpoint'
|
||||
? translate('recipes.resources.checkpointReconnectSuggestionsEmpty', {}, 'No matching checkpoints in your local library')
|
||||
: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library'),
|
||||
onCommit: (value) => {
|
||||
this.reconnectLora(loraIndex, value);
|
||||
},
|
||||
@@ -1790,7 +1980,10 @@ class RecipeModal {
|
||||
|
||||
async _fetchReconnectSuggestions(loraIndex, query) {
|
||||
const suffix = query ? `?query=${encodeURIComponent(query)}` : '';
|
||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/lora/${loraIndex}/reconnect-suggestions${suffix}`);
|
||||
const targetPath = String(loraIndex) === 'checkpoint'
|
||||
? 'checkpoint/reconnect-suggestions'
|
||||
: `lora/${loraIndex}/reconnect-suggestions`;
|
||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/${targetPath}${suffix}`);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
@@ -1831,7 +2024,9 @@ class RecipeModal {
|
||||
if (!suggestions.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'reconnect-suggestions-empty';
|
||||
empty.textContent = translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
|
||||
empty.textContent = String(loraIndex) === 'checkpoint'
|
||||
? translate('recipes.resources.checkpointReconnectSuggestionsEmpty', {}, 'No matching checkpoints in your local library')
|
||||
: translate('recipes.resources.reconnectSuggestionsEmpty', {}, 'No matching LoRAs in your local library');
|
||||
listElement.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -1888,6 +2083,12 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
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}"]`);
|
||||
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
@@ -2003,8 +2204,153 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
async reconnectCheckpoint(inputValue) {
|
||||
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
this.showReconnectError(container, translate('toast.recipes.enterCheckpointName', {}, 'Please enter a checkpoint name'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove .safetensors extension if present
|
||||
const fileName = inputValue.trim().replace(/\.safetensors$/, '');
|
||||
|
||||
state.loadingManager.showSimpleLoading('Reconnecting checkpoint...');
|
||||
|
||||
// Call API to reconnect the checkpoint entry
|
||||
const response = await fetch('/api/lm/recipe/checkpoint/reconnect', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_id: this.recipeId,
|
||||
target_name: fileName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Hide the reconnect input
|
||||
this.hideReconnectInput(container);
|
||||
|
||||
// Update the current recipe with the updated checkpoint data
|
||||
this.currentRecipe.checkpoint = result.updated_checkpoint;
|
||||
|
||||
// Show success message
|
||||
showToast('toast.recipes.checkpointReconnectedSuccessfully', {}, 'success');
|
||||
|
||||
// Same-architecture-family reconnects (e.g. Pony ↔ Illustrious)
|
||||
// succeed but carry structured mismatch data — warn the user.
|
||||
if (result.base_model_mismatch) {
|
||||
showToast(
|
||||
'toast.recipes.reconnectCheckpointBaseModelMismatch',
|
||||
{
|
||||
recipe: result.base_model_mismatch.recipe_base_model,
|
||||
checkpoint: result.base_model_mismatch.checkpoint_base_model,
|
||||
},
|
||||
'warning'
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh modal to show updated content
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
}, 500);
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||
checkpoint: this.currentRecipe.checkpoint
|
||||
});
|
||||
} else {
|
||||
this.showReconnectError(container, translate('toast.recipes.checkpointReconnectFailed', { message: result.error }, `Error reconnecting checkpoint: ${result.error}`));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reconnecting checkpoint:', error);
|
||||
this.showReconnectError(container, translate('toast.recipes.checkpointReconnectFailed', { message: error.message }, `Error reconnecting checkpoint: ${error.message}`));
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async restoreCheckpoint() {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Restoring checkpoint...');
|
||||
|
||||
const response = await fetch('/api/lm/recipe/checkpoint/restore', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_id: this.recipeId
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Swap the entry back to its pre-reconnect state
|
||||
this.currentRecipe.checkpoint = result.updated_checkpoint;
|
||||
|
||||
showToast('toast.recipes.checkpointRestored', {}, 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(this.currentRecipe);
|
||||
}, 500);
|
||||
|
||||
state.virtualScroller.updateSingleItem(this.listFilePath || this.currentRecipe.file_path, {
|
||||
checkpoint: this.currentRecipe.checkpoint
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.checkpointRestoreFailed', { message: result.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restoring checkpoint:', error);
|
||||
showToast('toast.recipes.checkpointRestoreFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async markCheckpointHashInvalid() {
|
||||
const recipeId =
|
||||
this.recipeId ||
|
||||
extractRecipeId(this.listFilePath || this.currentRecipe?.file_path);
|
||||
if (!recipeId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch('/api/lm/recipe/checkpoint/mark-hash-invalid', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_id: recipeId,
|
||||
}),
|
||||
});
|
||||
if (this.currentRecipe?.checkpoint) {
|
||||
this.currentRecipe.checkpoint.hashInvalid = true;
|
||||
this.syncResourcesSection(this.currentRecipe);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to mark checkpoint hash invalid:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderCheckpoint(checkpoint) {
|
||||
const existsLocally = !!checkpoint.inLibrary;
|
||||
const isDeleted = !!checkpoint.isDeleted;
|
||||
const hashInvalid = !!checkpoint.hashInvalid;
|
||||
// "Broken" = cannot be restored by downloading: explicitly marked
|
||||
// deleted, an unresolvable hash, or an entry with no CivitAI
|
||||
// identifiers at all (a name-only remnant that never had a version
|
||||
// id to query — it can only be fixed by reconnecting a local model).
|
||||
const broken = isDeleted
|
||||
|| hashInvalid
|
||||
|| (!existsLocally && !this.canDownloadCheckpoint(checkpoint));
|
||||
const localPath = checkpoint.localPath || '';
|
||||
const previewUrl = checkpoint.preview_url || checkpoint.thumbnailUrl || '/loras_static/images/no-preview.png';
|
||||
const isPreviewVideo = typeof previewUrl === 'string' && previewUrl.toLowerCase().endsWith('.mp4');
|
||||
@@ -2021,18 +2367,38 @@ class RecipeModal {
|
||||
` : `<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
|
||||
// items and the versions-tab badge pattern. The header carries only
|
||||
// the badge; every action lives in the bottom action row.
|
||||
const badge = existsLocally ? `
|
||||
<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'))}
|
||||
</div>
|
||||
` : `
|
||||
<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'))}
|
||||
</div>
|
||||
`;
|
||||
// items and the versions-tab badge pattern. Deleted / unresolvable
|
||||
// hash states render the same fixable-broken badges as LoRA entries.
|
||||
let badge;
|
||||
if (existsLocally) {
|
||||
badge = `
|
||||
<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'))}
|
||||
</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'))}">
|
||||
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
|
||||
</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 = [];
|
||||
if (existsLocally && localPath) {
|
||||
actions.push(`
|
||||
@@ -2041,6 +2407,16 @@ class RecipeModal {
|
||||
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
||||
</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)) {
|
||||
actions.push(`
|
||||
<button class="resource-action primary compact checkpoint-download">
|
||||
@@ -2055,15 +2431,56 @@ class RecipeModal {
|
||||
: '';
|
||||
|
||||
// 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.
|
||||
const rowA11yAttributes = existsLocally
|
||||
? ` 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 `
|
||||
<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">
|
||||
${previewMedia}
|
||||
</div>
|
||||
@@ -2079,9 +2496,11 @@ class RecipeModal {
|
||||
${versionLabel ? `<div class="recipe-lora-version">${versionLabel}</div>` : ''}
|
||||
${baseModel ? `<div class="base-model">${baseModel}</div>` : ''}
|
||||
${modelTypeLabel ? `<span class="checkpoint-type-text">${modelTypeLabel}</span>` : ''}
|
||||
${undoReconnectIcon}
|
||||
</div>
|
||||
${actionsRow}
|
||||
</div>
|
||||
${reconnectContainer}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -2102,6 +2521,27 @@ class RecipeModal {
|
||||
await this.downloadCheckpoint(checkpoint, downloadBtn);
|
||||
});
|
||||
}
|
||||
|
||||
// Deferred wiring can run again after a hydration re-render while the
|
||||
// latest DOM is already in place; a data flag prevents stacking
|
||||
// duplicate handlers (same pattern as the LoRA item actions).
|
||||
const reconnectBtn = container.querySelector('.checkpoint-reconnect');
|
||||
if (reconnectBtn && reconnectBtn.dataset.wired !== 'true') {
|
||||
reconnectBtn.dataset.wired = 'true';
|
||||
reconnectBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.showReconnectInput('checkpoint');
|
||||
});
|
||||
}
|
||||
|
||||
const undoBtn = container.querySelector('.checkpoint-undo-reconnect');
|
||||
if (undoBtn && undoBtn.dataset.wired !== 'true') {
|
||||
undoBtn.dataset.wired = 'true';
|
||||
undoBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.restoreCheckpoint();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setupCheckpointNavigation(container, checkpoint) {
|
||||
@@ -2184,7 +2624,14 @@ class RecipeModal {
|
||||
|
||||
async downloadCheckpoint(checkpoint, button) {
|
||||
if (!this.canDownloadCheckpoint(checkpoint)) {
|
||||
showToast('toast.recipes.missingCheckpointInfo', {}, 'error');
|
||||
// No resolvable CivitAI identifiers for this entry. A hash-only
|
||||
// checkpoint is not downloadable through the version downloader —
|
||||
// point the user at the reconnect flow instead.
|
||||
if (this._getCheckpointHash(checkpoint)) {
|
||||
showToast('toast.recipes.checkpointDownloadUnavailable', {}, 'warning');
|
||||
} else {
|
||||
showToast('toast.recipes.missingCheckpointInfo', {}, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2208,6 +2655,16 @@ class RecipeModal {
|
||||
);
|
||||
if (success) {
|
||||
await this.refreshResourcesAfterDownload();
|
||||
return;
|
||||
}
|
||||
// Business-level download failure (the request completed but the
|
||||
// backend rejected it). Enroll the entry in the rematch/reconnect
|
||||
// remediation flow only when the failure is clearly unresolvable
|
||||
// (model removed or version gone on CivitAI) — the same signal
|
||||
// rule as the LoRA path. Transient failures (network, 5xx) leave
|
||||
// the entry untouched.
|
||||
if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) {
|
||||
await this.markCheckpointHashInvalid();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading checkpoint:', error);
|
||||
@@ -2219,6 +2676,21 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a download failure means the model is unrecoverable.
|
||||
*
|
||||
* Mirrors the LoRA behaviour: the hash invalid flag (and the resulting
|
||||
* rematch/reconnect candidacy) is only set when CivitAI explicitly says
|
||||
* the model cannot be resolved — never for transient transport errors.
|
||||
*/
|
||||
_isUnresolvableDownloadError(message) {
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const text = String(message).toLowerCase();
|
||||
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
|
||||
}
|
||||
|
||||
getResourceCivitaiUrl(resource) {
|
||||
if (!resource) {
|
||||
return null;
|
||||
@@ -2263,7 +2735,7 @@ class RecipeModal {
|
||||
`;
|
||||
}
|
||||
|
||||
renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) {
|
||||
renderLoraItemActions(loraIndex, { existsLocally, needsReconnect }) {
|
||||
// In-library LoRAs need no remediation: the badge and the local path
|
||||
// already tell the full story. (The restore affordance for manually
|
||||
// reconnected entries lives on the info row, not here.)
|
||||
@@ -2272,7 +2744,7 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
const controls = [];
|
||||
if (isDeleted || lora.hashInvalid) {
|
||||
if (needsReconnect) {
|
||||
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
|
||||
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
|
||||
controls.push(`
|
||||
@@ -2283,24 +2755,20 @@ class RecipeModal {
|
||||
</button>
|
||||
`);
|
||||
} else {
|
||||
if (this.canDownloadLora(lora)) {
|
||||
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
||||
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||
controls.push(`
|
||||
<button type="button" class="resource-action primary compact lora-download" data-lora-index="${loraIndex}"
|
||||
title="${escapeHtml(downloadTooltip)}" aria-label="${escapeHtml(downloadTooltip)}">
|
||||
<i class="fas fa-download" aria-hidden="true"></i>
|
||||
<span>${escapeHtml(downloadLabel)}</span>
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
// needsReconnect already implies canDownloadLora() here, so the
|
||||
// download action is unconditional.
|
||||
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
||||
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||
controls.push(`
|
||||
<button type="button" class="resource-action primary compact lora-download" data-lora-index="${loraIndex}"
|
||||
title="${escapeHtml(downloadTooltip)}" aria-label="${escapeHtml(downloadTooltip)}">
|
||||
<i class="fas fa-download" aria-hidden="true"></i>
|
||||
<span>${escapeHtml(downloadLabel)}</span>
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
|
||||
const markup = controls.filter(Boolean).join('');
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
return `<div class="recipe-lora-actions">${markup}</div>`;
|
||||
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
|
||||
}
|
||||
|
||||
setupLoraItemActions() {
|
||||
@@ -2462,6 +2930,16 @@ class RecipeModal {
|
||||
);
|
||||
if (success) {
|
||||
await this.refreshResourcesAfterDownload();
|
||||
return;
|
||||
}
|
||||
// Business-level download failure (the request completed but the
|
||||
// backend rejected it). Mark the hash invalid — and thereby offer
|
||||
// the reconnect affordance — only when the failure is clearly
|
||||
// unresolvable (model removed or version gone on CivitAI), the
|
||||
// same signal rule as the checkpoint path. Transient failures
|
||||
// (network, 5xx) leave the entry untouched.
|
||||
if (this._isUnresolvableDownloadError(downloadManager._lastDownloadError)) {
|
||||
await this.markLoraHashInvalid(loraIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!hasDirectIds) {
|
||||
|
||||
@@ -1182,10 +1182,13 @@ export class DownloadManager {
|
||||
if (!response?.success) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
const errorMessage = response?.error || 'Unknown error';
|
||||
// Always record the latest failure so callers can distinguish
|
||||
// an unresolvable model (not found / deleted) from a transient
|
||||
// transport failure; the summary flow below may or may not run.
|
||||
this._lastDownloadError = errorMessage;
|
||||
// When the caller aggregates failures itself (multi-file
|
||||
// loop), just record the error and return (#1058).
|
||||
if (suppressFailureSummary) {
|
||||
this._lastDownloadError = errorMessage;
|
||||
return false;
|
||||
}
|
||||
// A file-level "already in library" rejection is an expected
|
||||
|
||||
@@ -65,6 +65,13 @@ export class DownloadManager {
|
||||
raw_metadata: this.importManager.recipeData.raw_metadata || {},
|
||||
};
|
||||
|
||||
// Pass analysis diagnostics through so the backend can record
|
||||
// why the recipe ended up with no LoRAs (recipe modal panel).
|
||||
const diagnostics = this.importManager.recipeData.diagnostics;
|
||||
if (diagnostics && typeof diagnostics === 'object') {
|
||||
completeMetadata.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
// Preserve preview_nsfw_level from analysis so the saved
|
||||
// recipe applies the correct NSFW blur on the preview image.
|
||||
const nsfwLevel = this.importManager.recipeData.preview_nsfw_level;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||
|
||||
const loadingManagerStub = {
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
show: vi.fn(),
|
||||
restoreProgressBar: vi.fn(),
|
||||
};
|
||||
|
||||
const virtualScrollerStub = {
|
||||
updateSingleItem: vi.fn(),
|
||||
getNavigationState: vi.fn(() => ({
|
||||
index: 0,
|
||||
hasPrev: false,
|
||||
hasNext: false,
|
||||
loadedItems: 1,
|
||||
totalItems: 1,
|
||||
})),
|
||||
getAdjacentItemByFilePath: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
const stateStub = {
|
||||
global: { settings: {}, loadingManager: loadingManagerStub },
|
||||
loadingManager: loadingManagerStub,
|
||||
virtualScroller: virtualScrollerStub,
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
sendLoraToWorkflow: vi.fn(),
|
||||
sendModelPathToWorkflow: vi.fn(),
|
||||
openCivitaiByMetadata: vi.fn(),
|
||||
stripLoraTags: vi.fn((text) => text),
|
||||
sendPromptToWorkflow: vi.fn(),
|
||||
sendGenParamsToWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
state: stateStub,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
setSessionItem: vi.fn(),
|
||||
removeSessionItem: vi.fn(),
|
||||
getStorageItem: vi.fn(() => null),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
fetchRecipeDetails: vi.fn(),
|
||||
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
|
||||
sendRecipeWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
MODEL_TYPES: {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings',
|
||||
},
|
||||
}));
|
||||
|
||||
function recipeModalFixture() {
|
||||
return `
|
||||
<div id="recipeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-actions">
|
||||
<span id="recipeLorasCount"></span>
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
describe('RecipeModal no-LoRA reason panel', () => {
|
||||
let recipeModal;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = recipeModalFixture();
|
||||
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||
recipeModal = new RecipeModal();
|
||||
});
|
||||
|
||||
function sync(recipe) {
|
||||
recipeModal.syncResourcesSection(recipe);
|
||||
return document.getElementById('recipeLorasList');
|
||||
}
|
||||
|
||||
it('shows the base message only when generation genuinely used no LoRAs', () => {
|
||||
const list = sync({
|
||||
id: 'r1',
|
||||
loras: [],
|
||||
import_info: { channel: 'url', reason: 'no_loras_used' },
|
||||
});
|
||||
|
||||
expect(list.querySelector('.no-loras')).not.toBeNull();
|
||||
expect(list.textContent).toContain('No LoRAs associated with this recipe');
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a collapsed reason panel from recorded import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r2',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'batch_import_url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: {
|
||||
api_meta_keys: ['prompt'],
|
||||
api_model_version_ids: 0,
|
||||
exif_present: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
// Collapsed by default (no `open` attribute).
|
||||
expect(details.hasAttribute('open')).toBe(false);
|
||||
expect(details.querySelector('summary').textContent).toContain('Why no LoRAs?');
|
||||
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('Batch import (image URL)');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
expect(body.textContent).toContain('API metadata fields');
|
||||
expect(body.textContent).toContain('prompt');
|
||||
expect(body.textContent).toContain('Model version IDs reported');
|
||||
expect(body.textContent).toContain('Embedded metadata');
|
||||
// Recorded diagnostics are not labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).toBeNull();
|
||||
});
|
||||
|
||||
it('infers a possible reason for legacy URL recipes without import_info', () => {
|
||||
const list = sync({
|
||||
id: 'r3',
|
||||
loras: [],
|
||||
source_path: 'https://civitai.red/images/139995974',
|
||||
gen_params: { prompt: 'a castle' },
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
const body = details.querySelector('.no-loras-reason-body');
|
||||
expect(body.textContent).toContain('The source API returned no LoRA resource data');
|
||||
// Heuristic results must be labeled as inferred.
|
||||
expect(body.querySelector('.no-loras-inferred-note')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reports missing embedded metadata for legacy local recipes with no params', () => {
|
||||
const list = sync({
|
||||
id: 'r4',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: {},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'The image has no embedded generation metadata'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show the panel for legacy local recipes with complete params', () => {
|
||||
const list = sync({
|
||||
id: 'r5',
|
||||
loras: [],
|
||||
source_path: '/data/images/photo.png',
|
||||
gen_params: { prompt: 'a castle', steps: 20, seed: 42 },
|
||||
});
|
||||
|
||||
expect(list.querySelector('details.no-loras-reason')).toBeNull();
|
||||
});
|
||||
|
||||
it('flags ComfyUI workflow sources via has_workflow', () => {
|
||||
const list = sync({
|
||||
id: 'r6',
|
||||
loras: [],
|
||||
has_workflow: true,
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.querySelector('.no-loras-reason-body').textContent).toContain(
|
||||
'ComfyUI workflow'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes HTML in recorded diagnostic values', () => {
|
||||
const list = sync({
|
||||
id: 'r7',
|
||||
loras: [],
|
||||
import_info: {
|
||||
channel: 'url',
|
||||
reason: 'api_meta_no_lora_resources',
|
||||
details: { api_meta_keys: ['<img src=x onerror=alert(1)>'] },
|
||||
},
|
||||
});
|
||||
|
||||
const details = list.querySelector('details.no-loras-reason');
|
||||
expect(details).not.toBeNull();
|
||||
expect(details.innerHTML).not.toContain('<img src=x');
|
||||
expect(details.textContent).toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
@@ -92,10 +92,13 @@ vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const downloadManagerMock = {
|
||||
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
||||
_lastDownloadError: '',
|
||||
};
|
||||
|
||||
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||
downloadManager: {
|
||||
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
||||
},
|
||||
downloadManager: downloadManagerMock,
|
||||
}));
|
||||
|
||||
function recipeModalFixture() {
|
||||
@@ -192,6 +195,7 @@ describe('RecipeModal resource item interactions', () => {
|
||||
// the shared mocks to their defaults explicitly.
|
||||
downloadVersionWithDefaultsMock.mockReset();
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(undefined);
|
||||
downloadManagerMock._lastDownloadError = '';
|
||||
fetchRecipeDetailsMock.mockReset();
|
||||
// Hydration re-fetches the recipe right after render; resolving an empty
|
||||
// object would delete currentRecipe.loras and wipe the list, so resolve
|
||||
@@ -428,14 +432,20 @@ describe('RecipeModal resource item interactions', () => {
|
||||
expect(downloadVersionWithDefaultsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders no action row when neither identifiers nor hash are available', async () => {
|
||||
it('offers reconnect for name-only LoRAs with no CivitAI identifiers', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
recipeModal.showRecipeDetails(recipeWithResources);
|
||||
await flushWiring();
|
||||
|
||||
const mysteryItem = document.querySelector('[data-lora-index="4"]');
|
||||
expect(mysteryItem.querySelector('.lora-download')).toBeNull();
|
||||
// No actions at all -> no empty action row taking vertical space
|
||||
expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull();
|
||||
const reconnectButton = mysteryItem.querySelector('.lora-reconnect');
|
||||
expect(reconnectButton).not.toBeNull();
|
||||
|
||||
reconnectButton.click();
|
||||
const container = mysteryItem.querySelector('.lora-reconnect-container');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.classList.contains('active')).toBe(true);
|
||||
|
||||
// The name-fallback search link still sits inline in the title
|
||||
const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link');
|
||||
@@ -443,6 +453,81 @@ describe('RecipeModal resource item interactions', () => {
|
||||
expect(link.href).toContain('query=Mystery%20LoRA');
|
||||
});
|
||||
|
||||
it('marks the entry hash-invalid when a direct download fails with an unresolvable error', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const requests = [];
|
||||
// Deep copy so the mark step mutating loras[1].hashInvalid does not
|
||||
// leak into the shared fixture used by later tests.
|
||||
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
downloadManagerMock._lastDownloadError = 'Model not found';
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
|
||||
// missingLora carries direct identifiers, so no hash-resolution round
|
||||
// trip happens before the download attempt.
|
||||
const missingItem = document.querySelector('[data-lora-index="1"]');
|
||||
missingItem.querySelector('.lora-download').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
requests.some(r => r.url.includes('/recipe/lora/mark-hash-invalid'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const markRequest = requests.find(r => r.url.includes('/mark-hash-invalid'));
|
||||
expect(JSON.parse(markRequest.options.body)).toEqual({
|
||||
recipe_id: 'recipe-resources',
|
||||
lora_index: 1,
|
||||
});
|
||||
|
||||
// The re-rendered entry swaps the download action for the reconnect one
|
||||
await vi.waitFor(() => {
|
||||
const item = document.querySelector('[data-lora-index="1"]');
|
||||
expect(item.querySelector('.lora-reconnect')).not.toBeNull();
|
||||
expect(item.querySelector('.lora-download')).toBeNull();
|
||||
expect(item.querySelector('.invalid-hash-badge')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the entry untouched when a direct download fails transiently', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const requests = [];
|
||||
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
downloadManagerMock._lastDownloadError = 'Connection timed out';
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
|
||||
const missingItem = document.querySelector('[data-lora-index="1"]');
|
||||
missingItem.querySelector('.lora-download').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Give any (unexpected) mark request a chance to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
|
||||
|
||||
// The entry keeps the download action and never flips to reconnect
|
||||
const item = document.querySelector('[data-lora-index="1"]');
|
||||
expect(item.querySelector('.lora-download')).not.toBeNull();
|
||||
expect(item.querySelector('.lora-reconnect')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
global.fetch = vi.fn(async (url) => ({
|
||||
@@ -776,4 +861,321 @@ describe('RecipeModal resource item interactions', () => {
|
||||
lora_index: '0',
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkpoint reconnect', () => {
|
||||
const brokenCheckpoint = {
|
||||
name: 'gone-checkpoint',
|
||||
file_name: 'gone',
|
||||
inLibrary: false,
|
||||
isDeleted: true,
|
||||
hash: 'a2a12bfa01',
|
||||
};
|
||||
const hashInvalidCheckpoint = {
|
||||
name: 'invalid-checkpoint',
|
||||
file_name: 'invalid',
|
||||
inLibrary: false,
|
||||
hashInvalid: true,
|
||||
hash: 'deadbeefcafe',
|
||||
};
|
||||
|
||||
function recipeWithCheckpoint(checkpoint) {
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(recipeWithResources)),
|
||||
checkpoint: { ...checkpoint },
|
||||
};
|
||||
}
|
||||
|
||||
// Hydration re-fetches the recipe right after render and re-renders the
|
||||
// modal, so the mock must resolve the SAME broken-checkpoint recipe —
|
||||
// otherwise the fetch wipes isDeleted/hashInvalid back to the fixture.
|
||||
async function renderBrokenCheckpoint(recipeModal, checkpoint) {
|
||||
const isolated = recipeWithCheckpoint(checkpoint);
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolated);
|
||||
recipeModal.showRecipeDetails(isolated);
|
||||
await flushWiring();
|
||||
}
|
||||
|
||||
function mockCheckpointSuggestionsFetch(payload) {
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
if (String(url).includes('/checkpoint/reconnect-suggestions')) {
|
||||
return { ok: true, json: async () => payload };
|
||||
}
|
||||
if (String(url).includes('/recipe/checkpoint/reconnect')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
updated_checkpoint: {
|
||||
name: 'main-checkpoint',
|
||||
file_name: 'main',
|
||||
inLibrary: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
return requests;
|
||||
}
|
||||
|
||||
it('renders a deleted checkpoint with a badge and reconnect affordance', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
expect(item.classList.contains('is-deleted')).toBe(true);
|
||||
expect(item.querySelector('.deleted-badge')).not.toBeNull();
|
||||
const reconnectButton = item.querySelector('.checkpoint-reconnect');
|
||||
expect(reconnectButton).not.toBeNull();
|
||||
// Deleted checkpoints lose the civitai link (their source page is gone)
|
||||
expect(item.querySelector('.recipe-lora-title a.recipe-civitai-link')).toBeNull();
|
||||
// The inline form is present but hidden until the button is pressed
|
||||
const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
expect(container).not.toBeNull();
|
||||
expect(container.classList.contains('active')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders a hash-invalid checkpoint with the unresolvable hash badge', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
await renderBrokenCheckpoint(recipeModal, hashInvalidCheckpoint);
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
expect(item.querySelector('.invalid-hash-badge')).not.toBeNull();
|
||||
expect(item.querySelector('.checkpoint-reconnect')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders reconnect for a name-only checkpoint with no download identifiers', async () => {
|
||||
// Importers can leave a checkpoint entry with nothing but a model name
|
||||
// (no hash / version id, so nothing was ever queryable on CivitAI).
|
||||
// It cannot be downloaded and is not marked deleted — reconnect is the
|
||||
// only remediation, so it must still surface.
|
||||
const recipeModal = await createRecipeModal();
|
||||
await renderBrokenCheckpoint(recipeModal, {
|
||||
type: 'checkpoint',
|
||||
modelName: 'meichidarkMix_meichidarkanimxlV1',
|
||||
inLibrary: false,
|
||||
});
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
expect(item.querySelector('.checkpoint-download')).toBeNull();
|
||||
const reconnectButton = item.querySelector('.checkpoint-reconnect');
|
||||
expect(reconnectButton).not.toBeNull();
|
||||
const container = item.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
expect(container).not.toBeNull();
|
||||
|
||||
reconnectButton.click();
|
||||
expect(container.classList.contains('active')).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches checkpoint suggestions against the checkpoint endpoint', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const suggestionsPayload = {
|
||||
success: true,
|
||||
suggestions: [
|
||||
{
|
||||
file_name: 'main-checkpoint.safetensors',
|
||||
base_model: 'SD 1.5',
|
||||
preview_url: '/preview/main.png',
|
||||
score: 0.95,
|
||||
match_reason: 'same_version',
|
||||
target_name: 'main-checkpoint',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockCheckpointSuggestionsFetch(suggestionsPayload);
|
||||
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
document.querySelector('.checkpoint-reconnect').click();
|
||||
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
|
||||
expect(container.classList.contains('active')).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipe/recipe-resources/checkpoint/reconnect-suggestions'
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1);
|
||||
});
|
||||
expect(container.querySelector('.reconnect-suggestion-name').textContent)
|
||||
.toBe('main-checkpoint');
|
||||
});
|
||||
|
||||
it('reconnects the checkpoint via its own endpoint when a suggestion is clicked', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const requests = mockCheckpointSuggestionsFetch({
|
||||
success: true,
|
||||
suggestions: [
|
||||
{
|
||||
file_name: 'main-checkpoint.safetensors',
|
||||
target_name: 'main-checkpoint',
|
||||
match_reason: 'same_version',
|
||||
score: 0.95,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
document.querySelector('.checkpoint-reconnect').click();
|
||||
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelectorAll('.reconnect-suggestion').length).toBe(1);
|
||||
});
|
||||
container.querySelector('.reconnect-suggestion').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/reconnect')).toBe(true);
|
||||
});
|
||||
const reconnectRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/reconnect');
|
||||
expect(reconnectRequest.options.method).toBe('POST');
|
||||
expect(JSON.parse(reconnectRequest.options.body)).toEqual({
|
||||
recipe_id: 'recipe-resources',
|
||||
target_name: 'main-checkpoint',
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.checkpointReconnectedSuccessfully',
|
||||
{},
|
||||
'success'
|
||||
);
|
||||
});
|
||||
expect(recipeModal.currentRecipe.checkpoint.inLibrary).toBe(true);
|
||||
});
|
||||
|
||||
it('warns when the checkpoint reconnect crossed base-model families', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
global.fetch = vi.fn(async (url) => {
|
||||
if (String(url).includes('/checkpoint/reconnect-suggestions')) {
|
||||
return { ok: true, json: async () => ({ success: true, suggestions: [] }) };
|
||||
}
|
||||
if (String(url).includes('/recipe/checkpoint/reconnect')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
updated_checkpoint: { name: 'main', inLibrary: true },
|
||||
base_model_mismatch: { recipe_base_model: 'Illustrious', checkpoint_base_model: 'Pony' },
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
|
||||
await renderBrokenCheckpoint(recipeModal, brokenCheckpoint);
|
||||
document.querySelector('.checkpoint-reconnect').click();
|
||||
const container = document.querySelector('.lora-reconnect-container[data-lora-index="checkpoint"]');
|
||||
const input = container.querySelector('.reconnect-input');
|
||||
input.value = 'main';
|
||||
container.querySelector('.reconnect-confirm-btn').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.recipes.reconnectCheckpointBaseModelMismatch',
|
||||
{ recipe: 'Illustrious', checkpoint: 'Pony' },
|
||||
'warning'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('offers undo for a reconnected checkpoint and restores via the API', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const isolatedRecipe = recipeWithCheckpoint(brokenCheckpoint);
|
||||
isolatedRecipe.checkpoint = {
|
||||
name: 'main-checkpoint',
|
||||
file_name: 'main',
|
||||
inLibrary: true,
|
||||
reconnectSnapshot: { name: 'gone-checkpoint', file_name: 'gone', isDeleted: true },
|
||||
};
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
if (String(url).includes('/recipe/checkpoint/restore')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
updated_checkpoint: { name: 'gone', inLibrary: false, isDeleted: true },
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
});
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
|
||||
const item = document.querySelector('.checkpoint-item');
|
||||
const undoButton = item.querySelector('.checkpoint-undo-reconnect');
|
||||
expect(undoButton).not.toBeNull();
|
||||
|
||||
undoButton.click();
|
||||
await vi.waitFor(() => {
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.checkpointRestored', {}, 'success');
|
||||
});
|
||||
const restoreRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/restore');
|
||||
expect(restoreRequest.options.method).toBe('POST');
|
||||
expect(JSON.parse(restoreRequest.options.body)).toEqual({
|
||||
recipe_id: 'recipe-resources',
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the checkpoint hash invalid only when the failure is unresolvable', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js');
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
});
|
||||
|
||||
// Explicit "model removed" signal: the entry becomes a rematch/
|
||||
// reconnect candidate (same rule as the LoRA resolve "not found").
|
||||
// Use an isolated copy so the hashInvalid mutation does not leak into
|
||||
// the shared recipeWithResources fixture used by later tests.
|
||||
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
|
||||
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
downloadManager._lastDownloadError = 'Model not found';
|
||||
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||
await flushWiring();
|
||||
document.querySelector('.checkpoint-download').click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(true);
|
||||
});
|
||||
const markRequest = requests.find(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid');
|
||||
expect(markRequest.options.method).toBe('POST');
|
||||
expect(JSON.parse(markRequest.options.body)).toEqual({ recipe_id: 'recipe-resources' });
|
||||
expect(recipeModal.currentRecipe.checkpoint.hashInvalid).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mark the checkpoint hash invalid on transient download failures', async () => {
|
||||
const recipeModal = await createRecipeModal();
|
||||
const { downloadManager } = await import('../../../static/js/managers/DownloadManager.js');
|
||||
const requests = [];
|
||||
global.fetch = vi.fn(async (url, options) => {
|
||||
requests.push({ url: String(url), options });
|
||||
return { ok: true, json: async () => ({ success: true }) };
|
||||
});
|
||||
|
||||
// Transport/API exceptions must NOT enroll the entry in the
|
||||
// remediation flow — transient failures are not evidence the model is
|
||||
// unrecoverable (mirrors the LoRA path).
|
||||
downloadVersionWithDefaultsMock.mockRejectedValue(new Error('Network timeout'));
|
||||
recipeModal.showRecipeDetails(recipeWithResources);
|
||||
await flushWiring();
|
||||
document.querySelector('.checkpoint-download').click();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false);
|
||||
|
||||
// Business failure without an unresolvable signal also stays untouched.
|
||||
downloadVersionWithDefaultsMock.mockResolvedValue(false);
|
||||
downloadManager._lastDownloadError = 'Connection refused';
|
||||
await recipeModal.downloadCheckpoint(recipeModal.currentRecipe.checkpoint);
|
||||
expect(requests.some(r => r.url === '/api/lm/recipe/checkpoint/mark-hash-invalid')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,6 +311,28 @@ class StubPersistenceService:
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def reconnect_checkpoint(
|
||||
self, *, recipe_scanner, recipe_id: str, target_name: str
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def restore_checkpoint(
|
||||
self, *, recipe_scanner, recipe_id: str
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self, *, recipe_scanner, recipe_id: str, query: str | None = None
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(
|
||||
payload={"success": True, "suggestions": []}, status=200
|
||||
)
|
||||
|
||||
async def mark_checkpoint_hash_invalid(
|
||||
self, *, recipe_scanner, recipe_id: str, hash_invalid: bool = True
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
return SimpleNamespace(payload={"success": True}, status=200)
|
||||
|
||||
async def bulk_delete(
|
||||
self, *, recipe_scanner, recipe_ids: List[str]
|
||||
) -> SimpleNamespace: # pragma: no cover
|
||||
@@ -2050,3 +2072,99 @@ async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys(
|
||||
assert len(groups) == 2
|
||||
assert {g["type"] for g in groups} == {"fingerprint", "source_path"}
|
||||
assert len({g["key"] for g in groups}) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checkpoint reconnect routes (manual remediation for recipe.checkpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_route(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/reconnect",
|
||||
json={"recipe_id": "r1", "target_name": "main"},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_route_requires_target_name(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/reconnect",
|
||||
json={"recipe_id": "r1"},
|
||||
)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
async def test_checkpoint_restore_route(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/restore",
|
||||
json={"recipe_id": "r1"},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_restore_route_requires_recipe_id(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/restore",
|
||||
json={},
|
||||
)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_suggestions_route(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipe/r1/checkpoint/reconnect-suggestions?query=main"
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["suggestions"] == []
|
||||
|
||||
|
||||
async def test_checkpoint_reconnect_suggestions_route_without_query(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipe/r1/checkpoint/reconnect-suggestions"
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_mark_hash_invalid_route(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/mark-hash-invalid",
|
||||
json={"recipe_id": "r1"},
|
||||
)
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
|
||||
|
||||
async def test_checkpoint_mark_hash_invalid_route_requires_recipe_id(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/checkpoint/mark-hash-invalid",
|
||||
json={},
|
||||
)
|
||||
assert response.status == 400
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for the recipe import_info helpers (no-LoRA reason computation)."""
|
||||
|
||||
from py.services.recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
CHANNEL_LOCAL,
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_UPLOAD,
|
||||
CHANNEL_URL,
|
||||
CHANNEL_WIDGET,
|
||||
REASON_API_META_MISSING,
|
||||
REASON_API_NO_LORA_RESOURCES,
|
||||
REASON_METADATA_UNSUPPORTED,
|
||||
REASON_NO_EMBEDDED_METADATA,
|
||||
REASON_NO_LORAS_USED,
|
||||
REASON_VIDEO_NO_METADATA,
|
||||
REASON_WORKFLOW_METADATA_LIMITED,
|
||||
build_import_info,
|
||||
compute_no_loras_reason,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeNoLorasReason:
|
||||
def test_video_takes_priority(self):
|
||||
diag = {"is_video": True, "exif_parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_VIDEO_NO_METADATA
|
||||
)
|
||||
|
||||
def test_comfy_workflow_parser(self):
|
||||
diag = {"exif_parser": "ComfyMetadataParser", "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_merged_comfy_parser(self):
|
||||
# ComfyUI workflow parsed from the merged dict (no string EXIF).
|
||||
diag = {"parser": "ComfyMetadataParser"}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_WORKFLOW_METADATA_LIMITED
|
||||
)
|
||||
|
||||
def test_civitai_url_with_api_meta_but_no_lora_resources(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_model_version_ids_only(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 2,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_civitai_url_with_no_meta_at_all(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_REIMPORT_URL, diag)
|
||||
== REASON_API_META_MISSING
|
||||
)
|
||||
|
||||
def test_civitai_url_with_parsed_exif_still_reports_api_gap(self):
|
||||
# CivitAI's onsite generator writes A1111-style EXIF WITHOUT LoRA
|
||||
# references (LoRA usage lives in CivitAI-internal data), so cleanly
|
||||
# parsed EXIF must NOT be read as "no LoRAs were used".
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt", "steps", "seed", "resources"],
|
||||
"api_model_version_ids": 1,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
"parser": "CivitaiApiMetadataParser",
|
||||
}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_URL, diag)
|
||||
== REASON_API_NO_LORA_RESOURCES
|
||||
)
|
||||
|
||||
def test_generic_url_without_embedded_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_generic_url_with_unsupported_metadata(self):
|
||||
diag = {"civitai_image": False, "exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, diag) == REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_generic_url_with_parsed_metadata_means_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": False,
|
||||
"exif_present": True,
|
||||
"exif_parser": "AutomaticMetadataParser",
|
||||
}
|
||||
assert compute_no_loras_reason(CHANNEL_URL, diag) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_widget(self):
|
||||
assert compute_no_loras_reason(CHANNEL_WIDGET, None) == REASON_NO_LORAS_USED
|
||||
|
||||
def test_local_without_embedded_metadata(self):
|
||||
diag = {"exif_present": False}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_BATCH_IMPORT_LOCAL, diag)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
def test_local_with_unsupported_metadata(self):
|
||||
diag = {"exif_present": True}
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_UPLOAD, diag)
|
||||
== REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
|
||||
def test_missing_diagnostics_falls_back_safely(self):
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_LOCAL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
# URL channel without diagnostics is treated as a generic URL (the
|
||||
# civitai_image flag defaults to False).
|
||||
assert (
|
||||
compute_no_loras_reason(CHANNEL_URL, None)
|
||||
== REASON_NO_EMBEDDED_METADATA
|
||||
)
|
||||
|
||||
|
||||
class TestBuildImportInfo:
|
||||
def test_channel_always_recorded(self):
|
||||
info = build_import_info(
|
||||
CHANNEL_URL, None, loras=[{"file_name": "x", "hash": "abc"}]
|
||||
)
|
||||
assert info == {"channel": CHANNEL_URL}
|
||||
|
||||
def test_reason_and_details_when_no_loras(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": ["prompt"],
|
||||
"api_model_version_ids": 0,
|
||||
"exif_present": False,
|
||||
"exif_parser": None,
|
||||
}
|
||||
info = build_import_info(CHANNEL_BATCH_IMPORT_URL, diag, loras=[])
|
||||
assert info["channel"] == CHANNEL_BATCH_IMPORT_URL
|
||||
assert info["reason"] == REASON_API_NO_LORA_RESOURCES
|
||||
assert info["details"]["api_meta_keys"] == ["prompt"]
|
||||
assert info["details"]["api_model_version_ids"] == 0
|
||||
assert info["details"]["exif_present"] is False
|
||||
# Empty exif_parser must not leak into details.
|
||||
assert "exif_parser" not in info["details"]
|
||||
|
||||
def test_details_omitted_when_nothing_to_report(self):
|
||||
info = build_import_info(CHANNEL_WIDGET, None, loras=[])
|
||||
assert info == {"channel": CHANNEL_WIDGET, "reason": REASON_NO_LORAS_USED}
|
||||
|
||||
def test_api_meta_keys_capped(self):
|
||||
diag = {
|
||||
"civitai_image": True,
|
||||
"api_meta_keys": [f"k{i}" for i in range(50)],
|
||||
"api_model_version_ids": 1,
|
||||
}
|
||||
info = build_import_info(CHANNEL_URL, diag, loras=[])
|
||||
assert len(info["details"]["api_meta_keys"]) == 12
|
||||
@@ -768,6 +768,251 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_
|
||||
assert cleared_lora["hashInvalid"] is False
|
||||
|
||||
|
||||
async def test_update_checkpoint_entry_updates_cache_and_file(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-1"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
original_checkpoint = {
|
||||
"name": "Old Model",
|
||||
"version": "v1",
|
||||
"id": 1,
|
||||
"type": "Checkpoint",
|
||||
"baseModel": "SDXL 1.0",
|
||||
"file_name": "old",
|
||||
"hash": "aaa",
|
||||
"isDeleted": True,
|
||||
}
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Original",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"base_model": "SDXL 1.0",
|
||||
"checkpoint": dict(original_checkpoint),
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
target_info = {
|
||||
"sha256": "abc123",
|
||||
"file_path": str(tmp_path / "checkpoints" / "main.safetensors"),
|
||||
"preview_url": "preview.png",
|
||||
"model_name": "Main Model",
|
||||
"base_model": "SDXL 1.0",
|
||||
"civitai": {"id": 42, "name": "v2"},
|
||||
}
|
||||
|
||||
updated_recipe, updated_checkpoint = await scanner.update_checkpoint_entry(
|
||||
recipe_id,
|
||||
target_name="main",
|
||||
target_checkpoint=target_info,
|
||||
)
|
||||
|
||||
# Write-back follows the pinned checkpoint key set, keeping the
|
||||
# user-entered file_name.
|
||||
assert updated_checkpoint["file_name"] == "main"
|
||||
assert updated_checkpoint["hash"] == "abc123"
|
||||
assert updated_checkpoint["isDeleted"] is False
|
||||
assert updated_checkpoint["hashInvalid"] is False
|
||||
assert updated_checkpoint["name"] == "Main Model"
|
||||
assert updated_checkpoint["version"] == "v2"
|
||||
assert updated_checkpoint["baseModel"] == "SDXL 1.0"
|
||||
assert updated_checkpoint["id"] == 42
|
||||
# The pre-reconnect state is snapshotted for undo
|
||||
assert updated_checkpoint["reconnectSnapshot"] == original_checkpoint
|
||||
assert "reconnectSnapshot" not in updated_checkpoint["reconnectSnapshot"]
|
||||
|
||||
with recipe_path.open("r", encoding="utf-8") as file_obj:
|
||||
persisted = json.load(file_obj)
|
||||
assert persisted["checkpoint"]["hash"] == "abc123"
|
||||
assert persisted["checkpoint"]["reconnectSnapshot"] == original_checkpoint
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id)
|
||||
assert cached_recipe["checkpoint"]["hash"] == "abc123"
|
||||
|
||||
|
||||
async def test_update_checkpoint_entry_backfills_missing_display_keys(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-sparse"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
# Parser-style sparse entry without name/version/baseModel keys
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Sparse",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"checkpoint": {"file_name": "old", "hash": "aaa", "isDeleted": True},
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
target_info = {
|
||||
"sha256": "abc123",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"model_name": "Main Model",
|
||||
"base_model": "SDXL 1.0",
|
||||
"civitai": {"id": 42, "name": "v2"},
|
||||
}
|
||||
|
||||
_, updated_checkpoint = await scanner.update_checkpoint_entry(
|
||||
recipe_id,
|
||||
target_name="main",
|
||||
target_checkpoint=target_info,
|
||||
)
|
||||
|
||||
assert updated_checkpoint["name"] == "Main Model"
|
||||
assert updated_checkpoint["version"] == "v2"
|
||||
assert updated_checkpoint["baseModel"] == "SDXL 1.0"
|
||||
assert updated_checkpoint["modelVersionId"] == 42
|
||||
|
||||
|
||||
async def test_restore_checkpoint_entry_round_trip(tmp_path: Path, recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-restore"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
original_checkpoint = {
|
||||
"name": "Old Model",
|
||||
"file_name": "old",
|
||||
"hash": "aaa",
|
||||
"isDeleted": True,
|
||||
}
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Original",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"checkpoint": dict(original_checkpoint),
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
target_info = {
|
||||
"sha256": "abc123",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"model_name": "Main Model",
|
||||
"civitai": {"id": 42, "name": "v2"},
|
||||
}
|
||||
await scanner.update_checkpoint_entry(
|
||||
recipe_id, target_name="main", target_checkpoint=target_info
|
||||
)
|
||||
|
||||
restored_recipe, restored_checkpoint = await scanner.restore_checkpoint_entry(
|
||||
recipe_id
|
||||
)
|
||||
|
||||
assert restored_recipe["checkpoint"] == original_checkpoint
|
||||
assert "reconnectSnapshot" not in restored_recipe["checkpoint"]
|
||||
assert restored_checkpoint["file_name"] == "old"
|
||||
|
||||
with recipe_path.open("r", encoding="utf-8") as file_obj:
|
||||
persisted = json.load(file_obj)
|
||||
assert persisted["checkpoint"] == original_checkpoint
|
||||
|
||||
|
||||
async def test_restore_checkpoint_entry_without_snapshot_rejected(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "recipe-ckpt-no-snapshot"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": recipe_id, "checkpoint": {"file_name": "plain"}})
|
||||
)
|
||||
|
||||
with pytest.raises(RecipeValidationError):
|
||||
await scanner.restore_checkpoint_entry(recipe_id)
|
||||
|
||||
|
||||
async def test_set_checkpoint_entry_hash_invalid_persists_flag(
|
||||
tmp_path: Path, recipe_scanner
|
||||
):
|
||||
scanner, _ = recipe_scanner
|
||||
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "hash-invalid-ckpt"
|
||||
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(tmp_path / "image.png"),
|
||||
"title": "Hash invalid",
|
||||
"modified": 0.0,
|
||||
"created_date": 0.0,
|
||||
"checkpoint": {"name": "Old", "file_name": "old", "hash": "a2a12bfa01"},
|
||||
}
|
||||
recipe_path.write_text(json.dumps(recipe_data))
|
||||
await scanner.add_recipe(dict(recipe_data))
|
||||
|
||||
updated_recipe, updated_checkpoint = await scanner.set_checkpoint_entry_hash_invalid(
|
||||
recipe_id, True
|
||||
)
|
||||
|
||||
assert updated_checkpoint["hashInvalid"] is True
|
||||
assert updated_recipe["checkpoint"]["hashInvalid"] is True
|
||||
with recipe_path.open("r", encoding="utf-8") as file_obj:
|
||||
persisted = json.load(file_obj)
|
||||
assert persisted["checkpoint"]["hashInvalid"] is True
|
||||
assert persisted["checkpoint"]["hash"] == "a2a12bfa01"
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id)
|
||||
assert cached_recipe["checkpoint"]["hashInvalid"] is True
|
||||
|
||||
_, cleared_checkpoint = await scanner.set_checkpoint_entry_hash_invalid(
|
||||
recipe_id, False
|
||||
)
|
||||
assert cleared_checkpoint["hashInvalid"] is False
|
||||
|
||||
|
||||
async def test_find_local_checkpoints_by_name_uses_checkpoint_scanner(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
from py.services.recipe_scanner import RecipeScanner as RecipeScannerCls
|
||||
|
||||
class StubCheckpointScanner:
|
||||
async def find_models_by_name(self, name, *, base_model=None):
|
||||
return [
|
||||
{"file_name": f"{name}.safetensors", "base_model": base_model or ""}
|
||||
]
|
||||
|
||||
class StubLoraScannerForCkpt:
|
||||
async def get_cached_data(self):
|
||||
return SimpleNamespace(raw_data=[], version_index={})
|
||||
|
||||
RecipeScannerCls._instance = None
|
||||
scanner = RecipeScannerCls(
|
||||
lora_scanner=StubLoraScannerForCkpt(),
|
||||
checkpoint_scanner=StubCheckpointScanner(), # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
matches = await scanner.find_local_checkpoints_by_name("main")
|
||||
assert matches == [{"file_name": "main.safetensors", "base_model": ""}]
|
||||
|
||||
assert await scanner.find_local_checkpoints_by_name("") == []
|
||||
scanner._checkpoint_scanner = None
|
||||
assert await scanner.find_local_checkpoints_by_name("main") == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_recipe_syntax_tokens_skips_unobtainable_loras(tmp_path: Path, recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
@@ -1676,3 +1676,306 @@ async def test_analyze_remote_image_meta_null_keeps_exif_loras(tmp_path, monkeyp
|
||||
assert loras[0]["hash"] == LORA_SHA256
|
||||
assert loras[0].get("isDeleted") in (None, False)
|
||||
assert "Daphne" in str(payload.get("gen_params", {}).get("prompt"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Checkpoint reconnect chain (manual remediation for recipe.checkpoint)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_persistence_service():
|
||||
return RecipePersistenceService(
|
||||
exif_utils=DummyExifUtils(),
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_checkpoint_distinguishes_ambiguous_mismatched_and_missing(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
models = [
|
||||
{
|
||||
"file_name": "realistic.safetensors",
|
||||
"folder": "sdxl",
|
||||
"file_path": "/models/checkpoints/sdxl/realistic.safetensors",
|
||||
"base_model": "SDXL 1.0",
|
||||
},
|
||||
{
|
||||
"file_name": "realistic.safetensors",
|
||||
"folder": "sd15",
|
||||
"file_path": "/models/checkpoints/sd15/realistic.safetensors",
|
||||
"base_model": "SD 1.5",
|
||||
},
|
||||
]
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self, recipe_path):
|
||||
self._recipe_path = recipe_path
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(self._recipe_path)
|
||||
|
||||
async def find_local_checkpoints_by_name(self, name, base_model=None):
|
||||
return ModelScanner.find_matching_models(models, name, base_model=base_model)
|
||||
|
||||
def write_recipe(base_model):
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": "r1", "base_model": base_model, "checkpoint": {}})
|
||||
)
|
||||
return DummyScanner(recipe_path)
|
||||
|
||||
# Ambiguous bare name: two candidates survive (recipe base model unknown)
|
||||
scanner = write_recipe("")
|
||||
with pytest.raises(RecipeValidationError, match="include the folder path"):
|
||||
await service.reconnect_checkpoint(
|
||||
recipe_scanner=scanner, recipe_id="r1", target_name="realistic"
|
||||
)
|
||||
|
||||
# Confident base-model mismatch: the only candidate belongs to another family
|
||||
scanner = write_recipe("SD 1.5")
|
||||
with pytest.raises(RecipeValidationError, match="different base model"):
|
||||
await service.reconnect_checkpoint(
|
||||
recipe_scanner=scanner, recipe_id="r1", target_name="sdxl/realistic"
|
||||
)
|
||||
|
||||
# No candidate at all
|
||||
scanner = write_recipe("SDXL 1.0")
|
||||
with pytest.raises(RecipeNotFoundError, match="not found"):
|
||||
await service.reconnect_checkpoint(
|
||||
recipe_scanner=scanner, recipe_id="r1", target_name="missing"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_checkpoint_family_compatible_succeeds_with_warning(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
pony_item = {
|
||||
"file_name": "main.safetensors",
|
||||
"folder": "",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"base_model": "Pony",
|
||||
"sha256": "ab" * 32,
|
||||
}
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": "r1", "base_model": "Illustrious", "checkpoint": {}})
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(recipe_path)
|
||||
|
||||
async def find_local_checkpoints_by_name(self, name, base_model=None):
|
||||
return [pony_item]
|
||||
|
||||
async def update_checkpoint_entry(self, recipe_id, *, target_name, target_checkpoint):
|
||||
assert target_checkpoint is pony_item
|
||||
return ({"id": "r1"}, {"file_name": target_checkpoint["file_name"]})
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
result = await service.reconnect_checkpoint(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1", target_name="main"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["base_model_mismatch"] == {
|
||||
"recipe_base_model": "Illustrious",
|
||||
"checkpoint_base_model": "Pony",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_checkpoint_exact_base_model_has_no_warning(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
item = {
|
||||
"file_name": "main.safetensors",
|
||||
"folder": "",
|
||||
"file_path": "/models/checkpoints/main.safetensors",
|
||||
"base_model": "SDXL 1.0",
|
||||
"sha256": "ab" * 32,
|
||||
}
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps({"id": "r1", "base_model": "SDXL 1.0", "checkpoint": {}})
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(recipe_path)
|
||||
|
||||
async def find_local_checkpoints_by_name(self, name, base_model=None):
|
||||
return [item]
|
||||
|
||||
async def update_checkpoint_entry(self, recipe_id, *, target_name, target_checkpoint):
|
||||
return ({"id": "r1"}, {"file_name": target_checkpoint["file_name"]})
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
result = await service.reconnect_checkpoint(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1", target_name="main"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert "base_model_mismatch" not in result.payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_checkpoint_delegates_and_reports(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class DummyScanner:
|
||||
async def restore_checkpoint_entry(self, recipe_id):
|
||||
assert recipe_id == "r1"
|
||||
return (
|
||||
{"id": "r1", "checkpoint": {"file_name": "old.safetensors"}},
|
||||
{"file_name": "old.safetensors"},
|
||||
)
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint):
|
||||
return []
|
||||
|
||||
result = await service.restore_checkpoint(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["updated_checkpoint"]["file_name"] == "old.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_checkpoint_reconnect_suggestions_loads_entry_and_delegates(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": "r1",
|
||||
"base_model": "SD 1.5",
|
||||
"checkpoint": {"file_name": "old.safetensors", "hash": "aaa"},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
class DummyScanner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
assert recipe_id == "r1"
|
||||
return str(recipe_path)
|
||||
|
||||
async def suggest_checkpoint_reconnect_candidates(
|
||||
self, *, entry, recipe_base_model, query=None, limit=5
|
||||
):
|
||||
self.calls.append(
|
||||
{
|
||||
"entry": entry,
|
||||
"recipe_base_model": recipe_base_model,
|
||||
"query": query,
|
||||
}
|
||||
)
|
||||
return [
|
||||
{
|
||||
"file_name": "new.safetensors",
|
||||
"score": 1.0,
|
||||
"match_reason": "same_hash",
|
||||
"target_name": "new",
|
||||
}
|
||||
]
|
||||
|
||||
scanner = DummyScanner()
|
||||
result = await service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=scanner, recipe_id="r1", query="new"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["suggestions"][0]["target_name"] == "new"
|
||||
assert scanner.calls == [
|
||||
{
|
||||
"entry": {"file_name": "old.safetensors", "hash": "aaa"},
|
||||
"recipe_base_model": "SD 1.5",
|
||||
"query": "new",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_checkpoint_reconnect_suggestions_validates_recipe(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class MissingScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(tmp_path / "missing.json")
|
||||
|
||||
with pytest.raises(RecipeNotFoundError):
|
||||
await service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=MissingScanner(), recipe_id="nope"
|
||||
)
|
||||
|
||||
recipe_path = tmp_path / "recipe.json"
|
||||
recipe_path.write_text(json.dumps({"id": "r1"}))
|
||||
|
||||
class EmptyScanner:
|
||||
async def get_recipe_json_path(self, recipe_id):
|
||||
return str(recipe_path)
|
||||
|
||||
with pytest.raises(RecipeValidationError, match="checkpoint"):
|
||||
await service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=EmptyScanner(), recipe_id="r1"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_checkpoint_hash_invalid_delegates_and_reports(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class DummyScanner:
|
||||
async def set_checkpoint_entry_hash_invalid(self, recipe_id, hash_invalid):
|
||||
assert recipe_id == "r1"
|
||||
assert hash_invalid is True
|
||||
return (
|
||||
{"id": "r1", "checkpoint": {"file_name": "m", "hashInvalid": True}},
|
||||
{"file_name": "m", "hashInvalid": True},
|
||||
)
|
||||
|
||||
result = await service.mark_checkpoint_hash_invalid(
|
||||
recipe_scanner=DummyScanner(), recipe_id="r1"
|
||||
)
|
||||
|
||||
assert result.payload["success"] is True
|
||||
assert result.payload["recipe_id"] == "r1"
|
||||
assert result.payload["hash_invalid"] is True
|
||||
assert result.payload["updated_checkpoint"]["hashInvalid"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_checkpoint_hash_invalid_can_clear_flag(tmp_path):
|
||||
service = _make_persistence_service()
|
||||
|
||||
class DummyScanner:
|
||||
async def set_checkpoint_entry_hash_invalid(self, recipe_id, hash_invalid):
|
||||
assert hash_invalid is False
|
||||
return (
|
||||
{"id": "r1", "checkpoint": {"file_name": "m", "hashInvalid": False}},
|
||||
{"file_name": "m", "hashInvalid": False},
|
||||
)
|
||||
|
||||
result = await service.mark_checkpoint_hash_invalid(
|
||||
recipe_scanner=DummyScanner(),
|
||||
recipe_id="r1",
|
||||
hash_invalid=False,
|
||||
)
|
||||
|
||||
assert result.payload["hash_invalid"] is False
|
||||
assert result.payload["updated_checkpoint"]["hashInvalid"] is False
|
||||
|
||||
@@ -120,6 +120,76 @@ class TestPersistentRecipeCache:
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is None
|
||||
|
||||
def test_import_info_roundtrip(self, temp_db_path, sample_recipes):
|
||||
"""import_info (import provenance + no-LoRA reason) survives the cache."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
|
||||
sample_recipes[0]["import_info"] = {
|
||||
"channel": "batch_import_url",
|
||||
"reason": "api_meta_no_lora_resources",
|
||||
"details": {"api_meta_keys": ["prompt"], "api_model_version_ids": 0},
|
||||
}
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
r1 = next(r for r in loaded.raw_data if r["id"] == "recipe-001")
|
||||
assert r1["import_info"]["channel"] == "batch_import_url"
|
||||
assert r1["import_info"]["reason"] == "api_meta_no_lora_resources"
|
||||
assert r1["import_info"]["details"]["api_meta_keys"] == ["prompt"]
|
||||
|
||||
# Recipes without import_info simply omit the key.
|
||||
r2 = next(r for r in loaded.raw_data if r["id"] == "recipe-002")
|
||||
assert "import_info" not in r2
|
||||
|
||||
def test_import_info_column_migration(self, temp_db_path, sample_recipes):
|
||||
"""Existing databases gain the import_info_json column via ALTER TABLE."""
|
||||
import sqlite3
|
||||
|
||||
# Simulate a legacy database without the new column.
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE recipes (
|
||||
recipe_id TEXT PRIMARY KEY,
|
||||
file_path TEXT,
|
||||
json_path TEXT,
|
||||
title TEXT,
|
||||
folder TEXT,
|
||||
source_path TEXT,
|
||||
base_model TEXT,
|
||||
fingerprint TEXT,
|
||||
created_date REAL,
|
||||
modified REAL,
|
||||
file_mtime REAL,
|
||||
file_size INTEGER,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
repair_version INTEGER DEFAULT 0,
|
||||
preview_nsfw_level INTEGER DEFAULT 0,
|
||||
loras_json TEXT,
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE cache_metadata (key TEXT PRIMARY KEY, value TEXT);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
cache.save_cache(sample_recipes)
|
||||
|
||||
conn = sqlite3.connect(temp_db_path)
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(recipes)")}
|
||||
conn.close()
|
||||
assert "import_info_json" in columns
|
||||
|
||||
loaded = cache.load_cache()
|
||||
assert loaded is not None
|
||||
assert len(loaded.raw_data) == 2
|
||||
|
||||
def test_update_single_recipe(self, temp_db_path, sample_recipes):
|
||||
"""Test updating a single recipe."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
@@ -595,15 +665,19 @@ class TestHasWorkflowColumn:
|
||||
assert by_id["wf-3"]["has_workflow"] is False
|
||||
|
||||
def test_prepare_recipe_row_matches_column_order(self, temp_db_path):
|
||||
"""The prepared row must append has_workflow in column order."""
|
||||
"""The prepared row must append has_workflow/import_info in column order."""
|
||||
cache = PersistentRecipeCache(db_path=temp_db_path)
|
||||
row_true = cache._prepare_recipe_row({"id": "r1", "has_workflow": True}, "")
|
||||
row_false = cache._prepare_recipe_row({"id": "r2", "has_workflow": False}, "")
|
||||
|
||||
assert row_true[-1] == 1
|
||||
assert row_false[-1] == 0
|
||||
assert row_true[-2] == 1
|
||||
assert row_false[-2] == 0
|
||||
# import_info_json is the trailing column, unset by default.
|
||||
assert row_true[-1] is None
|
||||
assert row_false[-1] is None
|
||||
assert len(row_true) == len(cache._RECIPE_COLUMNS)
|
||||
assert cache._RECIPE_COLUMNS[-1] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-2] == "has_workflow"
|
||||
assert cache._RECIPE_COLUMNS[-1] == "import_info_json"
|
||||
|
||||
def test_update_recipe_preserves_has_workflow(self, temp_db_path):
|
||||
"""update_recipe() must write the has_workflow column correctly."""
|
||||
|
||||
Reference in New Issue
Block a user