Compare commits

...

4 Commits

Author SHA1 Message Date
Will Miao d761ac77f7 fix(recipes): align LoRA reconnect affordances with checkpoint rules
- Offer reconnect for name-only LoRA entries with no CivitAI
  identifiers, matching the checkpoint "broken" classification
  instead of rendering no action at all
- Mark a LoRA hash-invalid when a direct (modelId/versionId) download
  fails with a clearly unresolvable error, mirroring the checkpoint
  path; transient failures leave the entry untouched
2026-08-30 18:33:24 +08:00
Will Miao c8b9db5bf4 feat(recipes): add manual checkpoint reconnect for broken recipe entries
Checkpoint entries that cannot be restored by download (deleted,
unresolvable hash, or name-only remnants with no CivitAI identifiers)
now get the same remediation chain LoRAs already had:

- scanner: parameterized reconnect-suggestion ranking, update/restore/
  set-hash-invalid for the checkpoint entry, and clear hashInvalid on
  rematch write-back (was only done for LoRAs)
- persistence/handlers/routes: reconnect/restore/reconnect-suggestions/
  mark-hash-invalid endpoints under /api/lm/recipe/checkpoint/*
- modal: checkpoint reconnect UI (deleted/hash-invalid badges, inline
  form with suggestions, undo for reconnected entries); download
  failures mark the hash invalid only on explicit unresolvable signals
  (not found/deleted/404/410), matching the LoRA rule
- css: checkpoint undo button shares the LoRA undo styles
- i18n: the 14 new keys translated in all 9 locales
2026-08-30 18:02:15 +08:00
Will Miao bce7d1d30c docs(i18n): resolve R1 vs R8/§7 contradiction on proactive translation
R1 instructed agents to "translate the newly added keys in every locale"
right after syncing, while R8 and §7 make [TODO: Translate] placeholders
the sanctioned end state during feature development until the feature
owner explicitly asks for translations. Reword R1 and the AGENTS.md
Localization section to say stop after syncing and never translate
proactively.
2026-08-30 16:28:53 +08:00
Will Miao bccd494a56 feat(recipes): explain empty LoRA lists with collapsible "Why no LoRAs?" panel
Record import provenance on every recipe: a new import_info block
(channel, machine-readable no-LoRA reason, diagnostic details) built at
import time across all channels (batch import, single URL, local file,
upload, widget save, re-imports) and persisted in the recipe JSON plus
the SQLite persistent cache (new import_info_json column with ALTER
TABLE migration).

The recipe modal renders the empty LoRA list with a collapsed details
panel showing the import method, the reason (CivitAI API returned no
LoRA resource data, API meta missing, no embedded metadata, ComfyUI
workflow metadata, video, unparsable format), and recorded diagnostics.
Legacy recipes without import_info fall back to heuristics labeled as
inferred. Genuine no-LoRA generations show no panel.

CivitAI images are always classified by API meta shape: the onsite
generator writes A1111-style EXIF without LoRA references, so parsed
EXIF cannot prove "no LoRAs used".

Adds recipes.resources.noLoras* i18n keys (all 10 locales) plus
frontend vitest and backend pytest coverage.
2026-08-30 16:28:41 +08:00
32 changed files with 3503 additions and 75 deletions
+5
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}",
+160
View File
@@ -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")
+16
View File
@@ -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(
+16
View File
@@ -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"):
+24 -1
View File
@@ -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
+280 -7
View File
@@ -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."""
+3
View File
@@ -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",
+53 -4
View File
@@ -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)
+129
View File
@@ -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
+186
View File
@@ -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
+73 -3
View File
@@ -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);
+516 -38
View File
@@ -66,6 +66,29 @@ function escapeHtml(value) {
.replace(/'/g, '&#39;');
}
// 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) {
+4 -1
View File
@@ -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);
});
});
});
+118
View File
@@ -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
+188
View File
@@ -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
+245
View File
@@ -768,6 +768,251 @@ async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_
assert cleared_lora["hashInvalid"] is False
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
+303
View File
@@ -1676,3 +1676,306 @@ async def test_analyze_remote_image_meta_null_keeps_exif_loras(tmp_path, monkeyp
assert loras[0]["hash"] == LORA_SHA256
assert loras[0].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
+78 -4
View File
@@ -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."""