Compare commits

..
21 Commits
Author SHA1 Message Date
Will Miao 94dd08646d chore(release): bump version to v1.2.1 2026-08-16 19:47:15 +08:00
Will Miao 658f88ca48 feat(recipes): add toolbar toggle and settings preview for masonry layout 2026-08-16 15:23:30 +08:00
Will Miao f53352efb2 feat(metadata): collect generation params from Krea two/three stage samplers 2026-08-16 09:53:08 +08:00
Will Miao 38809a9d1b feat(recipes): add filename fallback tier to recipe rematch 2026-08-16 09:17:59 +08:00
Will Miao 395682509c feat(autocomplete): replace /af and /ac toggle abbreviations with full command names 2026-08-15 22:03:54 +08:00
Will Miao ef3e7d7bf4 feat(update): detect CivitAI paidAccess versions and add hide paid updates (#1060)
CivitAI's PaidAccess cutover deprecated the availability=EarlyAccess and
earlyAccessEndsAt signals; gated versions now report availability=Public
with a paidAccess DTO that LoRA Manager previously ignored, so "Hide
Early Access Updates" missed paid/early-access models and downloads
failed with 401.

Parse and persist paidAccess from model-level, bulk, and by-hash
responses; treat timed paid gates as early access and permanent paid
versions as a distinct is_paid state; add a hide_paid_updates setting
with a "Paid" badge in the versions tab; warn before downloading gated
versions. Includes SQLite migration, i18n for all locales, and
backend/frontend tests.
2026-08-15 18:08:14 +08:00
Will Miao c85b6b64a1 feat(recipes): add recently opened sort with modal open tracking
Track recipe modal opens in a separate stats file (never touching recipe
JSON/EXIF), expose a fire-and-forget POST endpoint, and add an 'opened'
sort that hides never-opened recipes as a true recently-opened view.
Includes i18n for all locales and backend/frontend tests.
2026-08-15 11:37:46 +08:00
Will Miao 34c87d4934 refactor(sort): extract seeded random sort helpers into SortDropdown 2026-08-15 09:53:28 +08:00
Will Miao 93472e5d67 feat(recipes): add sort by random option with seeded stable pagination 2026-08-15 09:50:46 +08:00
Will Miao ae185ee714 fix(loaders): correct random checkpoint loader return type annotation
load_checkpoint returns a 4-tuple (MODEL, CLIP, VAE, model_name) since the
random loader exposes the selected model name; the annotation still claimed
a 3-tuple.
2026-08-15 08:51:39 +08:00
Martial Michel 795036275a feat(loaders): add random model selection by base model to checkpoint/unet loaders
Add dedicated Random Checkpoint/Unet Loader (LoraManager) nodes that pick a random model from the indexed pool on every run, optionally filtered by base_model, and expose the selected model name via a STRING output.
2026-08-15 08:48:57 +08:00
Will Miao d43ab6e32f fix(vue-widgets): make text widget clear button undoable via Ctrl+Z (#1056) 2026-08-14 23:16:12 +08:00
Will Miao 280181f92e feat(metadata-overwrite): support wired SAMPLER input on sampler field
The sampler field now accepts either a manual string or a SAMPLER
connection. When wired, the sampler name is extracted from the
KSAMPLER object's sampler_function __name__ (sample_euler -> euler),
with special-casing for dpm_fast/dpm_adaptive local closures and
uni_pc/uni_pc_bh2 function names.

- sampler input declared as "STRING,SAMPLER" with widgetType STRING,
  mirroring the existing model field union pattern
- shared collect_overwrite_params() handles the non-str branch so the
  node and the metadata extractor conversion logic stay in sync;
  unrecognized sampler functions are logged and skipped
- note: ddim is constructed by ComfyUI as euler with random inpaint,
  so the ddim name is unrecoverable and extracts as euler
2026-08-14 15:21:28 +08:00
Will Miao f8d98934ad feat(ui): set preview via drag and drop on model cards (#1034) 2026-08-14 13:01:10 +08:00
Will Miao 303cca0d85 fix(download): accept newer CivitAI file types for primary file selection
Downloads failed with "No suitable file found in metadata" for models whose
only file uses newer CivitAI file types (e.g. 'Enhancement LoRA' for
Anima/AIR image-editing LoRAs) because the primary-file allowlist only
covered legacy types.

- unify the weights-type allowlist as MODEL_WEIGHT_FILE_TYPES
  (py/utils/constants.py) and apply it across download, recipe and
  metadata-refresh lookups
- mirror CivitAI's getPrimaryFile() semantics: prefer weights-type primary,
  fall back to weights files, then trust CivitAI's primary flag (excluding
  non-downloadable artifacts like Config/Archive/Workflow)
- mirror the allowlist in the frontend via shared isModelWeightFile() helper
- add regression tests for the Enhancement LoRA primary-file download,
  primary-flag fallback and weights-over-non-weights-primary preference
2026-08-12 21:14:23 +08:00
Will Miao c2f16784b3 fix(metadata): keep identity selectors from leaking unselected prompts 2026-08-12 19:44:43 +08:00
Will Miao 5bc6d8286c fix(metadata): exclude scalar fields from conditioning provenance inputs 2026-08-12 19:18:46 +08:00
Luna_K 3f8381ffee Fix prompt tracking through conditioning transforms 2026-08-12 19:16:30 +08:00
Will Miao 1ca99294c9 feat(delete): shorten undo window to 20s and make undo toast dismissible 2026-08-12 19:15:03 +08:00
Will Miao 680f0a57f5 fix(update): resolve template path when updating to a different base model (#1059)
Version-tab updates reused the current version's folder, so updating a LoRA
to a version with a different base model (e.g. Illustrious -> Anima) ignored
the download path template and landed in the old version's directory.

When the target version's base model differs from the current local version
and a path template is configured, re-resolve the template under the same
model root. The backend keeps an explicitly provided root when
use_save_dir_as_root is set, so regular downloads still use the default root.
2026-08-12 18:45:54 +08:00
Will Miao 94e3f54571 feat(workflow): exclude text-capable nodes with connected text from send targets
CLIP Text Encode and friends whose text widget is backed by a connected
input cannot have their text changed via the widget (execution reads the
linked input), so sending to them was a silent no-op.

- Registry: compute text_widget_connected capability from the widget's
  backing input link state; has_text_widget drops to false when wired;
  include the flag in the registration fingerprint so link changes
  re-register the affected nodes
- Registry: hook link connect/disconnect (graph events on new litegraph,
  onAfterChange fallback for classic) on root and subgraphs, plus
  subgraph-created for future subgraphs
- applyWidgetUpdate: skip inject_text when the target widget is connected
  and self-heal the registry instead of writing a value that is ignored
- Web UI: drop text_widget_connected nodes from prompt/embedding send
  candidates; show a Mark as -> Send Prompt Target hint toast when no
  candidates remain (new uiHelpers.workflow.noPromptTargets key, synced
  to all locales; zh-CN/zh-TW translated)
- Extract shared resolveTextWidget() used by both the candidate-set
  logic and the write path so the two cannot drift apart
- Tests: workflow registry connection-state registration, subgraph
  handling, fingerprint re-registration, inject_text write/skip paths,
  setup link-change hooks; uiHelpers candidate filtering and hint toast
2026-08-12 16:50:07 +08:00
82 changed files with 6488 additions and 747 deletions
+10
View File
@@ -3,6 +3,8 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
from .py.nodes.unet_loader import UNETLoaderLM
from .py.nodes.random_checkpoint_loader import RandomCheckpointLoaderLM
from .py.nodes.random_unet_loader import RandomUNETLoaderLM
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM
@@ -40,6 +42,12 @@ except (
"py.nodes.checkpoint_loader"
).CheckpointLoaderLM
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
RandomCheckpointLoaderLM = importlib.import_module(
"py.nodes.random_checkpoint_loader"
).RandomCheckpointLoaderLM
RandomUNETLoaderLM = importlib.import_module(
"py.nodes.random_unet_loader"
).RandomUNETLoaderLM
TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle"
).TriggerWordToggleLM
@@ -79,6 +87,8 @@ NODE_CLASS_MAPPINGS = {
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
UNETLoaderLM.NAME: UNETLoaderLM,
RandomCheckpointLoaderLM.NAME: RandomCheckpointLoaderLM,
RandomUNETLoaderLM.NAME: RandomUNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM,
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
+327 -295
View File
File diff suppressed because it is too large Load Diff
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
@@ -920,7 +924,9 @@
"dateAsc": "Älteste",
"lorasCount": "LoRA-Anzahl",
"lorasCountDesc": "Meiste",
"lorasCountAsc": "Wenigste"
"lorasCountAsc": "Wenigste",
"opened": "Zuletzt geöffnet",
"openedDesc": "Zuletzt geöffnet"
},
"refresh": {
"title": "Rezeptliste aktualisieren",
@@ -931,6 +937,11 @@
"favorites": {
"title": "Nur Favoriten anzeigen",
"action": "Favoriten"
},
"layout": {
"title": "Rezepte-Layout",
"grid": "Raster-Layout",
"masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Gibt {size} frei",
"title": "Modell löschen",
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Die Datei wird nach 20 Sekunden endgültig gelöscht, sofern Sie nicht rückgängig machen."
},
"deleteRecipe": {
"recoverableWarning": "Diese Aktion kann 30 Sekunden lang rückgängig gemacht werden."
"recoverableWarning": "Diese Aktion kann 20 Sekunden lang rückgängig gemacht werden."
},
"excludeModel": {
"title": "Modell ausschließen",
@@ -1548,6 +1559,8 @@
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
@@ -1557,6 +1570,7 @@
"download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
@@ -2119,12 +2134,12 @@
"copyFailed": "Kopieren fehlgeschlagen"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Rückgängig",
"deleted": "Gelöscht: {name}",
"deletedBulk": "{count} Element(e) gelöscht",
"expired": "Undo-Fenster abgelaufen. Das Element wurde endgültig gelöscht.",
"failed": "Rückgängig machen fehlgeschlagen: {error}",
"restored": "Element wiederhergestellt"
},
"virtual": {
"loadFailed": "Fehler beim Laden der Elemente",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
"previewUpdated": "Vorschau erfolgreich aktualisiert",
"previewUploadFailed": "Fehler beim Hochladen des Vorschaubilds",
"previewDropInvalid": "Nicht unterstützter Dateityp: {name}. Ziehen Sie stattdessen ein Bild oder ein MP4-Video hinein.",
"refreshComplete": "{action} abgeschlossen",
"refreshFailed": "Fehler beim {action} der {type}s",
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
+19 -3
View File
@@ -622,6 +622,10 @@
"label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
},
"hidePaidUpdates": {
"label": "Hide Paid Updates",
"help": "When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Use updated license icons",
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
@@ -920,7 +924,9 @@
"dateAsc": "Oldest",
"lorasCount": "LoRA Count",
"lorasCountDesc": "Most",
"lorasCountAsc": "Least"
"lorasCountAsc": "Least",
"opened": "Recently Opened",
"openedDesc": "Recently opened"
},
"refresh": {
"title": "Refresh recipe list",
@@ -931,6 +937,11 @@
"favorites": {
"title": "Show Favorites Only",
"action": "Favorites"
},
"layout": {
"title": "Recipes Layout",
"grid": "Grid layout",
"masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)"
}
},
"duplicates": {
@@ -1280,10 +1291,10 @@
"freesSpace": "Frees {size}",
"title": "Delete Model",
"message": "Are you sure you want to delete this model and all associated files?",
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "This will permanently delete the file after 20 seconds unless you undo."
},
"deleteRecipe": {
"recoverableWarning": "This action can be undone for 30 seconds."
"recoverableWarning": "This action can be undone for 20 seconds."
},
"excludeModel": {
"title": "Exclude Model",
@@ -1548,6 +1559,8 @@
"newerTooltip": "This version is newer than your latest local version",
"earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access",
"paid": "Paid",
"paidTooltip": "This version requires payment to download",
"ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
@@ -1557,6 +1570,7 @@
"download": "Download",
"downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete",
"deleteTooltip": "Delete this local version",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noPromptTargets": "No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "Failed to rename file: {error}",
"previewUpdated": "Preview updated successfully",
"previewUploadFailed": "Failed to upload preview image",
"previewDropInvalid": "Unsupported file type: {name}. Drop an image or MP4 video instead.",
"refreshComplete": "{action} complete",
"refreshFailed": "Failed to {action} {type}s",
"metadataRefreshed": "Metadata refreshed successfully",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo actualizaciones de acceso temprano"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados",
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
@@ -920,7 +924,9 @@
"dateAsc": "Más antiguo",
"lorasCount": "Cant. de LoRAs",
"lorasCountDesc": "Más",
"lorasCountAsc": "Menos"
"lorasCountAsc": "Menos",
"opened": "Abiertos recientemente",
"openedDesc": "Abiertos recientemente"
},
"refresh": {
"title": "Actualizar lista de recetas",
@@ -931,6 +937,11 @@
"favorites": {
"title": "Mostrar solo favoritos",
"action": "Favoritos"
},
"layout": {
"title": "Diseño de recetas",
"grid": "Vista de cuadrícula",
"masonry": "Vista masonry (estilo Pinterest, conserva la proporción de aspecto de la imagen)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Libera {size}",
"title": "Eliminar modelo",
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "El archivo se eliminará permanentemente después de 20 segundos a menos que deshaga la acción."
},
"deleteRecipe": {
"recoverableWarning": "Esta acción se puede deshacer durante 30 segundos."
"recoverableWarning": "Esta acción se puede deshacer durante 20 segundos."
},
"excludeModel": {
"title": "Excluir modelo",
@@ -1548,6 +1559,8 @@
"newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
@@ -1557,6 +1570,7 @@
"download": "Descargar",
"downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo",
@@ -2119,12 +2134,12 @@
"copyFailed": "Error al copiar"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Deshacer",
"deleted": "Eliminado: {name}",
"deletedBulk": "{count} elemento(s) eliminado(s)",
"expired": "La ventana de deshacer ha caducado. El elemento se eliminó permanentemente.",
"failed": "No se pudo deshacer: {error}",
"restored": "Elemento restaurado"
},
"virtual": {
"loadFailed": "Error al cargar elementos",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "Error al renombrar archivo: {error}",
"previewUpdated": "Vista previa actualizada exitosamente",
"previewUploadFailed": "Error al subir imagen de vista previa",
"previewDropInvalid": "Tipo de archivo no admitido: {name}. Arrastra una imagen o un video MP4 en su lugar.",
"refreshComplete": "{action} completada",
"refreshFailed": "Error al {action} {type}s",
"metadataRefreshed": "Metadatos actualizados exitosamente",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement les mises à jour en accès anticipé"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour",
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
@@ -920,7 +924,9 @@
"dateAsc": "Plus ancien",
"lorasCount": "Nombre de LoRAs",
"lorasCountDesc": "Plus",
"lorasCountAsc": "Moins"
"lorasCountAsc": "Moins",
"opened": "Récemment ouverts",
"openedDesc": "Récemment ouverts"
},
"refresh": {
"title": "Actualiser la liste des recipes",
@@ -931,6 +937,11 @@
"favorites": {
"title": "Afficher uniquement les favoris",
"action": "Favoris"
},
"layout": {
"title": "Disposition des recettes",
"grid": "Disposition en grille",
"masonry": "Disposition masonry (style Pinterest, préserve le rapport d'aspect de l'image)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Libère {size}",
"title": "Supprimer le modèle",
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Le fichier sera définitivement supprimé après 20 secondes, sauf si vous annulez."
},
"deleteRecipe": {
"recoverableWarning": "Cette action peut être annulée pendant 30 secondes."
"recoverableWarning": "Cette action peut être annulée pendant 20 secondes."
},
"excludeModel": {
"title": "Exclure le modèle",
@@ -1548,6 +1559,8 @@
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
@@ -1557,6 +1570,7 @@
"download": "Télécharger",
"downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle",
@@ -2119,12 +2134,12 @@
"copyFailed": "Échec de la copie"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Annuler",
"deleted": "Supprimé : {name}",
"deletedBulk": "{count} élément(s) supprimé(s)",
"expired": "La fenêtre d'annulation a expiré. L'élément a été définitivement supprimé.",
"failed": "Échec de l'annulation : {error}",
"restored": "Élément restauré"
},
"virtual": {
"loadFailed": "Échec du chargement des éléments",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "Échec du renommage du fichier : {error}",
"previewUpdated": "Aperçu mis à jour avec succès",
"previewUploadFailed": "Échec du téléchargement de l'image d'aperçu",
"previewDropInvalid": "Type de fichier non pris en charge : {name}. Déposez plutôt une image ou une vidéo MP4.",
"refreshComplete": "{action} terminé",
"refreshFailed": "Échec de {action} des {type}s",
"metadataRefreshed": "Métadonnées actualisées avec succès",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
@@ -920,7 +924,9 @@
"dateAsc": "הכי ישן",
"lorasCount": "מספר LoRAs",
"lorasCountDesc": "הכי הרבה",
"lorasCountAsc": "הכי פחות"
"lorasCountAsc": "הכי פחות",
"opened": "נפתחו לאחרונה",
"openedDesc": "נפתחו לאחרונה"
},
"refresh": {
"title": "רענן רשימת מתכונים",
@@ -931,6 +937,11 @@
"favorites": {
"title": "הצג מועדפים בלבד",
"action": "מועדפים"
},
"layout": {
"title": "פריסת מתכונים",
"grid": "פריסת רשת",
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "מפנה {size}",
"title": "מחק מודל",
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "הקובץ יימחק לצמיתות לאחר 20 שניות, אלא אם תבטלו את הפעולה."
},
"deleteRecipe": {
"recoverableWarning": "ניתן לבטל פעולה זו תוך 30 שניות."
"recoverableWarning": "ניתן לבטל פעולה זו תוך 20 שניות."
},
"excludeModel": {
"title": "החרג מודל",
@@ -1548,6 +1559,8 @@
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
@@ -1557,6 +1570,7 @@
"download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל",
@@ -2119,12 +2134,12 @@
"copyFailed": "ההעתקה נכשלה"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "בטל",
"deleted": "נמחק: {name}",
"deletedBulk": "{count} פריטים נמחקו",
"expired": "חלון הביטול פג. הפריט נמחק לצמיתות.",
"failed": "הביטול נכשל: {error}",
"restored": "הפריט שוחזר"
},
"virtual": {
"loadFailed": "טעינת הפריטים נכשלה",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
"previewUploadFailed": "העלאת תמונת התצוגה המקדימה נכשלה",
"previewDropInvalid": "סוג קובץ לא נתמך: {name}. גרור במקום זאת תמונה או סרטון MP4.",
"refreshComplete": "{action} הושלם",
"refreshFailed": "{action} של {type}s נכשל",
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
@@ -920,7 +924,9 @@
"dateAsc": "古い順",
"lorasCount": "LoRA数",
"lorasCountDesc": "多い順",
"lorasCountAsc": "少ない順"
"lorasCountAsc": "少ない順",
"opened": "最近開いた",
"openedDesc": "最近開いた"
},
"refresh": {
"title": "レシピリストを更新",
@@ -931,6 +937,11 @@
"favorites": {
"title": "お気に入りのみ表示",
"action": "お気に入り"
},
"layout": {
"title": "レシピのレイアウト",
"grid": "グリッドレイアウト",
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "{size} を解放します",
"title": "モデルを削除",
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "元に戻さない場合、このファイルは20秒後に完全に削除されます。"
},
"deleteRecipe": {
"recoverableWarning": "この操作は30秒以内であれば元に戻せます。"
"recoverableWarning": "この操作は20秒以内であれば元に戻せます。"
},
"excludeModel": {
"title": "モデルを除外",
@@ -1548,6 +1559,8 @@
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
@@ -1557,6 +1570,7 @@
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました",
@@ -2119,12 +2134,12 @@
"copyFailed": "コピーに失敗しました"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "元に戻す",
"deleted": "{name} を削除しました",
"deletedBulk": "{count} 個のアイテムを削除しました",
"expired": "元に戻せる時間が経過しました。アイテムは完全に削除されました。",
"failed": "元に戻せませんでした: {error}",
"restored": "アイテムを復元しました"
},
"virtual": {
"loadFailed": "アイテムの読み込みに失敗しました",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
"previewUpdated": "プレビューが正常に更新されました",
"previewUploadFailed": "プレビュー画像のアップロードに失敗しました",
"previewDropInvalid": "サポートされていないファイル形式:{name}。画像またはMP4ビデオをドロップしてください。",
"refreshComplete": "{action} 完了",
"refreshFailed": "{type}の{action}に失敗しました",
"metadataRefreshed": "メタデータが正常に更新されました",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
@@ -920,7 +924,9 @@
"dateAsc": "오래된순",
"lorasCount": "LoRA 수",
"lorasCountDesc": "많은순",
"lorasCountAsc": "적은순"
"lorasCountAsc": "적은순",
"opened": "최근에 연",
"openedDesc": "최근에 연"
},
"refresh": {
"title": "레시피 목록 새로고침",
@@ -931,6 +937,11 @@
"favorites": {
"title": "즐겨찾기만 표시",
"action": "즐겨찾기"
},
"layout": {
"title": "레시피 레이아웃",
"grid": "그리드 레이아웃",
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "{size} 확보",
"title": "모델 삭제",
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "실행 취소하지 않으면 20초 후에 파일이 영구적으로 삭제됩니다."
},
"deleteRecipe": {
"recoverableWarning": "이 작업은 30초 이내에 실행 취소할 수 있습니다."
"recoverableWarning": "이 작업은 20초 이내에 실행 취소할 수 있습니다."
},
"excludeModel": {
"title": "모델 제외",
@@ -1548,6 +1559,8 @@
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
@@ -1557,6 +1570,7 @@
"download": "다운로드",
"downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패",
@@ -2119,12 +2134,12 @@
"copyFailed": "복사 실패"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "실행 취소",
"deleted": "{name} 삭제됨",
"deletedBulk": "{count}개 항목 삭제됨",
"expired": "실행 취소 기간이 만료되었습니다. 항목이 영구적으로 삭제되었습니다.",
"failed": "실행 취소 실패: {error}",
"restored": "항목이 복원되었습니다"
},
"virtual": {
"loadFailed": "항목 로딩 실패",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "파일 이름 변경 실패: {error}",
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
"previewUploadFailed": "미리보기 이미지 업로드 실패",
"previewDropInvalid": "지원되지 않는 파일 형식: {name}. 이미지 또는 MP4 동영상을 드롭하세요.",
"refreshComplete": "{action} 완료",
"refreshFailed": "{type} {action} 실패",
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
@@ -920,7 +924,9 @@
"dateAsc": "Сначала старые",
"lorasCount": "Кол-во LoRA",
"lorasCountDesc": "Больше всего",
"lorasCountAsc": "Меньше всего"
"lorasCountAsc": "Меньше всего",
"opened": "Недавно открытые",
"openedDesc": "Недавно открытые"
},
"refresh": {
"title": "Обновить список рецептов",
@@ -931,6 +937,11 @@
"favorites": {
"title": "Только избранные",
"action": "Избранное"
},
"layout": {
"title": "Макет рецептов",
"grid": "Макет сеткой",
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Освобождает {size}",
"title": "Удалить модель",
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Файл будет удалён навсегда через 20 секунд, если вы не отмените действие."
},
"deleteRecipe": {
"recoverableWarning": "Это действие можно отменить в течение 30 секунд."
"recoverableWarning": "Это действие можно отменить в течение 20 секунд."
},
"excludeModel": {
"title": "Исключить модель",
@@ -1548,6 +1559,8 @@
"newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
@@ -1557,6 +1570,7 @@
"download": "Скачать",
"downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели",
@@ -2119,12 +2134,12 @@
"copyFailed": "Копирование не удалось"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Отменить",
"deleted": "Удалено: {name}",
"deletedBulk": "Удалено: {count} шт.",
"expired": "Время отмены истекло. Элемент был удалён навсегда.",
"failed": "Не удалось отменить: {error}",
"restored": "Элемент восстановлен"
},
"virtual": {
"loadFailed": "Не удалось загрузить элементы",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "Не удалось переименовать файл: {error}",
"previewUpdated": "Превью успешно обновлено",
"previewUploadFailed": "Не удалось загрузить превью изображение",
"previewDropInvalid": "Неподдерживаемый тип файла: {name}. Перетащите вместо этого изображение или видео MP4.",
"refreshComplete": "{action} завершено",
"refreshFailed": "Не удалось {action} {type}s",
"metadataRefreshed": "Метаданные успешно обновлены",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "隐藏抢先体验更新",
"help": "抢先体验更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
@@ -920,7 +924,9 @@
"dateAsc": "最早",
"lorasCount": "LoRA 数量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
"lorasCountAsc": "最少",
"opened": "最近打开",
"openedDesc": "最近打开"
},
"refresh": {
"title": "刷新配方列表",
@@ -931,6 +937,11 @@
"favorites": {
"title": "仅显示收藏",
"action": "收藏"
},
"layout": {
"title": "配方布局",
"grid": "网格布局",
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "释放 {size}",
"title": "删除模型",
"message": "你确定要删除此模型及所有相关文件吗?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "如果不撤销,文件将在 20 秒后被永久删除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒内撤销。"
"recoverableWarning": "此操作可在 20 秒内撤销。"
},
"excludeModel": {
"title": "排除模型",
@@ -1548,6 +1559,8 @@
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
@@ -1557,6 +1570,7 @@
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败",
@@ -2119,12 +2134,12 @@
"copyFailed": "复制失败"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "撤销",
"deleted": "已删除 {name}",
"deletedBulk": "已删除 {count} 个项目",
"expired": "撤销窗口已过期,项目已被永久删除。",
"failed": "撤销失败:{error}",
"restored": "项目已恢复"
},
"virtual": {
"loadFailed": "加载项目失败",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "重命名文件失败:{error}",
"previewUpdated": "预览图片更新成功",
"previewUploadFailed": "上传预览图片失败",
"previewDropInvalid": "不支持的文件类型:{name}。请拖入图片或 MP4 视频。",
"refreshComplete": "{action} 完成",
"refreshFailed": "{action} {type} 失败",
"metadataRefreshed": "元数据刷新成功",
+26 -10
View File
@@ -622,6 +622,10 @@
"label": "隱藏搶先體驗更新",
"help": "搶先體驗更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
@@ -920,7 +924,9 @@
"dateAsc": "最舊",
"lorasCount": "LoRA 數量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
"lorasCountAsc": "最少",
"opened": "最近開啟",
"openedDesc": "最近開啟"
},
"refresh": {
"title": "重新整理配方列表",
@@ -931,6 +937,11 @@
"favorites": {
"title": "僅顯示收藏",
"action": "收藏"
},
"layout": {
"title": "配方版面",
"grid": "網格版面",
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
}
},
"duplicates": {
@@ -1277,13 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "釋放 {size}",
"title": "刪除模型",
"message": "您確定要刪除此模型及所有相關檔案嗎?",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "如果未復原,檔案將在 20 秒後被永久刪除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 30 秒內復原。"
"recoverableWarning": "此操作可在 20 秒內復原。"
},
"excludeModel": {
"title": "排除模型",
@@ -1548,6 +1559,8 @@
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
@@ -1557,6 +1570,7 @@
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
@@ -1726,6 +1740,7 @@
"recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗",
@@ -2119,12 +2134,12 @@
"copyFailed": "複製失敗"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "復原",
"deleted": "已刪除 {name}",
"deletedBulk": "已刪除 {count} 個項目",
"expired": "復原視窗已過期,項目已被永久刪除。",
"failed": "復原失敗:{error}",
"restored": "項目已還原"
},
"virtual": {
"loadFailed": "載入項目失敗",
@@ -2189,6 +2204,7 @@
"fileRenameFailed": "重新命名檔案失敗:{error}",
"previewUpdated": "預覽圖片已成功更新",
"previewUploadFailed": "上傳預覽圖片失敗",
"previewDropInvalid": "不支援的檔案類型:{name}。請拖入圖片或 MP4 影片。",
"refreshComplete": "{action} 完成",
"refreshFailed": "{action} {type} 失敗",
"metadataRefreshed": "metadata 已成功刷新",
@@ -214,6 +214,24 @@ class MetadataProcessor:
max_denoise = denoise
primary_sampler = sampler_info
primary_sampler_id = node_id
# Last resort: any registered sampler. Samplers without a denoise or
# add_noise parameter (e.g. multi-stage samplers like KreaTwoStageSampler)
# are not caught by the criteria above. Prefer execution order so the
# first executed sampler wins, matching the downstream_id branch.
if primary_sampler is None:
sampler_ids = [
node_id
for node_id, sampler_info in metadata.get(SAMPLING, {}).items()
if sampler_info.get(IS_SAMPLER, False)
]
if sampler_ids:
if downstream_id and "execution_order" in metadata:
for node_id in metadata["execution_order"]:
if node_id in sampler_ids:
return node_id, metadata[SAMPLING][node_id]
primary_sampler_id = sampler_ids[0]
primary_sampler = metadata[SAMPLING][sampler_ids[0]]
return primary_sampler_id, primary_sampler
+160 -20
View File
@@ -40,7 +40,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
are checked for a model file name and stored as checkpoint metadata.
* ``CONDITIONING`` output: common text input fields are checked for
prompt text and stored as prompt metadata.
prompt text, and conditioning inputs are tracked through transforms.
"""
# Input field names that carry a model path in loader-style nodes.
@@ -73,7 +73,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
_store_checkpoint_metadata(metadata, node_id, name)
return
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom)
# — CONDITIONING encoder / transform detection
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
text = None
for field in GenericNodeExtractor._TEXT_FIELDS:
@@ -81,12 +81,14 @@ class GenericNodeExtractor(NodeMetadataExtractor):
if val and isinstance(val, str) and val.strip():
text = val.strip()
break
if text:
prompt_data = metadata.setdefault(PROMPTS, {})
prompt_data[node_id] = {
"text": text,
"node_id": node_id,
}
input_conditionings = _collect_conditioning_inputs(inputs)
if text or input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
if text:
prompt_metadata["text"] = text
if input_conditionings:
prompt_metadata["orig_conditionings"] = input_conditionings
@staticmethod
def update(node_id, outputs, metadata, return_types=None):
@@ -98,11 +100,26 @@ class GenericNodeExtractor(NodeMetadataExtractor):
return
if node_id not in metadata.get(PROMPTS, {}):
return
if outputs and isinstance(outputs, list) and len(outputs) > 0:
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
cond = outputs[0][0]
if cond is not None:
metadata[PROMPTS][node_id]["conditioning"] = cond
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 1:
return
conditioning_index = _first_conditioning_index(return_types)
if conditioning_index is None or len(output_tuple) <= conditioning_index:
return
output_conditioning = output_tuple[conditioning_index]
if output_conditioning is None:
return
prompt_metadata = metadata[PROMPTS][node_id]
prompt_metadata["conditioning"] = output_conditioning
_record_conditioning_source(
metadata,
node_id,
output_conditioning,
prompt_metadata.get("orig_conditionings", []),
)
class CheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod
@@ -417,6 +434,34 @@ def _first_output_tuple(outputs):
return None
def _first_conditioning_index(return_types):
"""Return the index of the first CONDITIONING output slot, or None."""
if not return_types:
return None
for index, return_type in enumerate(return_types):
if "CONDITIONING" in str(return_type):
return index
return None
def _collect_conditioning_inputs(inputs):
"""Collect conditioning object inputs (``conditioning*`` keys).
Primitive values (None, str, int, float, bool) are excluded so scalar
fields like ``conditioning_strength`` are not mistaken for conditioning
objects during provenance tracking.
"""
if not inputs:
return []
return [
value
for input_name, value in inputs.items()
if input_name.startswith("conditioning")
and value is not None
and not isinstance(value, (str, int, float, bool))
]
def _record_conditioning_source(
metadata, node_id, output_conditioning, input_conditionings
):
@@ -429,6 +474,14 @@ def _record_conditioning_source(
if not sources:
return
# Identity-preserving selectors return one of their inputs unchanged:
# only that input contributed to the output, so record it alone instead
# of treating every input as a combination source.
for conditioning in sources:
if id(conditioning) == id(output_conditioning):
sources = [conditioning]
break
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata.setdefault("conditioning_sources", []).append(
{
@@ -508,13 +561,7 @@ class ConditioningCombineExtractor(NodeMetadataExtractor):
if not inputs:
return
input_conditionings = []
for input_name in inputs:
if (
input_name.startswith("conditioning")
and inputs[input_name] is not None
):
input_conditionings.append(inputs[input_name])
input_conditionings = _collect_conditioning_inputs(inputs)
if input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
@@ -814,6 +861,65 @@ class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtr
# Update method is inherited from TSCSamplerBaseExtractor
class KreaTwoStageSamplerExtractor(BaseSamplerExtractor):
"""Extractor for Krea Two/Three Stage Samplers (Auryg/Krea-2-Two-Stage-Sampler).
The node samples in two (or three) stages with per-stage settings
(stage1_steps/stage2_steps, stage1_cfg/stage2_cfg, ...). The canonical
metadata fields consumed by ``extract_generation_params`` (steps, cfg,
sampler_name, scheduler) are derived from the base stage (stage 1; the
three-stage variant reuses stage 1 settings for stage 3), while the full
per-stage breakdown is preserved in the raw parameters.
"""
# All per-stage parameter keys present on both node variants.
_STAGE_PARAM_KEYS = (
"stage1_steps", "stage1_cfg", "stage1_sampler_name", "stage1_scheduler",
"stage2_steps", "stage2_cfg", "stage2_sampler_name", "stage2_scheduler",
)
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
BaseSamplerExtractor.extract_sampling_params(
node_id,
inputs,
metadata,
("seed", "handoff_percent", "stage3_handoff_percent")
+ KreaTwoStageSamplerExtractor._STAGE_PARAM_KEYS,
)
# Derive the canonical fields expected by extract_generation_params.
sampling_params = metadata[SAMPLING][node_id]["parameters"]
if "stage1_steps" in sampling_params or "stage2_steps" in sampling_params:
sampling_params["steps"] = (
(sampling_params.get("stage1_steps") or 0)
+ (sampling_params.get("stage2_steps") or 0)
)
if "stage1_cfg" in sampling_params:
sampling_params["cfg"] = sampling_params["stage1_cfg"]
if "stage1_sampler_name" in sampling_params:
sampling_params["sampler_name"] = sampling_params["stage1_sampler_name"]
if "stage1_scheduler" in sampling_params:
sampling_params["scheduler"] = sampling_params["stage1_scheduler"]
BaseSamplerExtractor.extract_conditioning(node_id, inputs, metadata)
# Prefer the final generation resolution; latent dims are the fallback.
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
final_width = inputs.get("final_width")
final_height = inputs.get("final_height")
if final_width and final_height:
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": final_width,
"height": final_height,
"node_id": node_id,
}
class LoraLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -854,6 +960,37 @@ class ImageSizeExtractor(NodeMetadataExtractor):
"node_id": node_id
}
class KreaDualResolutionSelectorExtractor(NodeMetadataExtractor):
"""Extract base resolution from Krea Dual Resolution Selector outputs
(Auryg/Krea-2-Two-Stage-Sampler).
The node computes base/final dimensions at runtime from aspect ratio and
megapixel settings, so the values are only available in the update phase
(outputs: base_width, base_height, final_width, final_height, seed).
"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Dimensions are computed at runtime; nothing to do here.
pass
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 2:
return
width, height = output_tuple[0], output_tuple[1]
if not isinstance(width, int) or not isinstance(height, int):
return
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": width,
"height": height,
"node_id": node_id,
}
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
"""Extract LoRA metadata from rgthree Power Lora Loader.
@@ -1255,6 +1392,8 @@ NODE_EXTRACTORS = {
"ClownsharKSampler_Beta": SamplerExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
"KreaTwoStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
"KreaThreeStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
@@ -1306,6 +1445,7 @@ NODE_EXTRACTORS = {
"GetNode": GetNodeExtractor,
# Latent
"EmptyLatentImage": ImageSizeExtractor,
"KreaDualResolutionSelector": KreaDualResolutionSelectorExtractor, # Auryg/Krea-2-Two-Stage-Sampler
# Flux
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
+11 -2
View File
@@ -8,7 +8,7 @@ cannot drift between the two paths.
import logging
from typing import Any, Dict
from ..utils.utils import model_patcher_to_name
from ..utils.utils import model_patcher_to_name, sampler_object_to_name
from .constants import CLIP_SKIP_SENTINEL, METADATA_OVERWRITE_FIELDS
logger = logging.getLogger(__name__)
@@ -22,7 +22,9 @@ def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
of 0 is preserved. The ``model`` field accepts either a manual string or
a wired MODEL (ModelPatcher) connection; in the latter case the source
model name is extracted from the patcher's ``cached_patcher_init`` and
stored as a ComfyUI-style relative path.
stored as a ComfyUI-style relative path. The ``sampler`` field likewise
accepts a manual string or a wired SAMPLER (KSAMPLER) connection, from
which the sampler name is extracted via the sampler function's name.
"""
result: Dict[str, Any] = {}
for key in METADATA_OVERWRITE_FIELDS:
@@ -34,6 +36,13 @@ def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
"Could not extract model name from wired MODEL input "
"(no cached_patcher_init); model metadata overwrite skipped"
)
elif key == "sampler" and not isinstance(value, str):
value = sampler_object_to_name(value)
if value is None:
logger.warning(
"Could not extract sampler name from wired SAMPLER input "
"(unrecognized sampler function); sampler metadata overwrite skipped"
)
if key == "clip_skip":
if value != CLIP_SKIP_SENTINEL:
result[key] = value
+13 -3
View File
@@ -71,10 +71,18 @@ class MetadataOverwriteLM:
},
),
"sampler": (
"STRING",
"STRING,SAMPLER",
{
"default": "",
"tooltip": "Sampler name. Only overwrites when non-empty.",
"widgetType": "STRING",
"tooltip": (
"Sampler name. Fill in the name manually or "
"connect a SAMPLER output (e.g. KSamplerSelect) "
"— the sampler name is then extracted "
"automatically. Note: ddim is recorded as "
"euler (ComfyUI internal representation). "
"Only overwrites when non-empty."
),
},
),
"scheduler": (
@@ -164,6 +172,8 @@ class MetadataOverwriteLM:
The ``model`` field accepts either a manual string or a wired MODEL
(ModelPatcher) connection; in the latter case the underlying model
name is extracted from the patcher's ``cached_patcher_init`` and
stored as a ComfyUI-style relative path.
stored as a ComfyUI-style relative path. The ``sampler`` field
likewise accepts a manual string or a wired SAMPLER (KSAMPLER)
connection, from which the sampler name is extracted automatically.
"""
return (collect_overwrite_params(kwargs),)
+214
View File
@@ -0,0 +1,214 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
import folder_paths # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class RandomCheckpointLoaderLM:
"""Checkpoint Loader that can randomly pick a checkpoint from the pool
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths. When select_at_random is enabled, ignores ckpt_name
and picks a random checkpoint (optionally filtered by base_model) on
every run.
"""
NAME = "Random Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = cls._get_checkpoint_names()
base_models = cls._get_available_base_models()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore ckpt_name and pick a random checkpoint from the "
"pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "VAE", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The CLIP model used for encoding text prompts.",
"The VAE model used for encoding and decoding images to and from latent space.",
"The name of the checkpoint that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_checkpoint"
@classmethod
def IS_CHANGED(cls, ckpt_name, select_at_random=False, base_model="Any"):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return ckpt_name
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
@classmethod
def _get_checkpoint_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include checkpoints matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing checkpoints at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting checkpoint names: {e}")
return []
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
def load_checkpoint(
self,
ckpt_name: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, Any, Any, str]:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
select_at_random: If True, ignore ckpt_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, CLIP, VAE, model_name)
"""
if select_at_random:
pool = self._get_checkpoint_names(base_model)
if not pool:
raise FileNotFoundError(
f"No checkpoints found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
ckpt_name = random.choice(pool)
logger.info(
f"[RandomCheckpointLoaderLM] Randomly selected checkpoint: {ckpt_name}"
)
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
if metadata is None:
raise FileNotFoundError(
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
"Make sure the checkpoint is indexed and try again."
)
# Load regular checkpoint using ComfyUI's API
logger.info(f"Loading checkpoint from: {ckpt_path}")
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
return out[:3] + (ckpt_name,)
+326
View File
@@ -0,0 +1,326 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
def _reload_gguf_unet(
unet_path: str, weight_dtype: str, disable_dynamic: bool = False
) -> object:
"""Reload a GGUF diffusion model from disk (cached_patcher_init factory).
Mirrors the GGUF branch of RandomUNETLoaderLM.load_unet so ModelPatcher
deepclone/dynamic machinery can rebuild GGUF models with the correct
GGMLOps. ``disable_dynamic`` is accepted for signature compatibility
with core ComfyUI loaders.
"""
loader = RandomUNETLoaderLM()
model, _unet_name = loader._load_gguf_unet(unet_path, unet_path, weight_dtype)
return model
class RandomUNETLoaderLM:
"""UNET Loader that can randomly pick a diffusion model from the pool
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA
Manager's extra folder paths. Supports both regular diffusion models and
GGUF format models. When select_at_random is enabled, ignores unet_name
and picks a random diffusion model (optionally filtered by base_model)
on every run.
"""
NAME = "Random Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = cls._get_unet_names()
base_models = cls._get_available_base_models()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore unet_name and pick a random diffusion model from "
"the pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "STRING")
RETURN_NAMES = ("MODEL", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The name of the diffusion model that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_unet"
@classmethod
def IS_CHANGED(
cls, unet_name, weight_dtype, select_at_random=False, base_model="Any"
):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return unet_name
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
@classmethod
def _get_unet_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include models matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing diffusion models at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting unet names: {e}")
return []
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
def load_unet(
self,
unet_name: str,
weight_dtype: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, ...]:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
select_at_random: If True, ignore unet_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, model_name)
"""
import torch
if select_at_random:
pool = self._get_unet_names(base_model)
if not pool:
raise FileNotFoundError(
f"No diffusion models found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
unet_name = random.choice(pool)
logger.info(
f"[RandomUNETLoaderLM] Randomly selected diffusion model: {unet_name}"
)
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
if metadata is None:
raise FileNotFoundError(
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
"Make sure the model is indexed and try again."
)
# Check if it's a GGUF model
if unet_path.endswith(".gguf"):
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
# Load regular diffusion model using ComfyUI's API
logger.info(f"Loading diffusion model from: {unet_path}")
# Build model options based on weight_dtype
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e4m3fn_fast":
model_options["dtype"] = torch.float8_e4m3fn
model_options["fp8_optimizations"] = True
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
return (model, unet_name)
def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple[Any, ...]:
"""Load a GGUF format diffusion model
Args:
unet_path: Absolute path to the GGUF file
unet_name: Name of the model for error messages
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL, model_name)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
try:
loader_module, ops_module, nodes_module = get_gguf_modules()
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
GGMLOps = getattr(ops_module, "GGMLOps")
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
except RuntimeError as e:
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
try:
# Load GGUF state dict
sd, extra = gguf_sd_loader(unet_path)
# Prepare kwargs for metadata if supported
kwargs = {}
import inspect
valid_params = inspect.signature(
comfy.sd.load_diffusion_model_state_dict
).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
# Setup custom operations with GGUF support
ops = GGMLOps()
# Handle weight_dtype for GGUF models
if weight_dtype in ("default", None):
ops.Linear.dequant_dtype = None
elif weight_dtype in ["target"]:
ops.Linear.dequant_dtype = weight_dtype
else:
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
# Load the model
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}, **kwargs
)
if model is None:
raise RuntimeError(
f"Could not detect model type for GGUF diffusion model: {unet_path}"
)
# Wrap with GGUFModelPatcher
model = GGUFModelPatcher.clone(model)
# Register a reload factory so the MODEL carries its source path
# (cached_patcher_init) like core ComfyUI loaders do — required
# for model-name extraction downstream and for ModelPatcher
# deepclone/dynamic machinery.
model.cached_patcher_init = (_reload_gguf_unet, (unet_path, weight_dtype))
return (model, unet_name)
except Exception as e:
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
raise RuntimeError(
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
)
+14 -4
View File
@@ -11,7 +11,7 @@ import re
from typing import Dict, List, Any, Optional, Tuple
from abc import ABC, abstractmethod
from ..config import config
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.civitai_utils import rewrite_preview_url
logger = logging.getLogger(__name__)
@@ -155,9 +155,9 @@ class RecipeMetadataParser(ABC):
# Process file information if available
if 'files' in civitai_info:
# Find the primary model file (type="Model" and primary=true) in the files list
# Find the primary model file (weights-type and primary=true) in the files list
model_file = next((file for file in civitai_info.get('files', [])
if file.get('type') == 'Model' and file.get('primary') == True), None)
if file.get('type') in MODEL_WEIGHT_FILE_TYPES and file.get('primary') == True), None)
if model_file:
# Get size
@@ -261,11 +261,21 @@ class RecipeMetadataParser(ABC):
checkpoint['id'] = civitai_data.get('id', 0)
if 'files' in civitai_data:
# Prefer the file CivitAI marked primary; fall back to any
# weights-type file (providers without primary flags).
model_file = next(
(
file
for file in civitai_data.get('files', [])
if file.get('type') == 'Model'
if file.get('type') in MODEL_WEIGHT_FILE_TYPES
and file.get('primary') is True
),
None,
) or next(
(
file
for file in civitai_data.get('files', [])
if file.get('type') in MODEL_WEIGHT_FILE_TYPES
),
None,
)
+2 -1
View File
@@ -30,6 +30,7 @@ from ..services.websocket_progress_callback import (
WebSocketProgressCallback,
)
from ..utils.exif_utils import ExifUtils
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.metadata_manager import MetadataManager
from .model_route_registrar import COMMON_ROUTE_DEFINITIONS, ModelRouteRegistrar
from .handlers.model_handlers import (
@@ -251,7 +252,7 @@ class BaseModelRoutes(ABC):
def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
def get_handler(self, name: str) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
"""Expose handlers for subclasses or tests."""
+37 -5
View File
@@ -2535,6 +2535,7 @@ class ModelUpdateHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False
hide_paid = False
if self._settings is not None:
try:
hide_early_access = bool(
@@ -2542,12 +2543,17 @@ class ModelUpdateHandler:
)
except Exception:
pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
serialized_records = []
for record in records.values():
has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn(
hide_early_access=hide_early_access
hide_early_access=hide_early_access,
hide_paid=hide_paid,
):
serialized_records.append(self._serialize_record(record))
@@ -2701,10 +2707,16 @@ class ModelUpdateHandler:
if not record or not record.versions:
return record
# Find versions that need enrichment
# Find versions that need enrichment. Permanent paid versions are not
# early access (mirror _is_early_access_active) and never carry an end
# time, so skip them to avoid pointless per-version API calls.
versions_needing_update = []
for version in record.versions:
if version.is_early_access and not version.early_access_ends_at:
if (
version.is_early_access
and not version.early_access_ends_at
and not getattr(version, "is_paid", False)
):
versions_needing_update.append(version)
if not versions_needing_update:
@@ -2934,6 +2946,7 @@ class ModelUpdateHandler:
context = version_context or {}
# Check user setting for hiding early access versions
hide_early_access = False
hide_paid = False
if self._settings is not None:
try:
hide_early_access = bool(
@@ -2941,6 +2954,10 @@ class ModelUpdateHandler:
)
except Exception:
pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
return {
"modelType": record.model_type,
"modelId": record.model_id,
@@ -2949,7 +2966,10 @@ class ModelUpdateHandler:
"inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model,
"hasUpdate": record.has_update(hide_early_access=hide_early_access),
"hasUpdate": record.has_update(
hide_early_access=hide_early_access,
hide_paid=hide_paid,
),
"versions": [
self._serialize_version(version, context.get(version.version_id))
for version in record.versions
@@ -2968,8 +2988,11 @@ class ModelUpdateHandler:
# Determine if version is currently in early access
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag
# Mirror _is_early_access_active: permanent paid versions (no end time) are NOT early access
is_early_access = False
if version.early_access_ends_at:
if getattr(version, "is_paid", False) and not version.early_access_ends_at:
is_early_access = False
elif version.early_access_ends_at:
try:
from datetime import datetime, timezone
@@ -2984,6 +3007,13 @@ class ModelUpdateHandler:
# Fallback to basic EA flag from bulk API
is_early_access = True
paid_access_payload = None
if getattr(version, "paid_access", None):
try:
paid_access_payload = json.loads(version.paid_access)
except (TypeError, ValueError):
paid_access_payload = None
return {
"versionId": version.version_id,
"name": version.name,
@@ -2997,6 +3027,8 @@ class ModelUpdateHandler:
"earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access,
"usageControl": version.usage_control,
"isPaid": bool(getattr(version, "is_paid", False)),
"paidAccess": paid_access_payload,
"filePath": context.get("file_path"),
"fileName": context.get("file_name"),
}
+29
View File
@@ -34,6 +34,7 @@ from ...utils.civitai_utils import (
)
from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager
@@ -98,6 +99,7 @@ class RecipeHandlerSet:
"download_shared_recipe": self.sharing.download_shared_recipe,
"get_recipe_syntax": self.query.get_recipe_syntax,
"update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora,
"find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
@@ -1458,6 +1460,33 @@ class RecipeManagementHandler:
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def record_recipe_open(self, request: web.Request) -> web.Response:
"""Record that a recipe's detail modal was opened.
Lightweight fire-and-forget endpoint backing the "Recently Opened"
sort. It only writes the timestamp into the separate open-stats file
recipe JSON and EXIF are never touched.
"""
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["recipe_id"]
# Skip recording opens for recipes the scanner no longer knows.
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_json_path:
return web.json_response(
{"success": False, "error": "Recipe not found"}, status=404
)
RecipeOpenStats().record_open(recipe_id)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error("Error recording recipe open: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
+3
View File
@@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/opened", "record_recipe_open"
),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
+17 -2
View File
@@ -633,6 +633,13 @@ class BaseModelService(ABC):
except Exception:
hide_early_access = False
# Check user setting for hiding permanent paid updates
hide_paid = False
try:
hide_paid = bool(self.settings.get("hide_paid_updates", False))
except Exception:
hide_paid = False
records = None
resolved: Optional[Dict[int, bool]] = None
if same_base_mode:
@@ -641,7 +648,10 @@ class BaseModelService(ABC):
try:
records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
resolved = {
model_id: record.has_update(hide_early_access=hide_early_access)
model_id: record.has_update(
hide_early_access=hide_early_access,
hide_paid=hide_paid,
)
for model_id, record in records.items()
}
except Exception as exc:
@@ -663,6 +673,7 @@ class BaseModelService(ABC):
self.model_type,
ordered_ids,
hide_early_access=hide_early_access,
hide_paid=hide_paid,
))
except Exception as exc:
logger.error(
@@ -677,7 +688,10 @@ class BaseModelService(ABC):
if resolved is None:
tasks = [
self.update_service.has_update(
self.model_type, model_id, hide_early_access=hide_early_access
self.model_type,
model_id,
hide_early_access=hide_early_access,
hide_paid=hide_paid,
)
for model_id in ordered_ids
]
@@ -717,6 +731,7 @@ class BaseModelService(ABC):
threshold_version,
base_model,
hide_early_access=hide_early_access,
hide_paid=hide_paid,
)
else:
flag = default_flag
+9 -2
View File
@@ -21,6 +21,7 @@ from .model_metadata_provider import (
from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
logger = logging.getLogger(__name__)
@@ -538,10 +539,16 @@ class CivitaiClient:
return model_versions[0]
def _extract_primary_model_hash(self, version_entry: Dict[str, Any]) -> Optional[str]:
# Prefer the generic "Model" file (most reliable version identity);
# fall back to any other weights-type primary.
for file_info in version_entry.get("files", []):
if file_info.get("type") == "Model" and file_info.get("primary"):
hashes = file_info.get("hashes", {})
model_hash = hashes.get("SHA256")
model_hash = (file_info.get("hashes", {}) or {}).get("SHA256")
if model_hash:
return model_hash
for file_info in version_entry.get("files", []):
if file_info.get("type") in MODEL_WEIGHT_FILE_TYPES and file_info.get("primary"):
model_hash = (file_info.get("hashes", {}) or {}).get("SHA256")
if model_hash:
return model_hash
return None
+1
View File
@@ -83,6 +83,7 @@ class DownloadCoordinator:
save_dir=payload.get("model_root"),
relative_path=payload.get("relative_path", ""),
use_default_paths=payload.get("use_default_paths", False),
use_save_dir_as_root=payload.get("use_save_dir_as_root", False),
progress_callback=progress_callback,
download_id=download_id,
source=payload.get("source"),
+125 -48
View File
@@ -3,6 +3,7 @@
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import copy
import json
import logging
import os
import asyncio
@@ -18,6 +19,7 @@ from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
)
@@ -46,6 +48,11 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
)
# File types that are never the intended download target even when CivitAI
# marks them primary — configs/archives/workflows are auxiliary artifacts.
NON_DOWNLOADABLE_PRIMARY_TYPES = ("Config", "Archive", "Workflow", "Training Data")
class DownloadManager:
_instance = None
_lock = asyncio.Lock()
@@ -217,6 +224,7 @@ class DownloadManager:
download_id: str | None = None,
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
) -> Dict[str, Any]:
"""Download model from Civitai with task tracking and concurrency control
@@ -257,6 +265,7 @@ class DownloadManager:
"save_dir": save_dir,
"relative_path": relative_path,
"use_default_paths": bool(use_default_paths),
"use_save_dir_as_root": bool(use_save_dir_as_root),
"source": source,
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
"progress": 0,
@@ -287,6 +296,7 @@ class DownloadManager:
use_default_paths,
source,
file_params,
use_save_dir_as_root,
)
)
@@ -321,6 +331,7 @@ class DownloadManager:
use_default_paths: bool = False,
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
):
"""Execute download with semaphore to limit concurrency"""
# Update status to waiting
@@ -401,6 +412,7 @@ class DownloadManager:
),
source,
file_params,
use_save_dir_as_root=use_save_dir_as_root,
)
# Update status based on result
@@ -621,6 +633,7 @@ class DownloadManager:
"save_dir": info.get("save_dir"),
"relative_path": info.get("relative_path", ""),
"use_default_paths": bool(info.get("use_default_paths", False)),
"use_save_dir_as_root": bool(info.get("use_save_dir_as_root", False)),
"source": info.get("source"),
"file_params": copy.deepcopy(info.get("file_params")),
"transfer_backend": info.get("transfer_backend", "aria2"),
@@ -643,6 +656,7 @@ class DownloadManager:
"save_dir": record.get("save_dir"),
"relative_path": record.get("relative_path", ""),
"use_default_paths": bool(record.get("use_default_paths", False)),
"use_save_dir_as_root": bool(record.get("use_save_dir_as_root", False)),
"source": record.get("source"),
"file_params": copy.deepcopy(record.get("file_params")),
"progress": record.get("progress", 0),
@@ -1001,6 +1015,7 @@ class DownloadManager:
bool(restored.get("use_default_paths", False)),
restored.get("source"),
restored.get("file_params"),
bool(restored.get("use_save_dir_as_root", False)),
)
)
continue
@@ -1134,6 +1149,7 @@ class DownloadManager:
transfer_backend: str = "python",
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
) -> Dict[str, Any]:
"""Wrapper for original download_from_civitai implementation"""
try:
@@ -1362,36 +1378,41 @@ class DownloadManager:
# Handle use_default_paths
if use_default_paths:
settings_manager = get_settings_manager()
# Set save_dir based on model type
if model_type == "checkpoint":
if is_diffusion_model:
default_path = settings_manager.get("default_unet_root")
error_msg = "Default unet root path not set in settings"
else:
default_path = settings_manager.get("default_checkpoint_root")
error_msg = "Default checkpoint root path not set in settings"
if not default_path:
return {
"success": False,
"error": error_msg,
}
save_dir = default_path
elif model_type == "lora":
default_path = settings_manager.get("default_lora_root")
if not default_path:
return {
"success": False,
"error": "Default lora root path not set in settings",
}
save_dir = default_path
elif model_type == "embedding":
default_path = settings_manager.get("default_embedding_root")
if not default_path:
return {
"success": False,
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
# With use_save_dir_as_root, an explicitly provided save_dir is kept
# as the base root and the path template is resolved underneath it.
# Otherwise fall back to the configured default root, which keeps the
# classic "download to default root" behavior for regular downloads.
if not save_dir or not use_save_dir_as_root:
# Set save_dir based on model type
if model_type == "checkpoint":
if is_diffusion_model:
default_path = settings_manager.get("default_unet_root")
error_msg = "Default unet root path not set in settings"
else:
default_path = settings_manager.get("default_checkpoint_root")
error_msg = "Default checkpoint root path not set in settings"
if not default_path:
return {
"success": False,
"error": error_msg,
}
save_dir = default_path
elif model_type == "lora":
default_path = settings_manager.get("default_lora_root")
if not default_path:
return {
"success": False,
"error": "Default lora root path not set in settings",
}
save_dir = default_path
elif model_type == "embedding":
default_path = settings_manager.get("default_embedding_root")
if not default_path:
return {
"success": False,
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -1414,24 +1435,48 @@ class DownloadManager:
# Create directory if it doesn't exist
os.makedirs(save_dir, exist_ok=True)
# Check if this is an early access model
if version_info.get("earlyAccessEndsAt"):
early_access_date = version_info.get("earlyAccessEndsAt", "")
# Convert to a readable date if possible
# Check if this is a paid or early access model
paid_access = version_info.get("paidAccess")
if isinstance(paid_access, str):
# Some providers (e.g. CivArchive fallback) carry the DTO as JSON text
try:
from datetime import datetime
date_obj = datetime.fromisoformat(
early_access_date.replace("Z", "+00:00")
)
formatted_date = date_obj.strftime("%Y-%m-%d")
parsed = json.loads(paid_access)
paid_access = parsed if isinstance(parsed, dict) else None
except (TypeError, ValueError):
paid_access = None
if not isinstance(paid_access, dict):
paid_access = None
# An empty DTO ({"permanent": false, "endsAt": null}) is not a gate
if paid_access and not paid_access.get("permanent") and not paid_access.get("endsAt"):
paid_access = None
if version_info.get("earlyAccessEndsAt") or paid_access:
permanent_paid = bool(paid_access.get("permanent")) if paid_access else False
if permanent_paid:
early_access_msg = (
f"This model requires payment (until {formatted_date}). "
"This model requires payment. Please ensure you have "
"purchased access and are logged in to Civitai."
)
except:
early_access_msg = "This model requires payment. "
else:
early_access_date = version_info.get("earlyAccessEndsAt")
if not early_access_date and paid_access:
early_access_date = paid_access.get("endsAt")
if not early_access_date:
early_access_date = ""
# Convert to a readable date if possible
try:
from datetime import datetime
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
date_obj = datetime.fromisoformat(
early_access_date.replace("Z", "+00:00")
)
formatted_date = date_obj.strftime("%Y-%m-%d")
early_access_msg = (
f"This model requires payment (until {formatted_date}). "
)
except Exception:
early_access_msg = "This model requires payment. "
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
logger.warning(
f"Early access model detected: {version_info.get('name', 'Unknown')}"
)
@@ -1486,7 +1531,7 @@ class DownloadManager:
f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
),
None,
)
@@ -1526,21 +1571,52 @@ class DownloadManager:
# Fallback to primary file if no match found
if not file_info:
logger.debug("[download] Looking for primary file as fallback")
# Prefer a weights-type file CivitAI marked primary; then any
# weights-type file (providers without primary flags, e.g.
# civarchive); then trust CivitAI's primary flag regardless of
# type — newer types like 'Enhancement LoRA' are valid primary
# files. Weights files are preferred over non-weights primary
# files so a Config/Archive primary never replaces a Model.
file_info = next(
(
f
for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
if f.get("primary") and f.get("type") in MODEL_WEIGHT_FILE_TYPES
),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected: id=%s, name=%s",
"[download] Fallback primary file selected (primary + weights): id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
else:
logger.debug("[download] No primary file found in fallback lookup")
file_info = next(
(f for f in files if f.get("type") in MODEL_WEIGHT_FILE_TYPES),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected (weights type, no primary flag): id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
else:
file_info = next(
(
f
for f in files
if f.get("primary")
and f.get("type") not in NON_DOWNLOADABLE_PRIMARY_TYPES
),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected (trusting CivitAI primary flag): id=%s, name=%s, type=%s",
file_info.get("id"), file_info.get("name"), file_info.get("type"),
)
else:
logger.debug("[download] No primary file found in fallback lookup")
if not file_info:
return {"success": False, "error": "No suitable file found in metadata"}
@@ -2761,6 +2837,7 @@ class DownloadManager:
bool(persisted.get("use_default_paths", False)),
persisted.get("source"),
persisted.get("file_params"),
bool(persisted.get("use_save_dir_as_root", False)),
),
)
except Exception as exc:
+121 -7
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
@@ -74,6 +75,8 @@ class ModelVersionRecord:
sort_index: int = 0
is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
paid_access: Optional[str] = None # JSON string of the CivitAI paidAccess DTO
is_paid: bool = False # True when paidAccess.permanent is True (permanent paid gate)
@dataclass
@@ -107,13 +110,17 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library]
def has_update(
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
self,
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
hide_paid: bool = False,
) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available.
Args:
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
hide_paid: If True, exclude permanent paid versions from update check.
"""
if self.should_ignore_model:
@@ -129,6 +136,7 @@ class ModelUpdateRecord:
not version.is_in_library
and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
and not (hide_paid and version.is_paid)
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions
)
@@ -138,6 +146,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
if version.version_id > max_in_library:
@@ -152,6 +162,11 @@ class ModelUpdateRecord:
1. If exact EA end time available (from single version API), use it for precise check
2. Otherwise fallback to basic EA flag (from bulk API)
"""
# Permanent paid versions are not early access; they are filtered by
# hide_paid instead. Only timed gates count as early access.
if version.is_paid and not version.early_access_ends_at:
return False
# Phase 2: Precise check with exact end time
if version.early_access_ends_at:
try:
@@ -178,6 +193,7 @@ class ModelUpdateRecord:
local_base_model: Optional[str],
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
hide_paid: bool = False,
) -> bool:
"""Return True when a newer remote version with the same base model exists.
@@ -186,6 +202,7 @@ class ModelUpdateRecord:
local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
hide_paid: If True, exclude permanent paid versions from update check.
"""
if self.should_ignore_model:
@@ -216,6 +233,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
version_base = _normalize_base_model(version.base_model)
@@ -252,6 +271,8 @@ class ModelUpdateService:
is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
);
@@ -491,6 +512,14 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT"
),
"paid_access": (
"ALTER TABLE model_update_versions "
"ADD COLUMN paid_access TEXT"
),
"is_paid": (
"ALTER TABLE model_update_versions "
"ADD COLUMN is_paid INTEGER NOT NULL DEFAULT 0"
),
}
for column, statement in migrations.items():
@@ -592,6 +621,8 @@ class ModelUpdateService:
should_ignore INTEGER NOT NULL DEFAULT 0,
early_access_ends_at TEXT,
is_early_access INTEGER NOT NULL DEFAULT 0,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
)
@@ -611,6 +642,8 @@ class ModelUpdateService:
"should_ignore",
"early_access_ends_at",
"is_early_access",
"paid_access",
"is_paid",
]
defaults = {
"sort_index": "0",
@@ -623,6 +656,8 @@ class ModelUpdateService:
"should_ignore": "0",
"early_access_ends_at": "NULL",
"is_early_access": "0",
"paid_access": "NULL",
"is_paid": "0",
}
select_parts = []
@@ -936,17 +971,30 @@ class ModelUpdateService:
async with self._lock:
return self._get_record(model_type, model_id)
async def has_update(self, model_type: str, model_id: int, hide_early_access: bool = False) -> bool:
async def has_update(
self,
model_type: str,
model_id: int,
hide_early_access: bool = False,
hide_paid: bool = False,
) -> bool:
"""Determine if a model has updates pending."""
record = await self.get_record(model_type, model_id)
return record.has_update(hide_early_access=hide_early_access) if record else False
return (
record.has_update(
hide_early_access=hide_early_access, hide_paid=hide_paid
)
if record
else False
)
async def has_updates_bulk(
self,
model_type: str,
model_ids: Sequence[int],
hide_early_access: bool = False,
hide_paid: bool = False,
) -> Dict[int, bool]:
"""Return update availability for each model id in a single database pass."""
@@ -959,7 +1007,9 @@ class ModelUpdateService:
return {
model_id: (
records[model_id].has_update(hide_early_access=hide_early_access)
records[model_id].has_update(
hide_early_access=hide_early_access, hide_paid=hide_paid
)
if model_id in records
else False
)
@@ -1190,6 +1240,7 @@ class ModelUpdateService:
"earlyAccessEndsAt": _normalize_string(
entry.get("earlyAccessEndsAt")
),
"paidAccess": entry.get("paidAccess"),
}
except RateLimitError:
raise
@@ -1214,6 +1265,17 @@ class ModelUpdateService:
"earlyAccessEndsAt"
):
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
# Only backfill when the model-level response carries no *active*
# paidAccess signal: a present-but-empty DTO (e.g.
# {"permanent": false, "endsAt": null}) would otherwise block
# the authoritative by-hash data.
extra_paid = ModelUpdateService._normalize_paid_access(
extra.get("paidAccess")
)
if extra_paid and not ModelUpdateService._normalize_paid_access(
version.get("paidAccess")
):
version["paidAccess"] = extra["paidAccess"]
@staticmethod
def _collect_hashes_from_response(response: Mapping[str, Any]) -> Dict[int, str]:
@@ -1464,6 +1526,8 @@ class ModelUpdateService:
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control,
paid_access=remote_version.paid_access,
is_paid=remote_version.is_paid,
)
)
@@ -1564,6 +1628,18 @@ class ModelUpdateService:
is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl"))
# CivitAI's paidAccess DTO ({"permanent": bool, "endsAt": ISO|null})
# gates versions behind a paid tier while availability stays "Public".
paid_access = self._normalize_paid_access(entry.get("paidAccess"))
paid_access_json = json.dumps(paid_access) if paid_access else None
is_paid = bool(paid_access.get("permanent")) if paid_access else False
if early_access_ends_at is None and paid_access and paid_access.get("endsAt"):
early_access_ends_at = _normalize_string(paid_access.get("endsAt"))
# Only timed gates are early access; permanent paid versions are not
# (consumers filter them via is_paid), so the stored flag stays accurate.
if not is_early_access and paid_access and paid_access.get("endsAt"):
is_early_access = True
return ModelVersionRecord(
version_id=version_id,
name=name,
@@ -1577,8 +1653,36 @@ class ModelUpdateService:
sort_index=index,
is_early_access=is_early_access,
usage_control=usage_control,
paid_access=paid_access_json,
is_paid=is_paid,
)
@staticmethod
def _normalize_paid_access(value) -> Optional[Dict[str, Any]]:
"""Normalize a CivitAI ``paidAccess`` DTO into a mapping.
Accepts a dict, None, or a JSON string (as carried by the by-hash
enrichment path) and returns ``{"permanent": bool, "endsAt": str|None}``
or None when the input carries no paid-access signal.
"""
if value is None:
return None
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
value = parsed
if not isinstance(value, Mapping):
return None
permanent = bool(value.get("permanent"))
ends_at = _normalize_string(value.get("endsAt"))
if not permanent and ends_at is None:
return None
return {"permanent": permanent, "endsAt": ends_at}
def _extract_size_bytes(self, files) -> Optional[int]:
if not isinstance(files, Iterable):
return None
@@ -1691,7 +1795,7 @@ class ModelUpdateService:
f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control
is_early_access, usage_control, paid_access, is_paid
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1720,6 +1824,8 @@ class ModelUpdateService:
sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"],
paid_access=row["paid_access"],
is_paid=bool(row["is_paid"]),
)
)
@@ -1771,13 +1877,19 @@ class ModelUpdateService:
(record.model_id,),
)
for version in record.versions:
paid_access_value = (
version.paid_access
if version.paid_access is None
or isinstance(version.paid_access, str)
else json.dumps(version.paid_access)
)
conn.execute(
"""
INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_early_access, usage_control, paid_access, is_paid
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -1793,6 +1905,8 @@ class ModelUpdateService:
version.early_access_ends_at,
1 if version.is_early_access else 0,
version.usage_control,
paid_access_value,
1 if version.is_paid else 0,
),
)
conn.commit()
+1 -1
View File
@@ -43,7 +43,7 @@ from ..utils import settings_paths
logger = logging.getLogger(__name__)
# Undo window in seconds before a staged batch becomes purge-eligible.
PENDING_DELETE_TTL_SECONDS = 30
PENDING_DELETE_TTL_SECONDS = 20
# Hidden staging directory name placed inside each deleted model's own folder
# (sibling of the model artifacts) and under the settings dir for recipes.
PENDING_DELETE_DIR_NAME = ".lm-pending-delete"
+253 -48
View File
@@ -8,11 +8,13 @@ import asyncio
import json
import logging
import os
import random
import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
from ..utils.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats
from .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from natsort import natsorted
@@ -34,6 +36,11 @@ logger = logging.getLogger(__name__)
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
# Known weight-file extensions stripped by _normalize_filename_key. Names are
# stored extensionless on both sides, so splitext would misread dotted stems
# ("my.mix" -> "my") and silently collide distinct models.
_WEIGHT_FILE_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin", ".safebin", ".sft")
class RecipeScanner:
"""Service for scanning and managing recipe images"""
@@ -114,6 +121,12 @@ class RecipeScanner:
self._rematch_autov3_cache: dict[str, dict[str, Any]] | None = None
self._rematch_autov3_versions: tuple[int, int] | None = None
self._rematch_autov3_lock = asyncio.Lock()
# Normalized filename -> [items] map for the L4 rematch fallback,
# rebuilt only when either model scanner's cache_version changes.
# Mirrors the build_local_hash_cache version pattern.
self._local_filename_cache: dict[str, list[dict[str, Any]]] | None = None
self._local_filename_cache_versions: tuple[int, int] | None = None
self._local_filename_cache_lock = asyncio.Lock()
self._initialized = True
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
@@ -160,6 +173,70 @@ class RecipeScanner:
self._local_hash_cache_versions = versions
return cache
@staticmethod
def _normalize_filename_key(name: str) -> str:
"""Normalize a file name to a lookup key (basename, lowercase).
Only known weight-file extensions are stripped names are stored
extensionless on both sides, so splitext would misread dotted stems
("my.mix" -> "my") and collide distinct models.
"""
if not name:
return ""
basename = os.path.basename(name.replace("\\", "/"))
lower = basename.lower()
for ext in _WEIGHT_FILE_EXTS:
if lower.endswith(ext):
basename = basename[: -len(ext)]
break
return basename.strip().lower()
async def _build_local_filename_cache(self) -> dict[str, list[dict[str, Any]]]:
"""Build a version-cached map of normalized file names to local items.
Keys are lowercase basenames without extension. Values are lists of
items (lora + checkpoint, type-blind) sharing that name. Only items
with a sha256 are indexed matching a pending or failed download
(empty sha256) would leave the entry without a usable hash. The dict
is reused while both scanners' cache_version values are unchanged;
concurrent callers share a single build via the lock.
"""
async with self._local_filename_cache_lock:
lora_scanner = self._lora_scanner
checkpoint_scanner = self._checkpoint_scanner
versions = (
lora_scanner.cache_version if lora_scanner is not None else 0,
checkpoint_scanner.cache_version
if checkpoint_scanner is not None
else 0,
)
if (
self._local_filename_cache is not None
and self._local_filename_cache_versions == versions
):
return self._local_filename_cache
cache: dict[str, list[dict[str, Any]]] = {}
for scanner in (lora_scanner, checkpoint_scanner):
if scanner is None:
continue
data = await scanner.get_cached_data()
for item in data.raw_data:
if not isinstance(item, dict):
continue
if not (item.get("sha256") or "").lower():
continue
file_path = item.get("file_path") or ""
file_name = item.get("file_name") or ""
key = self._normalize_filename_key(file_name or file_path)
if not key:
continue
cache.setdefault(key, []).append(item)
self._local_filename_cache = cache
self._local_filename_cache_versions = versions
return cache
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
"""Return True when a recipe entry is eligible for local re-matching."""
if not isinstance(entry, dict):
@@ -168,7 +245,10 @@ class RecipeScanner:
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
)
has_identifier = (
entry.get("hash") or entry.get("modelVersionId") or entry.get("id")
entry.get("hash")
or entry.get("modelVersionId")
or entry.get("id")
or entry.get("file_name")
)
return bool(unresolved and has_identifier)
@@ -219,6 +299,97 @@ class RecipeScanner:
self._rematch_autov3_versions = versions
return cache
def _is_type_compatible(self, item: dict[str, Any], *, is_checkpoint: bool) -> bool:
"""Return True when a local item's type matches the entry kind.
The L1 hash cache and the L4 filename cache merge lora and checkpoint
items and are type-blind, so a match must be verified against the
entry kind before it is accepted.
"""
sub_type = (item.get("sub_type") or "").lower()
if sub_type:
valid = (
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
)
return sub_type in valid
civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {}
).get("type", "")
if civitai_type:
normalized = civitai_type.lower()
if is_checkpoint:
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
normalized, normalized
)
valid = VALID_CHECKPOINT_SUB_TYPES
else:
valid = VALID_LORA_TYPES
return normalized in valid
return True
@staticmethod
def _has_positive_type_evidence(item: dict[str, Any]) -> bool:
"""Return True when the item carries an explicit type marker.
Lora raw items rarely carry ``sub_type`` (it is only written when
metadata provides it), while checkpoint items always do so for
checkpoint slots a type-less candidate is a red flag, not the norm.
"""
if (item.get("sub_type") or "").lower():
return True
civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {}
).get("type", "")
return bool(civitai_type)
def _match_rematch_entry_filename(
self,
entry: dict[str, Any],
recipe_base_model: Optional[str],
filename_cache: dict[str, list[dict[str, Any]]],
*,
is_checkpoint: bool,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""Match a recipe entry against local models by file name (L4).
Conservative fallback used only after the hash (L1), version-index
(L2) and computed-autov3 (L3) tiers all failed. Candidates share the
entry's normalized file name; a candidate is accepted only when BOTH
the recipe base model and the candidate's base model are known and
equal (unknown on either side rejects never guess on missing
metadata), the type gate passes, and exactly one candidate survives
(ambiguity is a miss). Checkpoint slots additionally require positive
type evidence: lora raw items often lack ``sub_type`` while
checkpoints always carry it, so a type-less candidate is a red flag
there an unknown-type lora must not be bound into a checkpoint
slot.
Returns:
Tuple of (matched item, "L4") or ``(None, None)``.
"""
entry_name = self._normalize_filename_key(entry.get("file_name") or "")
if not entry_name:
return (None, None)
recipe_base = (recipe_base_model or "").strip().lower()
matched: list[dict[str, Any]] = []
for candidate in filename_cache.get(entry_name, []):
candidate_base = (candidate.get("base_model") or "").strip().lower()
if not recipe_base or not candidate_base:
continue
if recipe_base != candidate_base:
continue
if is_checkpoint and not self._has_positive_type_evidence(candidate):
continue
if not self._is_type_compatible(candidate, is_checkpoint=is_checkpoint):
continue
matched.append(candidate)
if len(matched) != 1:
return (None, None)
return (matched[0], "L4")
async def _match_rematch_entry(
self,
entry: dict[str, Any],
@@ -245,19 +416,23 @@ class RecipeScanner:
autov3_cache: dict[str, Any],
*,
is_checkpoint: bool,
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
recipe_base_model: Optional[str] = None,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""Match a recipe entry against local models across three levels.
"""Match a recipe entry against local models across four levels.
L1 looks the stored hash up in the type-blind local hash cache; L2
falls back to the version index via ``modelVersionId`` or ``id``; L3
resolves 12-char hashes through the computed AutoV3 cache. Matched
items are type-verified against the entry kind before being returned.
resolves 12-char hashes through the computed AutoV3 cache; L4
(conservative) falls back to the file name when a filename cache is
provided. Matched items are type-verified against the entry kind
before being returned.
Returns:
Tuple of (matched item, match level) where level is "L1", "L2" or
"L3" or ``(None, None)`` when no usable match exists. A missing
local match is an expected outcome (the model may simply not be
present locally), not an error.
Tuple of (matched item, match level) where level is "L1", "L2",
"L3" or "L4" or ``(None, None)`` when no usable match exists. A
missing local match is an expected outcome (the model may simply
not be present locally), not an error.
"""
entry_hash = (entry.get("hash") or "").lower()
@@ -277,33 +452,20 @@ class RecipeScanner:
item = autov3_cache.get(entry_hash)
level = "L3" if item is not None else None
if item is None and filename_cache is not None:
item, level = self._match_rematch_entry_filename(
entry,
recipe_base_model,
filename_cache,
is_checkpoint=is_checkpoint,
)
level = "L4" if item is not None else None
if item is None:
return (None, None)
# Type gate: the L1 cache merges lora and checkpoint items and is
# type-blind, so a match must be verified against the entry kind.
sub_type = (item.get("sub_type") or "").lower()
if sub_type:
valid = (
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
)
if sub_type not in valid:
return (None, None)
else:
civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {}
).get("type", "")
if civitai_type:
normalized = civitai_type.lower()
if is_checkpoint:
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
normalized, normalized
)
valid = VALID_CHECKPOINT_SUB_TYPES
else:
valid = VALID_LORA_TYPES
if normalized not in valid:
return (None, None)
if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
return (None, None)
return (item, level)
@@ -615,10 +777,11 @@ class RecipeScanner:
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built
BEFORE acquiring the mutation lock both are read-only snapshots and
the version-cached hash dict would otherwise rebuild mid-run if a scan
bumps a scanner's cache_version while we hold the lock.
Match snapshots (local hash cache, computed autov3 cache, filename
cache) are built BEFORE acquiring the mutation lock all three are
read-only snapshots and the version-cached dicts would otherwise
rebuild mid-run if a scan bumps a scanner's cache_version while we
hold the lock.
Args:
recipe_id: ID of the recipe to rematch
@@ -634,6 +797,7 @@ class RecipeScanner:
"""
local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache()
filename_cache = await self._build_local_filename_cache()
async with self._mutation_lock:
# Get raw recipe from cache directly to avoid formatted fields
@@ -647,7 +811,7 @@ class RecipeScanner:
try:
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache
recipe, local_cache, autov3_cache, filename_cache
)
except RecipePersistenceError as exc:
logger.error(
@@ -704,6 +868,7 @@ class RecipeScanner:
recipe: Dict[str, Any],
local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]],
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models.
@@ -717,6 +882,8 @@ class RecipeScanner:
recipe: The recipe dictionary to rematch (modified in-place)
local_cache: L1 hash cache snapshot (build_local_hash_cache)
autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback
Returns:
Tuple of (rematched_entries, errors, details). The errors element
@@ -742,7 +909,13 @@ class RecipeScanner:
if not self._is_rematch_candidate(entry):
continue
item, level = await self._match_rematch_entry_with_level(
entry, local_cache, autov3_cache, is_checkpoint=False
entry,
local_cache,
autov3_cache,
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model=entry.get("baseModel")
or recipe.get("base_model"),
)
if item is None:
details["unresolved"].append(
@@ -768,7 +941,13 @@ class RecipeScanner:
if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level(
checkpoint, local_cache, autov3_cache, is_checkpoint=True
checkpoint,
local_cache,
autov3_cache,
is_checkpoint=True,
filename_cache=filename_cache,
recipe_base_model=checkpoint.get("baseModel")
or recipe.get("base_model"),
)
if item is None:
details["unresolved"].append(
@@ -830,12 +1009,13 @@ class RecipeScanner:
) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built
ONCE before the loop both are read-only and the version-cached hash
dict would otherwise rebuild mid-run if a scan bumps a scanner's
cache_version while the mutation lock is held. ``_schedule_resort`` is
called exactly once after the loop: it spawns an asyncio task per call,
so per-recipe calls would race one resort task per recipe.
Match snapshots (local hash cache, computed autov3 cache, filename
cache) are built ONCE before the loop all three are read-only and
the version-cached dicts would otherwise rebuild mid-run if a scan
bumps a scanner's cache_version while the mutation lock is held.
``_schedule_resort`` is called exactly once after the loop: it spawns
an asyncio task per call, so per-recipe calls would race one resort
task per recipe.
Args:
progress_callback: Optional callback for progress updates
@@ -856,6 +1036,7 @@ class RecipeScanner:
# Match snapshots built once and shared by every recipe in the loop.
local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache()
filename_cache = await self._build_local_filename_cache()
async with self._mutation_lock:
cache = await self.get_cached_data()
@@ -923,7 +1104,7 @@ class RecipeScanner:
)
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache
recipe, local_cache, autov3_cache, filename_cache
)
if rematched > 0:
matched_recipes += 1
@@ -2781,7 +2962,11 @@ class RecipeScanner:
Args:
page: Current page number (1-based)
page_size: Number of items per page
sort_by: Sort method ('name' or 'date')
sort_by: Sort method ('name', 'date', 'loras_count', 'opened',
or 'random' with an optional seed like 'random:abc123'; the
part after 'random:' is the shuffle seed, not a direction).
'opened' hides recipes that were never opened it is a
"recently opened" view, not a plain reorder
search: Search term
filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply
@@ -2962,7 +3147,7 @@ class RecipeScanner:
]
# Apply sorting if not already handled by pre-sorted cache
if ":" in sort_by or sort_field == "loras_count":
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
field, order = (sort_by.split(":") + ["desc"])[:2]
reverse = order.lower() == "desc"
@@ -2981,10 +3166,30 @@ class RecipeScanner:
),
reverse=reverse,
)
elif field == "opened":
# "Recently Opened" view: recipes never opened are hidden.
# The open stats live outside recipe metadata; see
# RecipeOpenStats.
opened_map = RecipeOpenStats().get_opened_map()
filtered_data = [
item
for item in filtered_data
if opened_map.get(str(item.get("id", ""))) is not None
]
filtered_data.sort(
key=lambda x: opened_map.get(str(x.get("id", "")), 0),
reverse=reverse,
)
elif field == "loras_count":
filtered_data.sort(
key=lambda x: len(x.get("loras", [])), reverse=reverse
)
elif field == "random":
# Seeded random shuffle: same seed -> same order (stable
# pagination across requests), matching the model pages.
seed = order if order.lower() not in ("asc", "desc") else None
rng = random.Random(seed or "random")
rng.shuffle(filtered_data)
# Calculate pagination
total_items = len(filtered_data)
+14
View File
@@ -62,6 +62,20 @@ MODEL_FILE_EXTENSIONS = {
".gguf",
}
# CivitAI ModelFile.type values eligible as the main download file.
# Mirrors CivitAI's getPrimaryFile() (model-helpers.ts): weight types are
# preferred, but any file CivitAI marks `primary` is accepted — newer types
# like 'Enhancement LoRA' (Anima/AIR image-editing LoRAs) are valid primary
# files despite not being in the traditional weights allowlist.
MODEL_WEIGHT_FILE_TYPES = (
"Model",
"Pruned Model",
"Negative",
"UNet",
"Diffusion Model",
"Enhancement LoRA",
)
# Valid sub-types for each scanner type
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
+161
View File
@@ -0,0 +1,161 @@
"""Track recipe modal open timestamps for the "Recently Opened" sort.
The data is deliberately kept OUTSIDE the recipe metadata files: recording an
open must be cheap and must never rewrite recipe JSON or EXIF (which the
generic metadata update path does). A tiny JSON map of
``recipe_id -> unix timestamp`` lives under
``{settings_dir}/stats/recipe_last_opened.json`` and is written atomically on
a short debounce.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from ..utils.settings_paths import get_settings_dir
logger = logging.getLogger(__name__)
class RecipeOpenStats:
"""Persist the last time each recipe was opened in the recipe modal."""
STATS_FILENAME: str = "recipe_last_opened.json"
SAVE_DELAY: float = 1.0 # seconds of debounce between consecutive writes
_instance: "RecipeOpenStats | None" = None
_opened: dict[str, float]
_file_mtime: float | None
_dirty: bool
_lock: asyncio.Lock
_save_task: "asyncio.Task[None] | None"
_stats_file_path: str
_initialized: bool
def __new__(cls) -> "RecipeOpenStats":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if getattr(self, "_initialized", False):
return
self._opened = {}
self._file_mtime = None
self._dirty = False
self._lock = asyncio.Lock()
self._save_task = None
self._stats_file_path = self._get_stats_file_path()
self._load_stats()
self._initialized = True
def _get_stats_file_path(self) -> str:
settings_dir = get_settings_dir(create=True)
return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
def _load_stats(self) -> None:
"""Load the opened map from disk, tolerating corrupt/absent files.
The mtime is recorded even when parsing fails so a corrupt file is
not re-read (and re-logged) on every lookup.
"""
if not os.path.exists(self._stats_file_path):
return
try:
mtime = os.path.getmtime(self._stats_file_path)
except OSError:
return
try:
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
raw = json.load(file_obj)
if isinstance(raw, dict):
self._opened = {
str(key): float(value)
for key, value in raw.items()
if isinstance(value, (int, float))
}
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error loading recipe open stats: %s", exc)
self._opened = {}
self._file_mtime = mtime
def get_opened_map(self) -> dict[str, float]:
"""Return a copy of ``recipe_id -> last opened timestamp``.
Refreshes from disk when the file changed since the last load so a
second server process (or manual edit) is picked up without restart.
"""
try:
if os.path.exists(self._stats_file_path):
mtime = os.path.getmtime(self._stats_file_path)
if self._file_mtime is None or mtime != self._file_mtime:
self._load_stats()
except OSError:
pass
return dict(self._opened)
def record_open(self, recipe_id: str) -> None:
"""Mark a recipe as opened now; persists shortly in the background."""
if not recipe_id:
return
self._opened[str(recipe_id)] = time.time()
self._dirty = True
if self._save_task is None or self._save_task.done():
self._save_task = asyncio.create_task(self._delayed_save())
async def _delayed_save(self) -> None:
"""Debounced writer: batches rapid consecutive opens into one write."""
await asyncio.sleep(self.SAVE_DELAY)
_ = await self.save_stats()
async def save_stats(self, force: bool = False) -> bool:
"""Persist the opened map atomically if dirty (or when forced).
The on-disk map is merged in first so a second process sharing the
settings dir does not lose its entries; the larger timestamp wins
per recipe.
"""
if not force and not self._dirty:
return False
async with self._lock:
if not force and not self._dirty:
return False
try:
merged = self._merge_with_disk()
os.makedirs(os.path.dirname(self._stats_file_path), exist_ok=True)
temp_path = f"{self._stats_file_path}.tmp"
with open(temp_path, "w", encoding="utf-8") as file_obj:
json.dump(merged, file_obj, indent=2)
os.replace(temp_path, self._stats_file_path)
self._opened = merged
self._file_mtime = os.path.getmtime(self._stats_file_path)
self._dirty = False
return True
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error saving recipe open stats: %s", exc, exc_info=True)
return False
def _merge_with_disk(self) -> dict[str, float]:
"""Merge the in-memory map with the current on-disk map."""
disk: dict[str, float] = {}
try:
if os.path.exists(self._stats_file_path):
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
raw = json.load(file_obj)
if isinstance(raw, dict):
disk = {
str(key): float(value)
for key, value in raw.items()
if isinstance(value, (int, float))
}
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error reading recipe open stats for merge: %s", exc)
merged = dict(disk)
for key, value in self._opened.items():
merged[key] = max(value, disk.get(key, 0.0))
return merged
+36
View File
@@ -323,6 +323,42 @@ def model_patcher_to_name(model_patcher: Any) -> Optional[str]:
return _abs_model_path_to_name(abs_path)
def sampler_object_to_name(sampler: Any) -> Optional[str]:
"""Extract a ComfyUI-style sampler name from a SAMPLER (KSAMPLER) object.
Standard outputs (KSamplerSelect, most built-in sampler nodes) round-trip
losslessly via the underlying sampler function's ``__name__``
(``sample_euler`` -> ``euler``). A few edge cases need special-casing
because the function name diverges from the ``SAMPLER_NAMES`` entry:
- ``dpm_fast`` / ``dpm_adaptive`` are local closures inside
``comfy.samplers.ksampler`` (``dpm_fast_function`` / ``dpm_adaptive_function``)
- ``uni_pc`` / ``uni_pc_bh2`` use ``sample_unipc`` / ``sample_unipc_bh2``
``ddim`` is constructed by ComfyUI as ``euler`` with random inpaint, so
the original ``ddim`` name is unrecoverable (extracts as ``euler``).
Custom sampler nodes that pass non-``sample_*`` functions return None.
Returns None when the name cannot be recovered.
"""
sampler_function = getattr(sampler, "sampler_function", None)
func_name = getattr(sampler_function, "__name__", None)
if not isinstance(func_name, str) or not func_name:
return None
if func_name == "dpm_fast_function":
return "dpm_fast"
if func_name == "dpm_adaptive_function":
return "dpm_adaptive"
if func_name.startswith("sample_"):
name = func_name[len("sample_"):]
if name == "unipc":
return "uni_pc"
if name == "unipc_bh2":
return "uni_pc_bh2"
return name or None
return None
def _abs_model_path_to_name(abs_path: str) -> str:
"""Convert an absolute model path to a ComfyUI-style relative name.
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.0"
version = "1.2.1"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
+5
View File
@@ -41,6 +41,11 @@
border-color: var(--lora-accent);
}
.model-card.drag-over {
outline: 2px dashed var(--lora-accent);
outline-offset: -2px;
}
.model-card:focus-visible {
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
@@ -447,6 +447,19 @@
border-color: color-mix(in oklch, #F59F00 45%, transparent);
}
/* Paid badge - violet tone (#845EF7) to distinguish from early-access amber */
.version-badge-paid {
background: color-mix(in oklch, #845EF7 25%, transparent);
color: #7048E8;
border-color: color-mix(in oklch, #845EF7 55%, transparent);
}
[data-theme="dark"] .version-badge-paid {
background: color-mix(in oklch, #845EF7 20%, transparent);
color: #9775FA;
border-color: color-mix(in oklch, #845EF7 45%, transparent);
}
.version-meta-ea {
color: #E67700;
font-weight: 600;
@@ -911,6 +911,93 @@
outline: none;
}
/* Recipes layout segmented control with visual previews */
.layout-options-control {
width: 100%;
display: flex;
justify-content: flex-end;
}
.layout-options {
display: flex;
gap: 6px;
width: 100%;
}
.layout-option {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px;
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.layout-option:hover,
.layout-option:focus-visible {
border-color: var(--lora-accent);
outline: none;
}
.layout-option.active {
border-color: var(--lora-accent);
background-color: rgba(from var(--lora-accent) r g b / 0.12);
color: var(--lora-accent);
}
.layout-option-label {
font-size: 0.85em;
white-space: nowrap;
}
.layout-option-preview {
width: 72px;
height: 44px;
padding: 4px;
border-radius: var(--border-radius-xs);
background-color: var(--card-bg);
border: 1px solid var(--border-color);
box-sizing: border-box;
}
.layout-option-preview span {
background: currentColor;
opacity: 0.4;
border-radius: 1px;
}
.layout-preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 3px;
}
.layout-preview-masonry {
display: flex;
gap: 3px;
align-items: flex-start;
}
.layout-preview-masonry span {
flex: 1;
height: 100%;
}
.layout-preview-masonry span:nth-child(2) {
height: 60%;
}
.layout-preview-masonry span:nth-child(3) {
height: 80%;
}
/* Range Slider Control */
.range-control {
width: 100%;
+18
View File
@@ -107,6 +107,24 @@
white-space: nowrap;
}
.toast-close-btn {
flex-shrink: 0;
padding: 0 4px;
background: transparent;
color: var(--text-color);
border: none;
border-radius: 4px;
font-size: 1.1em;
line-height: 1;
opacity: 0.5;
cursor: pointer;
transition: opacity 0.2s ease;
}
.toast-close-btn:hover {
opacity: 1;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.toast {
+28
View File
@@ -168,6 +168,34 @@
border-color: var(--lora-accent);
}
/* Recipes layout toggle (grid / masonry) — segmented control in the toolbar */
.layout-toggle-group {
display: flex;
gap: 0;
}
.layout-toggle-group .layout-toggle-btn {
min-width: 36px;
width: 36px;
padding: 4px 0;
border-radius: 0;
}
.layout-toggle-group .layout-toggle-btn:first-child {
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
border-right: none;
}
.layout-toggle-group .layout-toggle-btn:last-child {
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
}
.layout-toggle-group .layout-toggle-btn:hover,
.layout-toggle-group .layout-toggle-btn:focus-visible {
transform: none;
box-shadow: var(--shadow-xs);
}
/* Keyboard shortcut indicator styling */
.shortcut-key {
display: inline-flex;
+2 -1
View File
@@ -1233,7 +1233,7 @@ export class BaseModelApiClient {
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null) {
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null, useSaveDirAsRoot = false) {
try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST',
@@ -1244,6 +1244,7 @@ export class BaseModelApiClient {
model_root: modelRoot,
relative_path: relativePath,
use_default_paths: useDefaultPaths,
use_save_dir_as_root: useSaveDirAsRoot,
download_id: downloadId,
...(source ? { source } : {}),
...(fileParams ? { file_params: fileParams } : {})
@@ -1,6 +1,7 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { isModelWeightFile } from '../../utils/modelFileTypes.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
@@ -255,7 +256,7 @@ export class RecipeContextMenu extends BaseContextMenu {
loras: validLoras.map(lora => {
const civitaiInfo = lora.civitaiInfo;
const modelFile = civitaiInfo.files ?
civitaiInfo.files.find(file => file.type === 'Model') : null;
civitaiInfo.files.find(file => isModelWeightFile(file.type)) : null;
return {
// Basic lora info
+10 -1
View File
@@ -1,5 +1,6 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
@@ -305,6 +306,14 @@ class RecipeModal {
modalManager.showModal('recipeModal');
if (this.recipeId) {
// Fire-and-forget: record this open for the "Recently Opened"
// sort. Tracking must never disturb the modal, so failures are
// swallowed.
fetch(`/api/lm/recipe/${encodeURIComponent(this.recipeId)}/opened`, {
method: 'POST',
keepalive: true,
}).catch(() => {});
const hydrationRequestId = ++this.recipeHydrationRequestId;
const requestEditVersions = this.captureLocalEditVersions();
this.hydrateRecipeDetails(
@@ -1412,7 +1421,7 @@ class RecipeModal {
loras: validLoras.map(lora => {
const civitaiInfo = lora.civitaiInfo;
const modelFile = civitaiInfo.files ?
civitaiInfo.files.find(file => file.type === 'Model') : null;
civitaiInfo.files.find(file => isModelWeightFile(file.type)) : null;
return {
// Basic lora info
+9 -47
View File
@@ -4,7 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown } from './SortDropdown.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
/**
* PageControls class - Unified control management for model pages
@@ -108,20 +108,20 @@ export class PageControls {
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
initSortDropdown(sortSelect);
this.applySortToSelect(this.pageState.sortBy);
applySortToSelect(this.pageState.sortBy);
sortSelect.addEventListener('change', async (e) => {
let value = e.target.value;
if (value.startsWith('random')) {
// Every pick of Random reshuffles the list: generate a
// fresh seed so the backend keeps a stable order across
// paginated requests.
value = this._randomizeSortValue();
value = randomizeSortValue();
}
this.pageState.sortBy = value;
this.saveSortPreference(value);
// Reset the seeded Random option when switching away from
// Random, or re-apply the fresh seed when picking it again.
this.applySortToSelect(value);
applySortToSelect(value);
await this.resetAndReload();
});
}
@@ -322,44 +322,6 @@ export class PageControls {
}
}
/**
* Apply a sort value to the native sort <select>, keeping the Random
* option's value in sync when the persisted value carries a seed
* (e.g. "random:abc123"). Must be used instead of assigning
* sortSelect.value directly whenever the value may be a seeded random
* sort, otherwise the native select has no matching option.
* @param {string} sortValue - Sort value like "name:asc" or "random:<seed>"
*/
applySortToSelect(sortValue) {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
}
sortSelect.value = sortValue;
}
/**
* Generate a fresh seeded random sort value ("random:<seed>") and keep
* the native <select> in sync so its value matches the persisted sort
* string and the dropdown shows the selected label.
* @returns {string} The new sort value, e.g. "random:abc123xyz"
*/
_randomizeSortValue() {
const seed = Math.random().toString(36).slice(2, 12);
const value = `random:${seed}`;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = value;
}
sortSelect.value = value;
}
return value;
}
/**
* Load sort preference from storage
*/
@@ -374,7 +336,7 @@ export class PageControls {
// Handle legacy format conversion
const convertedSort = this.convertLegacySortFormat(savedSort);
this.pageState.sortBy = convertedSort;
this.applySortToSelect(convertedSort);
applySortToSelect(convertedSort);
}
}
@@ -568,7 +530,7 @@ export class PageControls {
this.pageState.sortBy = restoredSort;
this.saveSortPreference(restoredSort);
this._removeVlmSortOption();
this.applySortToSelect(restoredSort);
applySortToSelect(restoredSort);
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.disabled = false;
@@ -620,7 +582,7 @@ export class PageControls {
const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort;
this.applySortToSelect(savedGroupedSort);
applySortToSelect(savedGroupedSort);
}
} else {
// Leaving group mode: persist current sort for next time, restore non-group sort
@@ -628,7 +590,7 @@ export class PageControls {
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
if (savedNormalSort) {
this.pageState.sortBy = savedNormalSort;
this.applySortToSelect(savedNormalSort);
applySortToSelect(savedNormalSort);
}
}
}
@@ -913,7 +875,7 @@ export class PageControls {
}
if (sortSelect) {
this.applySortToSelect(this.pageState.sortBy);
applySortToSelect(this.pageState.sortBy);
}
if (searchInput) {
searchInput.value = this.pageState.filters?.search || '';
@@ -18,6 +18,44 @@
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
/**
* Apply a sort value to the page's native sort <select>, keeping the Random
* option's value in sync when the persisted value carries a seed
* (e.g. "random:abc123"). Must be used instead of assigning
* sortSelect.value directly whenever the value may be a seeded random
* sort, otherwise the native select has no matching option.
* @param {string} sortValue - Sort value like "name:asc" or "random:<seed>"
*/
export function applySortToSelect(sortValue) {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
}
sortSelect.value = sortValue;
}
/**
* Generate a fresh seeded random sort value ("random:<seed>") and keep the
* native <select> in sync so its value matches the persisted sort string and
* the dropdown shows the selected label.
* @returns {string} The new sort value, e.g. "random:abc123xyz"
*/
export function randomizeSortValue() {
const seed = Math.random().toString(36).slice(2, 12);
const value = `random:${seed}`;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = value;
}
sortSelect.value = value;
}
return value;
}
/**
* Initialize a decoupled sort dropdown around a native <select>.
* Idempotent: safe to call more than once on the same element.
+40
View File
@@ -741,6 +741,46 @@ export function createModelCard(model, modelType) {
configureModelCardVideo(videoElement, autoplayOnHover);
}
// Dropping an image/video onto the card replaces the model preview via the
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
const preventDragDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
};
['dragenter', 'dragover'].forEach((eventName) => {
card.addEventListener(eventName, (event) => {
preventDragDefaults(event);
card.classList.add('drag-over');
});
});
card.addEventListener('dragleave', (event) => {
preventDragDefaults(event);
card.classList.remove('drag-over');
});
card.addEventListener('drop', (event) => {
preventDragDefaults(event);
card.classList.remove('drag-over');
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
// Keep in sync with the accept list of the preview file picker (image/* + video/mp4).
if (!file.type.startsWith('image/') && file.type !== 'video/mp4') {
showToast('toast.api.previewDropInvalid', { name: file.name || '' }, 'error');
return;
}
const filePath = card.dataset.filepath;
if (!filePath) return;
// uploadPreview handles loading state, card refresh and error toasts internally.
getModelApiClient().uploadPreview(filePath, file);
});
return card;
}
@@ -182,6 +182,10 @@ function isEarlyAccessActive(version) {
}
}
function isPaidPermanent(version) {
return version && version.isPaid === true;
}
function isDownloadAllowed(version) {
if (!version.usageControl) {
return true;
@@ -342,6 +346,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
const strategy = state?.global?.settings?.version_grouping;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
const hidePaid = state?.global?.settings?.hide_paid_updates;
if (!sameBaseMode) {
return Boolean(record?.hasUpdate);
@@ -388,6 +393,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false;
}
if (hidePaid && isPaidPermanent(version)) {
return false;
}
if (!isDownloadAllowed(version)) {
return false;
}
@@ -469,6 +477,7 @@ function renderRow(version, options) {
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
const paidBadgeLabel = translate('modals.model.versions.badges.paid', {}, 'Paid');
const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
@@ -522,6 +531,16 @@ function renderRow(version, options) {
}));
}
if (isPaidPermanent(version)) {
badges.push(buildBadge(paidBadgeLabel, 'paid', {
title: translate(
'modals.model.versions.badges.paidTooltip',
{},
'This version requires payment to download'
),
}));
}
if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
@@ -564,6 +583,12 @@ function renderRow(version, options) {
{},
'This version is only available for on-site generation on Civitai'
);
} else if (isPaidPermanent(version)) {
downloadTitle = translate(
'modals.model.versions.actions.downloadPaidTooltip',
{},
'Download this paid version from Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
@@ -1307,15 +1332,41 @@ export function initVersionsTab({
});
}
async function resolveDownloadPathFromCurrentVersion() {
function getCurrentInLibraryVersion() {
if (!normalizedCurrentVersionId || !controller.record?.versions) {
return null;
}
const currentVersion = controller.record.versions.find(
return controller.record.versions.find(
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
);
if (!currentVersion?.filePath) {
) || null;
}
function getDownloadPathTemplate() {
try {
const singularType = modelType.replace(/s$/, '');
const templates = state.global?.settings?.download_path_templates;
return (templates && templates[singularType]) || '';
} catch (error) {
return '';
}
}
function shouldResolveTemplatePath(targetVersion, pathInfo) {
if (!getDownloadPathTemplate() || !pathInfo?.modelRoot) {
return false;
}
const currentVersion = getCurrentInLibraryVersion();
const currentBase = normalizeBaseModelName(currentVersion?.baseModel);
const targetBase = normalizeBaseModelName(targetVersion?.baseModel);
if (!currentBase || !targetBase || currentBase === targetBase) {
return false;
}
return true;
}
async function resolveDownloadPathFromCurrentVersion() {
const currentVersion = getCurrentInLibraryVersion();
if (!currentVersion) {
return null;
}
@@ -1372,10 +1423,13 @@ export function initVersionsTab({
try {
const pathInfo = await resolveDownloadPathFromCurrentVersion();
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
versionName: version.name || `#${version.versionId}`,
modelRoot: pathInfo?.modelRoot || '',
targetFolder: pathInfo?.targetFolder || '',
targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''),
useDefaultPaths: resolveTemplatePath ? true : null,
useSaveDirAsRoot: resolveTemplatePath,
});
if (success) {
+13 -9
View File
@@ -3,6 +3,7 @@ import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -557,8 +558,7 @@ export class DownloadManager {
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
// Count model-type files per version
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) :
@@ -685,7 +685,7 @@ export class DownloadManager {
if (!version) return;
this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block';
@@ -747,7 +747,7 @@ export class DownloadManager {
return;
}
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
@@ -912,6 +912,7 @@ export class DownloadManager {
modelRoot = '',
targetFolder = '',
useDefaultPaths = false,
useSaveDirAsRoot = false,
source = null,
fileParams = null,
closeModal = false,
@@ -923,7 +924,7 @@ export class DownloadManager {
}
const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false };
let ws = null;
let updateProgress = () => { };
let cancelled = false;
@@ -995,7 +996,8 @@ export class DownloadManager {
useDefaultPaths,
downloadId,
source,
fileParams
fileParams,
useSaveDirAsRoot
);
if (cancelled) {
@@ -1809,7 +1811,9 @@ export class DownloadManager {
versionName = '',
source = null,
modelRoot = '',
targetFolder = ''
targetFolder = '',
useDefaultPaths = null,
useSaveDirAsRoot = false
} = {}) {
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
@@ -1824,14 +1828,14 @@ export class DownloadManager {
this.modelId = modelId ? modelId.toString() : null;
this.source = source;
const useDefaultPaths = !modelRoot;
return this.executeDownloadWithProgress({
modelId,
versionId,
versionName,
modelRoot: modelRoot || '',
targetFolder: targetFolder || '',
useDefaultPaths,
useDefaultPaths: useDefaultPaths ?? !modelRoot,
useSaveDirAsRoot,
source,
closeModal: false,
});
+55 -12
View File
@@ -1017,11 +1017,8 @@ export class SettingsManager {
displayDensitySelect.value = state.global.settings.display_density || 'default';
}
// Set recipes layout setting
const recipesLayoutSelect = document.getElementById('recipesLayout');
if (recipesLayoutSelect) {
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
}
// Set recipes layout setting (segmented control active state)
this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid');
// Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
@@ -1064,6 +1061,12 @@ export class SettingsManager {
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
}
// Set hide paid updates setting
const hidePaidUpdatesCheckbox = document.getElementById('hidePaidUpdates');
if (hidePaidUpdatesCheckbox) {
hidePaidUpdatesCheckbox.checked = state.global.settings.hide_paid_updates || false;
}
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
@@ -2288,19 +2291,18 @@ export class SettingsManager {
: element.value;
try {
// Recipes layout has its own shared entry point used by both the
// settings modal segmented control and the recipes page toolbar toggle
if (settingKey === 'recipes_layout') {
return this.saveRecipesLayout(element.value);
}
// Update frontend state with mapped keys
await this.saveSetting(settingKey, value);
// Apply frontend settings immediately
this.applyFrontendSettings();
// Dispatch layout change event; the scroller instance is about to be rebuilt,
// so calculateLayout() must NOT run on the old instance here
if (settingKey === 'recipes_layout') {
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
return;
}
// Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
@@ -2328,6 +2330,47 @@ export class SettingsManager {
}
}
/**
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
* Shared entry point for the settings modal segmented control and the
* recipes page toolbar toggle; both stay in sync via
* updateRecipesLayoutControls().
*/
async saveRecipesLayout(value) {
if (value !== 'grid' && value !== 'masonry') {
return;
}
// Update frontend state with mapped keys
await this.saveSetting('recipes_layout', value);
// Apply frontend settings immediately
this.applyFrontendSettings();
// Dispatch layout change event; the scroller instance is about to be rebuilt,
// so calculateLayout() must NOT run on the old instance here
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
this.updateRecipesLayoutControls(value);
}
/**
* Sync the active state of every recipes layout control
* (settings modal segmented control and recipes page toolbar toggle).
*/
updateRecipesLayoutControls(value) {
document.querySelectorAll('[data-recipes-layout]').forEach((control) => {
const active = control.dataset.recipesLayout === value;
control.classList.toggle('active', active);
if (control.hasAttribute('aria-pressed')) {
control.setAttribute('aria-pressed', String(active));
}
if (control.hasAttribute('aria-checked')) {
control.setAttribute('aria-checked', String(active));
}
});
}
async saveRangeSetting(elementId, displayId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
+38 -4
View File
@@ -10,7 +10,7 @@ import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './components/controls/SortDropdown.js';
class RecipePageControls {
constructor() {
@@ -245,10 +245,20 @@ class RecipeManager {
this.pageState.sortBy = savedSort;
}
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy || 'date:desc';
applySortToSelect(this.pageState.sortBy || 'date:desc');
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;
setStorageItem('recipes_sort', sortSelect.value);
let value = sortSelect.value;
if (value.startsWith('random')) {
// Every pick of Random reshuffles the list: generate a
// fresh seed so the backend keeps a stable order across
// paginated requests.
value = randomizeSortValue();
}
this.pageState.sortBy = value;
setStorageItem('recipes_sort', value);
// Reset the seeded Random option when switching away from
// Random, or re-apply the fresh seed when picking it again.
applySortToSelect(value);
refreshVirtualScroll();
});
}
@@ -272,6 +282,30 @@ class RecipeManager {
});
}
// Layout toggle (grid / masonry) — shares the recipes_layout setting with
// the settings modal segmented control; active states stay in sync via
// settingsManager.updateRecipesLayoutControls() after each save
const layoutToggleBtns = document.querySelectorAll('.layout-toggle-btn');
if (layoutToggleBtns.length) {
const currentLayout = state.global.settings?.recipes_layout || 'grid';
layoutToggleBtns.forEach((btn) => {
const isActive = btn.dataset.recipesLayout === currentLayout;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
btn.addEventListener('click', async () => {
const layout = btn.dataset.recipesLayout;
if ((state.global.settings?.recipes_layout || 'grid') === layout) {
return;
}
try {
await window.settingsManager?.saveRecipesLayout(layout);
} catch (error) {
console.error('Failed to switch recipes layout:', error);
}
});
});
}
// Rebuild the scroller on layout switch; in duplicates mode defer until
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
// the old instance while initializeVirtualScroll skips duplicates mode)
+1
View File
@@ -49,6 +49,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
version_grouping: 'same_base',
hide_early_access_updates: false,
hide_paid_updates: false,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
skip_previously_downloaded_model_versions: false,
+11 -4
View File
@@ -646,10 +646,17 @@ export class MasonryScroller {
const pageType = state.currentPageType;
if (pageType === 'recipes') {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
if (String(getCurrentPageState().sortBy).startsWith('opened')) {
placeholderText = `
<p>No recently opened recipes</p>
<p>Recipes you open will appear here.</p>
`;
} else {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
}
} else if (pageType === 'loras') {
placeholderText = `
<p>No LoRAs found</p>
+11 -4
View File
@@ -699,10 +699,17 @@ export class VirtualScroller {
const pageType = state.currentPageType;
if (pageType === 'recipes') {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
if (String(getCurrentPageState().sortBy).startsWith('opened')) {
placeholderText = `
<p>No recently opened recipes</p>
<p>Recipes you open will appear here.</p>
`;
} else {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
}
} else if (pageType === 'loras') {
placeholderText = `
<p>No LoRAs found</p>
+15
View File
@@ -0,0 +1,15 @@
// CivitAI ModelFile.type values eligible as the main download file.
// Mirrors the backend constant MODEL_WEIGHT_FILE_TYPES (py/utils/constants.py).
// Keep both lists in sync when CivitAI introduces new file types.
export const MODEL_WEIGHT_FILE_TYPES = [
'Model',
'Pruned Model',
'Negative',
'UNet',
'Diffusion Model',
'Enhancement LoRA',
];
export function isModelWeightFile(type) {
return MODEL_WEIGHT_FILE_TYPES.includes(type);
}
+39 -4
View File
@@ -236,11 +236,11 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
* @param {Object} [options]
* @param {string} [options.actionText] - Label for the action button (button omitted when empty)
* @param {Function} [options.onAction] - Callback invoked at most once on button click
* @param {number} [options.durationMs=30000] - How long the toast stays visible
* @param {number} [options.durationMs=20000] - How long the toast stays visible
* @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
*/
export function showActionToast(key, params = {}, type = 'info', options = {}) {
const { actionText, onAction, durationMs = 30000, countdown = true } = options;
const { actionText, onAction, durationMs = 20000, countdown = true } = options;
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params);
@@ -295,6 +295,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
}
}, 1000);
}
// Manual close button: hides the toast early without firing onAction. The
// backend undo window keeps running and the batch is purged when it expires.
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'toast-close-btn';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', translate('common.actions.close'));
closeBtn.addEventListener('click', (event) => {
event.preventDefault();
clearCountdown();
dismiss();
});
toast.append(closeBtn);
}
export function restoreFolderFilter() {
@@ -1092,6 +1106,9 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
if (!isNodeEnabled(node)) {
return false;
}
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1100,7 +1117,15 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast('uiHelpers.workflow.noMatchingNodes', {}, 'warning');
showToast(
translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
),
{},
'warning'
);
return false;
}
@@ -1152,6 +1177,11 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
if (!isNodeEnabled(node)) {
return false;
}
// A node whose text widget is backed by a connected input cannot have its
// text changed via the widget — execution reads the linked input.
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1160,7 +1190,12 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
const defaultHint = translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
showToast(options.missingNodesMessage || defaultHint, {}, 'warning');
return false;
}
+18 -5
View File
@@ -48,17 +48,20 @@
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
</optgroup>
{% endif %}
{% if page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.opened', default='Recently Opened') }}">
<option value="opened:desc">{{ t('recipes.controls.sort.openedDesc', default='Recently opened') }}</option>
</optgroup>
{% endif %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup>
</select>
</div>
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
@@ -131,6 +134,16 @@
</div>
<div class="controls-right">
{% if page_id == 'recipes' %}
<div class="control-group layout-toggle-group" role="group" aria-label="{{ t('recipes.controls.layout.title') }}" title="{{ t('recipes.controls.layout.title') }}">
<button type="button" class="layout-toggle-btn" data-recipes-layout="grid" aria-pressed="false" title="{{ t('recipes.controls.layout.grid') }}" aria-label="{{ t('recipes.controls.layout.grid') }}">
<i class="fas fa-th-large" aria-hidden="true"></i>
</button>
<button type="button" class="layout-toggle-btn" data-recipes-layout="masonry" aria-pressed="false" title="{{ t('recipes.controls.layout.masonry') }}" aria-label="{{ t('recipes.controls.layout.masonry') }}">
<i class="fas fa-columns" aria-hidden="true"></i>
</button>
</div>
{% endif %}
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
@@ -629,16 +629,22 @@
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="recipesLayout">
<label id="recipesLayoutLabel">
{{ t('settings.layoutSettings.recipesLayout') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
</select>
<div class="setting-control layout-options-control">
<div id="recipesLayoutOptions" class="layout-options" role="radiogroup" aria-label="{{ t('settings.layoutSettings.recipesLayout') }}" aria-labelledby="recipesLayoutLabel">
<button type="button" class="layout-option" data-recipes-layout="grid" onclick="settingsManager.saveRecipesLayout('grid')" role="radio" aria-checked="true">
<span class="layout-option-preview layout-preview-grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</span>
</button>
<button type="button" class="layout-option" data-recipes-layout="masonry" onclick="settingsManager.saveRecipesLayout('masonry')" role="radio" aria-checked="false">
<span class="layout-option-preview layout-preview-masonry" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</span>
</button>
</div>
</div>
</div>
</div>
@@ -1263,6 +1269,24 @@
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="hidePaidUpdates">
{{ t('settings.hidePaidUpdates.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.hidePaidUpdates.help') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="hidePaidUpdates"
onchange="settingsManager.saveToggleSetting('hidePaidUpdates', 'hide_paid_updates')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- Example Images -->
@@ -1667,7 +1667,7 @@ describe('AutoComplete widget interactions', () => {
expect(input.value).toBe('looking_to_the_side,');
});
it('shows /af command for loras when active-filters autocomplete is off (default)', async () => {
it('shows /activefilters command for loras when active-filters autocomplete is off (default)', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
@@ -1682,8 +1682,6 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(commandNames).not.toContain('/noaf');
expect(commandNames).toContain('/activefilters');
expect(commandNames).not.toContain('/noactivefilters');
});
@@ -1710,11 +1708,11 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(commandNames).toContain('/activefilters');
expect(previewTooltipMock.show).not.toHaveBeenCalled();
});
it('shows /noaf command for loras when active-filters autocomplete is on', async () => {
it('shows /noactivefilters command for loras when active-filters autocomplete is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
@@ -1736,8 +1734,6 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/noaf');
expect(commandNames).not.toContain('/af');
expect(commandNames).toContain('/noactivefilters');
expect(commandNames).not.toContain('/activefilters');
});
@@ -1766,7 +1762,7 @@ describe('AutoComplete widget interactions', () => {
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('toggles the active-filters setting when /af is accepted', async () => {
it('toggles the active-filters setting when /activefilters is accepted', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
@@ -1782,7 +1778,7 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
const afItem = autoComplete.items.find((item) => item.command === '/af');
const afItem = autoComplete.items.find((item) => item.command === '/activefilters');
expect(afItem).toBeDefined();
// Simulate the input being cleared after the command is accepted so the
@@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
MODEL_CARD_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
} = vi.hoisted(() => ({
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const uploadPreviewMock = vi.fn();
vi.mock(STATE_MODULE, () => ({
state: {
settings: {
blur_mature_content: false,
model_name_display: 'model_name',
},
global: {
settings: {
model_name_display: 'model_name',
group_by_model: false,
display_density: 'default',
model_card_footer_action: 'replace_preview',
},
},
pages: {
loras: {
previewVersions: new Map(),
sortBy: 'name',
},
},
bulkMode: false,
selectedLoras: new Set(),
},
getCurrentPageState: vi.fn(() => ({
sortBy: 'name',
previewVersions: new Map(),
})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
openCivitai: vi.fn(),
openHuggingFace: vi.fn(),
copyToClipboard: vi.fn(),
copyLoraSyntax: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendEmbeddingToWorkflow: vi.fn(),
openExampleImagesFolder: vi.fn(),
buildLoraSyntax: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(API_CONFIG_MODULE, () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({ uploadPreview: uploadPreviewMock })),
}));
describe('ModelCard drag & drop preview upload', () => {
let createModelCard;
beforeEach(async () => {
showToastMock.mockReset();
uploadPreviewMock.mockReset();
({ createModelCard } = await import(MODEL_CARD_MODULE));
});
function createCard() {
const model = {
sha256: 'abc123',
file_path: '/models/test_lora.safetensors',
model_name: 'Test LoRA',
file_name: 'test_lora',
folder: 'models',
modified: 1234567890,
file_size: 1024,
usage_count: 0,
notes: '',
base_model: 'SD1.5',
favorite: false,
exclude: false,
hf_url: '',
update_available: false,
skip_metadata_refresh: false,
preview_url: '',
preview_nsfw_level: 0,
tags: [],
civitai: {},
sub_type: 'lora',
};
return createModelCard(model, 'loras');
}
function dispatchDrop(card, files) {
const event = new Event('drop', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { files } });
card.dispatchEvent(event);
return event;
}
it('uploads the dropped image as the model preview', () => {
const card = createCard();
const file = new File(['data'], 'preview.png', { type: 'image/png' });
dispatchDrop(card, [file]);
expect(uploadPreviewMock).toHaveBeenCalledTimes(1);
expect(uploadPreviewMock).toHaveBeenCalledWith('/models/test_lora.safetensors', file);
});
it('supports MP4 video files', () => {
const card = createCard();
const file = new File(['data'], 'preview.mp4', { type: 'video/mp4' });
dispatchDrop(card, [file]);
expect(uploadPreviewMock).toHaveBeenCalledTimes(1);
});
it('rejects unsupported file types with a toast', () => {
const card = createCard();
const file = new File(['data'], 'notes.txt', { type: 'text/plain' });
dispatchDrop(card, [file]);
expect(uploadPreviewMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.previewDropInvalid',
{ name: 'notes.txt' },
'error'
);
});
it('ignores drops without files', () => {
const card = createCard();
dispatchDrop(card, []);
expect(uploadPreviewMock).not.toHaveBeenCalled();
});
it('prevents browser default and highlights the card while dragging over', () => {
const card = createCard();
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
card.dispatchEvent(dragOverEvent);
expect(dragOverEvent.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(true);
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true });
card.dispatchEvent(dragLeaveEvent);
expect(dragLeaveEvent.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(false);
});
it('clears the highlight when the drop completes', () => {
const card = createCard();
const file = new File(['data'], 'preview.png', { type: 'image/png' });
const event = dispatchDrop(card, [file]);
expect(event.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(false);
});
});
@@ -0,0 +1,210 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
MODEL_VERSIONS_MODULE,
API_FACTORY_MODULE,
DOWNLOAD_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
I18N_HELPERS_MODULE,
UTILS_MODULE,
} = vi.hoisted(() => ({
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
}));
const downloadVersionWithDefaults = vi.fn();
vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
downloadManager: {
downloadVersionWithDefaults,
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitaiUrl: vi.fn(),
}));
const stateMock = {
global: {
settings: {
autoplay_on_hover: false,
version_grouping: 'any',
download_path_templates: {
lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}/{first_tag}',
embedding: '{base_model}/{first_tag}',
},
},
},
};
vi.mock(STATE_MODULE, () => ({
state: stateMock,
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(UTILS_MODULE, () => ({
formatFileSize: vi.fn(() => '1 MB'),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
}));
const LORA_ROOT = '/models/loras';
function buildRecord(targetBaseModel = 'Anima') {
return {
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [10],
versions: [
{
versionId: 10,
name: 'v1.0',
baseModel: 'Illustrious',
sizeBytes: 1024,
isInLibrary: true,
shouldIgnore: false,
filePath: `${LORA_ROOT}/Illustrious/works/file.safetensors`,
},
{
versionId: 11,
name: 'v1.1',
baseModel: targetBaseModel,
sizeBytes: 2048,
isInLibrary: false,
shouldIgnore: false,
},
],
},
};
}
async function renderAndClickDownload({ currentVersionId = 10, record = null } = {}) {
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 123,
currentVersionId,
});
await controller.load();
const downloadButton = document.querySelector(
'.model-version-row[data-version-id="11"] [data-version-action="download"]'
);
downloadButton?.click();
await new Promise(resolve => setTimeout(resolve, 0));
return controller;
}
describe('ModelVersionsTab update download path resolution', () => {
let getModelApiClient;
let fetchModelUpdateVersions;
let fetchModelRoots;
beforeEach(async () => {
vi.resetModules();
downloadVersionWithDefaults.mockReset();
downloadVersionWithDefaults.mockResolvedValue(true);
document.body.innerHTML = `
<div id="model-versions-modal">
<div id="versions-tab">
<div class="model-versions-tab"></div>
</div>
</div>
`;
stateMock.global.settings.version_grouping = 'any';
stateMock.global.settings.download_path_templates.lora = '{base_model}/{first_tag}';
({ getModelApiClient } = await import(API_FACTORY_MODULE));
fetchModelUpdateVersions = vi.fn();
fetchModelRoots = vi.fn();
fetchModelRoots.mockResolvedValue({ roots: [LORA_ROOT] });
getModelApiClient.mockReturnValue({
fetchModelUpdateVersions,
fetchModelRoots,
setModelUpdateIgnore: vi.fn(),
setVersionUpdateIgnore: vi.fn(),
deleteModel: vi.fn(),
});
});
afterEach(() => {
document.body.innerHTML = '';
});
it('keeps the current folder when the target version has the same base model', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord('Illustrious'));
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: 'Illustrious/works',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
it('resolves the template path when the target base model differs and a template is configured', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: '',
useDefaultPaths: true,
useSaveDirAsRoot: true,
})
);
});
it('keeps the current folder when the target base model differs but no template is configured', async () => {
stateMock.global.settings.download_path_templates.lora = '';
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: 'Illustrious/works',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
it('falls back to default paths when no local version exists', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload({ currentVersionId: null });
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: '',
targetFolder: '',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
});
@@ -1,4 +1,5 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { applySortToSelect } from '../../../static/js/components/controls/SortDropdown.js';
const resetAndReloadMock = vi.fn();
const getModelApiClientMock = vi.fn();
@@ -190,7 +191,7 @@ describe('Random sort option', () => {
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
controls.applySortToSelect('name:desc');
applySortToSelect('name:desc');
expect(sortSelect.value).toBe('name:desc');
expect(randomOpt.value).toBe('random');
@@ -0,0 +1,246 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const { APP_MODULE, API_MODULE, STYLES_MODULE, REGISTRY_MODULE, appMock, apiMock, registeredExtensions } =
vi.hoisted(() => {
const registeredExtensions = [];
const appMock = {
graph: null,
registerExtension: (ext) => registeredExtensions.push(ext),
};
const apiMock = {
clientId: "client-1",
initialClientId: null,
addEventListener: vi.fn(),
};
return {
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
STYLES_MODULE: new URL("../../../web/comfyui/lm_styles_loader.js", import.meta.url).pathname,
REGISTRY_MODULE: new URL("../../../web/comfyui/workflow_registry.js", import.meta.url).pathname,
appMock,
apiMock,
registeredExtensions,
};
});
vi.mock(APP_MODULE, () => ({ app: appMock }));
vi.mock(API_MODULE, () => ({ api: apiMock }));
vi.mock(STYLES_MODULE, () => ({ ensureLmStyles: vi.fn() }));
function createTextEncodeNode({ linked = false, id = 1 } = {}) {
const textWidget = { name: "text", type: "customtext", value: "old prompt", callback: null };
return {
id,
comfyClass: "CLIPTextEncode",
title: "CLIP Text Encode",
mode: 0,
properties: {},
widgets: [textWidget, { name: "clip", type: "combo" }],
widgets_values: ["old prompt", "clip-1"],
inputs: [
{ name: "text", type: "STRING", widget: textWidget, link: linked ? 101 : null },
{ name: "clip", type: "CLIP", link: null },
],
setDirtyCanvas: vi.fn(),
graph: null,
};
}
function createSubgraph({ id = "sub-1", nodes = [] } = {}) {
const graph = {
id,
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function createGraph({ nodes = [], subgraphs = [] } = {}) {
const graph = {
id: "root",
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const subgraph of subgraphs) {
graph._subgraphs.set(subgraph.id, subgraph);
}
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function lastRegisterPayload(fetchMock) {
const calls = fetchMock.mock.calls.filter(
([url]) => url === "/api/lm/register-nodes"
);
expect(calls.length).toBeGreaterThan(0);
return JSON.parse(calls[calls.length - 1][1].body);
}
describe("LoraManager.WorkflowRegistry", () => {
let extension;
let fetchMock;
beforeEach(async () => {
vi.resetModules();
registeredExtensions.length = 0;
appMock.graph = null;
apiMock.addEventListener.mockClear();
fetchMock = vi.fn().mockResolvedValue({ ok: true });
global.fetch = fetchMock;
await import(REGISTRY_MODULE);
extension = registeredExtensions.find(
(ext) => ext.name === "LoraManager.WorkflowRegistry"
);
expect(extension).toBeDefined();
});
afterEach(() => {
delete global.fetch;
});
describe("refreshRegistry", () => {
it("registers an unconnected CLIPTextEncode as a text target", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode()] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(true);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(false);
});
it("excludes a CLIPTextEncode whose text input is connected", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode({ linked: true })] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(false);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("registers connection state for nodes inside subgraphs", async () => {
const inner = createTextEncodeNode({ linked: true, id: 7 });
const subgraph = createSubgraph({ id: "sub-1", nodes: [inner] });
appMock.graph = createGraph({ subgraphs: [subgraph] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].graph_id).toBe("sub-1");
expect(body.nodes[0].node_id).toBe(7);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("re-registers when text_widget_connected changes (fingerprint)", async () => {
const node = createTextEncodeNode();
appMock.graph = createGraph({ nodes: [node] });
await extension.refreshRegistry(true);
await extension.refreshRegistry();
expect(
fetchMock.mock.calls.filter(([url]) => url === "/api/lm/register-nodes")
).toHaveLength(1);
node.inputs[0].link = 101;
await extension.refreshRegistry();
const body = lastRegisterPayload(fetchMock);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
});
describe("applyWidgetUpdate (inject_text)", () => {
it("updates the widget value when the text input is not connected", async () => {
const node = createTextEncodeNode();
const callback = vi.fn();
node.widgets[0].callback = callback;
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
await extension.applyWidgetUpdate({
node_id: 1,
action: "inject_text",
value: "hello",
mode: "replace",
});
expect(node.widgets[0].value).toBe("hello");
expect(node.widgets_values[0]).toBe("hello");
expect(callback).toHaveBeenCalledWith("hello");
});
it("skips inject_text when the target widget is connected and self-heals the registry", async () => {
const node = createTextEncodeNode({ linked: true });
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await extension.applyWidgetUpdate({
node_id: 1,
graph_id: "root",
action: "inject_text",
value: "new prompt",
mode: "replace",
});
expect(node.widgets[0].value).toBe("old prompt");
expect(node.widgets_values[0]).toBe("old prompt");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("connected to an input"),
expect.anything(),
expect.anything()
);
await vi.waitFor(() => {
expect(
fetchMock.mock.calls.some(([url]) => url === "/api/lm/register-nodes")
).toBe(true);
});
warnSpy.mockRestore();
});
});
describe("setup link-change hooks", () => {
it("hooks root events, existing subgraphs, and future subgraphs", () => {
const subgraph = createSubgraph({ id: "sub-1", nodes: [] });
const graph = createGraph({ subgraphs: [subgraph] });
appMock.graph = graph;
extension.setup();
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"subgraph-created",
expect.any(Function)
);
expect(subgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
const createdHandler = graph.events.addEventListener.mock.calls.find(
([name]) => name === "subgraph-created"
)[1];
const laterSubgraph = createSubgraph({ id: "sub-2", nodes: [] });
createdHandler({ subgraph: laterSubgraph });
expect(laterSubgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
});
});
});
@@ -530,4 +530,49 @@ describe('SettingsManager recipes layout switch', () => {
dispatchSpy.mockRestore();
delete state.virtualScroller;
});
it('saveRecipesLayout persists, dispatches the layout event, and syncs controls', async () => {
const manager = createManager();
const gridBtn = document.createElement('button');
gridBtn.dataset.recipesLayout = 'grid';
gridBtn.setAttribute('aria-pressed', 'false');
const masonryBtn = document.createElement('button');
masonryBtn.dataset.recipesLayout = 'masonry';
masonryBtn.setAttribute('aria-pressed', 'false');
masonryBtn.setAttribute('role', 'radio');
masonryBtn.setAttribute('aria-checked', 'false');
document.body.appendChild(gridBtn);
document.body.appendChild(masonryBtn);
const calculateLayout = vi.fn();
state.virtualScroller = { calculateLayout };
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
await manager.saveRecipesLayout('masonry');
expect(state.global.settings.recipes_layout).toBe('masonry');
expect(masonryBtn.classList.contains('active')).toBe(true);
expect(masonryBtn.getAttribute('aria-pressed')).toBe('true');
expect(masonryBtn.getAttribute('aria-checked')).toBe('true');
expect(gridBtn.classList.contains('active')).toBe(false);
expect(gridBtn.getAttribute('aria-pressed')).toBe('false');
const layoutEvent = dispatchSpy.mock.calls
.map(([event]) => event)
.find(event => event.type === 'lm:recipes-layout-changed');
expect(layoutEvent).toBeInstanceOf(CustomEvent);
expect(calculateLayout).not.toHaveBeenCalled();
expect(showToast).not.toHaveBeenCalled();
dispatchSpy.mockRestore();
delete state.virtualScroller;
});
it('ignores invalid recipes layout values', async () => {
const manager = createManager();
await manager.saveRecipesLayout('bogus');
expect(state.global.settings.recipes_layout).toBeUndefined();
});
});
@@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderRecipesPage } from '../utils/pageFixtures.js';
import { applySortToSelect } from '../../../static/js/components/controls/SortDropdown.js';
const initializeAppMock = vi.fn();
const initializePageFeaturesMock = vi.fn();
const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const removeStorageItemMock = vi.fn();
const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn();
let importManagerInstance;
let recipeModalInstance;
let duplicatesManagerInstance;
const ImportManagerMock = vi.fn(() => importManagerInstance);
const RecipeModalMock = vi.fn(() => recipeModalInstance);
const DuplicatesManagerMock = vi.fn(() => duplicatesManagerInstance);
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: initializeAppMock,
initializePageFeatures: initializePageFeaturesMock,
},
}));
vi.mock('../../../static/js/managers/ImportManager.js', () => ({
ImportManager: ImportManagerMock,
}));
vi.mock('../../../static/js/components/RecipeModal.js', () => ({
RecipeModal: RecipeModalMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
getCurrentPageState: getCurrentPageStateMock,
state: {
currentPageType: 'recipes',
global: { settings: {} },
virtualScroller: {
removeItemByFilePath: vi.fn(),
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
},
},
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
removeStorageItem: removeStorageItemMock,
}));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
RecipeContextMenu: vi.fn(),
}));
vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
DuplicatesManager: DuplicatesManagerMock,
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
refreshVirtualScroll: refreshVirtualScrollMock,
recreateVirtualScroll: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
refreshRecipes: refreshRecipesMock,
RecipeSidebarApiClient: vi.fn(() => ({
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ success: true, tree: {} }),
fetchModelFolders: vi.fn().mockResolvedValue({ success: true, folders: [] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
moveBulkModels: vi.fn(),
moveSingleModel: vi.fn(),
})),
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
},
}));
function renderSortSelect() {
const sortSelectElement = document.createElement('select');
sortSelectElement.id = 'sortSelect';
sortSelectElement.innerHTML = `
<option value="date:desc">Newest</option>
<option value="name:asc">Name A-Z</option>
<option value="random">Randomize (shuffle)</option>
`;
document.body.appendChild(sortSelectElement);
return sortSelectElement;
}
describe('RecipeManager Random sort', () => {
let RecipeManager;
let pageState;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
importManagerInstance = { showImportModal: vi.fn() };
recipeModalInstance = { showRecipeDetails: vi.fn() };
duplicatesManagerInstance = {
findDuplicates: vi.fn(),
selectLatestDuplicates: vi.fn(),
deleteSelectedDuplicates: vi.fn(),
confirmDeleteDuplicates: vi.fn(),
exitDuplicateMode: vi.fn(),
};
pageState = {
sortBy: 'date:desc',
searchOptions: undefined,
customFilter: undefined,
duplicatesMode: false,
};
getCurrentPageStateMock.mockImplementation(() => pageState);
initializeAppMock.mockResolvedValue(undefined);
initializePageFeaturesMock.mockResolvedValue(undefined);
refreshVirtualScrollMock.mockImplementation(() => {});
refreshRecipesMock.mockResolvedValue('refreshed');
getSessionItemMock.mockImplementation(() => null);
removeSessionItemMock.mockImplementation(() => {});
getStorageItemMock.mockImplementation(() => null);
setStorageItemMock.mockImplementation(() => {});
renderRecipesPage();
});
afterEach(() => {
document.body.innerHTML = '';
delete window.recipeManager;
delete window.importManager;
});
async function createManager() {
({ RecipeManager } = await import('../../../static/js/recipes.js'));
const manager = new RecipeManager();
await manager.initialize();
return manager;
}
it('generates a seeded sort value when Random is picked', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(setStorageItemMock).toHaveBeenCalledWith('recipes_sort', pageState.sortBy);
expect(randomOpt.value).toBe(pageState.sortBy);
expect(sortSelect.value).toBe(pageState.sortBy);
expect(refreshVirtualScrollMock).toHaveBeenCalled();
});
it('reshuffles with a fresh seed every time Random is picked again', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
const firstSeed = pageState.sortBy;
sortSelect.value = randomOpt.value;
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(pageState.sortBy).not.toBe(firstSeed);
});
it('restores a persisted seeded random sort on load', async () => {
const sortSelect = renderSortSelect();
const savedSort = 'random:persistedseed';
getStorageItemMock.mockImplementation((key) =>
key === 'recipes_sort' ? savedSort : null
);
await createManager();
expect(pageState.sortBy).toBe(savedSort);
expect(sortSelect.value).toBe(savedSort);
expect(sortSelect.querySelector('option[value="random:persistedseed"]')).not.toBeNull();
});
it('applies a non-random sort back to the plain random option', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
applySortToSelect('name:asc');
expect(sortSelect.value).toBe('name:asc');
expect(randomOpt.value).toBe('random');
});
it('resets the seeded option when switching away from Random via the change handler', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(randomOpt.value).toMatch(/^random:[a-z0-9]+$/);
sortSelect.value = 'name:asc';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toBe('name:asc');
expect(sortSelect.value).toBe('name:asc');
expect(randomOpt.value).toBe('random');
});
});
+33
View File
@@ -163,6 +163,7 @@ describe('RecipeManager', () => {
afterEach(() => {
delete window.recipeManager;
delete window.importManager;
delete window.settingsManager;
});
it('initializes page controls, restores filters, and wires sort interactions', async () => {
@@ -227,6 +228,38 @@ describe('RecipeManager', () => {
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
});
it('wires the layout toggle and reflects the saved recipes layout setting', async () => {
const gridBtn = document.createElement('button');
gridBtn.className = 'layout-toggle-btn';
gridBtn.dataset.recipesLayout = 'grid';
gridBtn.setAttribute('aria-pressed', 'false');
const masonryBtn = document.createElement('button');
masonryBtn.className = 'layout-toggle-btn';
masonryBtn.dataset.recipesLayout = 'masonry';
masonryBtn.setAttribute('aria-pressed', 'false');
document.body.appendChild(gridBtn);
document.body.appendChild(masonryBtn);
const saveRecipesLayoutMock = vi.fn().mockResolvedValue();
window.settingsManager = { saveRecipesLayout: saveRecipesLayoutMock };
const manager = new RecipeManager();
await manager.initialize();
// Initial state follows the saved setting (default grid)
expect(gridBtn.classList.contains('active')).toBe(true);
expect(gridBtn.getAttribute('aria-pressed')).toBe('true');
expect(masonryBtn.classList.contains('active')).toBe(false);
// Clicking the inactive option saves the new layout
masonryBtn.dispatchEvent(new Event('click', { bubbles: true }));
expect(saveRecipesLayoutMock).toHaveBeenCalledWith('masonry');
// Clicking the already-active option is a no-op
gridBtn.dispatchEvent(new Event('click', { bubbles: true }));
expect(saveRecipesLayoutMock).toHaveBeenCalledTimes(1);
});
it('skips loading when duplicates mode is active and refreshes otherwise', async () => {
const manager = new RecipeManager();
+1 -1
View File
@@ -67,7 +67,7 @@ describe('translate() with real en.json locale', () => {
installWindowI18n();
expect(translate('modals.deleteModel.recoverableWarning')).toBe(
'This will permanently delete the file after 30 seconds unless you undo.',
'This will permanently delete the file after 20 seconds unless you undo.',
);
expect(translate('modals.deleteModel.freesSpace', { size: '1.2 MB' })).toBe(
'Frees 1.2 MB',
@@ -324,6 +324,18 @@ describe('MasonryScroller', () => {
expect(placeholder.textContent).toContain('No recipes found');
});
it('shows the recently-opened empty placeholder under the opened sort', async () => {
getCurrentPageState().sortBy = 'opened:desc';
const { scroller, grid } = track(createScroller({ items: [] }));
await scroller.initialize();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('No recently opened recipes');
getCurrentPageState().sortBy = '';
});
it('dispose removes classes, spacer and event listeners', () => {
const { scroller, grid } = track(createScroller());
+263 -3
View File
@@ -133,14 +133,14 @@ describe('UI helper DOM utilities', () => {
const countdown = toast.querySelector('.toast-countdown');
expect(countdown).not.toBeNull();
expect(countdown.textContent).toBe('(30s)');
expect(countdown.textContent).toBe('(20s)');
// Ticking one second updates the countdown text
vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(29s)');
expect(countdown.textContent).toBe('(19s)');
// Drain remaining timers so no state leaks into other tests
vi.advanceTimersByTime(30000);
vi.advanceTimersByTime(20000);
});
it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
@@ -186,6 +186,34 @@ describe('UI helper DOM utilities', () => {
expect(onAction).toHaveBeenCalledTimes(1);
});
it('dismisses the toast via the close button without firing onAction', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
});
const toast = document.querySelector('.toast-container .toast');
const countdown = toast.querySelector('.toast-countdown');
toast.querySelector('.toast-close-btn').click();
expect(onAction).not.toHaveBeenCalled();
expect(toast.classList.contains('show')).toBe(false);
// Advancing past the full duration must not tick the countdown further,
// throw, or re-dismiss the already-dismissed toast
vi.advanceTimersByTime(60000);
expect(countdown.textContent).toBe('(20s)');
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
});
it('dismisses the toast when the countdown reaches zero', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
@@ -315,6 +343,238 @@ describe('UI helper DOM utilities', () => {
]);
});
it('excludes prompt targets whose text widget is connected to an input', async () => {
const registryResponse = {
success: true,
data: {
node_count: 4,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Free Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
'root:3': {
id: 3,
graph_id: 'root',
graph_name: null,
title: 'Marked But Wired',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: true,
widget_names: ['seed'],
},
},
'root:4': {
id: 4,
graph_id: 'root',
graph_name: null,
title: 'Free Text 2',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Free Text', '#4 Free Text 2']);
});
it('returns false when the only prompt target has its text widget connected', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(false);
expect(document.querySelectorAll('#nodeSelector .node-item').length).toBe(0);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Mark as');
expect(toast.textContent).toContain('Send Prompt Target');
});
it('shows the mark-as hint when no embedding target is available', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendEmbeddingToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendEmbeddingToWorkflow('embeddingcode');
expect(result).toBe(false);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Send Prompt Target');
});
it('keeps unconnected marker targets in the prompt candidate list', async () => {
const registryResponse = {
success: true,
data: {
node_count: 2,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Marked Target',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Marked Target 2',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Marked Target', '#2 Marked Target 2']);
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
@@ -471,6 +471,482 @@ def test_conditioning_provenance_recovers_combined_controlnet_prompts(
assert params["negative_prompt"] == "low quality"
def test_conditioning_provenance_recovers_transformed_switched_prompts(
metadata_registry, monkeypatch
):
prompt_graph = {
"encode_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "expected positive", "clip": ["clip", 0]},
},
"encode_other_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "wrong positive", "clip": ["clip", 0]},
},
"encode_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "expected negative", "clip": ["clip", 0]},
},
"encode_other_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "wrong negative", "clip": ["clip", 0]},
},
"enhancer": {
"class_type": "KreaSeedVarianceEnhancer",
"inputs": {"conditioning": ["encode_pos", 0]},
},
"zero_out": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["encode_neg", 0]},
},
"positive_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_pos", 0],
"on_true": ["enhancer", 0],
},
},
"negative_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_neg", 0],
"on_true": ["zero_out", 0],
},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["positive_switch", 0],
"negative": ["negative_switch", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
positive_conditioning = object()
other_positive_conditioning = object()
negative_conditioning = object()
other_negative_conditioning = object()
enhanced_conditioning = object()
zeroed_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-transformed-switch")
metadata_registry.set_current_prompt(prompt)
for node_id, text, conditioning in (
("encode_pos", "expected positive", positive_conditioning),
("encode_other_pos", "wrong positive", other_positive_conditioning),
("encode_neg", "expected negative", negative_conditioning),
("encode_other_neg", "wrong negative", other_negative_conditioning),
):
metadata_registry.record_node_execution(
node_id, "CLIPTextEncode", {"text": text}, None
)
metadata_registry.update_node_execution(
node_id, "CLIPTextEncode", [(conditioning,)]
)
metadata_registry.record_node_execution(
"enhancer",
"KreaSeedVarianceEnhancer",
{"conditioning": positive_conditioning},
None,
return_types=("CONDITIONING", "STRING"),
)
metadata_registry.update_node_execution(
"enhancer",
"KreaSeedVarianceEnhancer",
[(enhanced_conditioning, "diagnostics")],
return_types=("CONDITIONING", "STRING"),
)
metadata_registry.record_node_execution(
"zero_out",
"ConditioningZeroOut",
{"conditioning": negative_conditioning},
None,
return_types=("CONDITIONING",),
)
metadata_registry.update_node_execution(
"zero_out",
"ConditioningZeroOut",
[(zeroed_conditioning,)],
return_types=("CONDITIONING",),
)
metadata_registry.record_node_execution(
"positive_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": other_positive_conditioning,
"on_true": enhanced_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"positive_switch", "ComfySwitchNode", [(enhanced_conditioning,)]
)
metadata_registry.record_node_execution(
"negative_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": other_negative_conditioning,
"on_true": zeroed_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"negative_switch", "ComfySwitchNode", [(zeroed_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": enhanced_conditioning,
"negative": zeroed_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-transformed-switch")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "expected positive"
assert params["negative_prompt"] == "expected negative"
def test_conditioning_provenance_identity_switch_between_encoders(
metadata_registry, monkeypatch
):
"""Lock identity-preserving switches placed directly between encoders.
A switch returns the selected input conditioning verbatim, so provenance
must be recovered through object identity without any transform metadata.
"""
prompt_graph = {
"encode_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "chosen positive", "clip": ["clip", 0]},
},
"encode_other_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "unchosen positive", "clip": ["clip", 0]},
},
"positive_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_pos", 0],
"on_true": ["encode_pos", 0],
},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["positive_switch", 0],
"negative": ["encode_other_pos", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
chosen_conditioning = object()
unchosen_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-identity-switch")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_pos", "CLIPTextEncode", {"text": "chosen positive"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "CLIPTextEncode", [(chosen_conditioning,)]
)
metadata_registry.record_node_execution(
"encode_other_pos", "CLIPTextEncode", {"text": "unchosen positive"}, None
)
metadata_registry.update_node_execution(
"encode_other_pos", "CLIPTextEncode", [(unchosen_conditioning,)]
)
metadata_registry.record_node_execution(
"positive_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": unchosen_conditioning,
"on_true": chosen_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"positive_switch", "ComfySwitchNode", [(chosen_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": chosen_conditioning,
"negative": unchosen_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-identity-switch")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "chosen positive"
assert params["negative_prompt"] == "unchosen positive"
def test_conditioning_provenance_ignores_scalar_conditioning_fields(
metadata_registry, monkeypatch
):
"""Scalar fields like ``conditioning_strength`` must not be collected as
conditioning objects for unregistered transform nodes."""
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-scalar-filter")
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
input_conditioning = object()
metadata_registry.record_node_execution(
"strength_node",
"SomeStrengthTransform",
{"conditioning": input_conditioning, "conditioning_strength": 0.8},
None,
return_types=("CONDITIONING",),
)
metadata = metadata_registry.get_metadata("prompt-scalar-filter")
assert metadata[PROMPTS]["strength_node"]["orig_conditionings"] == [
input_conditioning
]
def test_conditioning_provenance_selector_with_conditioning_named_inputs(
metadata_registry, monkeypatch
):
"""An identity selector whose inputs use ``conditioning*`` names must not
leak the unselected branch's prompt."""
prompt_graph = {
"encode_a": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "AAA", "clip": ["clip", 0]},
},
"encode_b": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "BBB", "clip": ["clip", 0]},
},
"selector": {
"class_type": "ConditioningSelector",
"inputs": {
"conditioning_a": ["encode_a", 0],
"conditioning_b": ["encode_b", 0],
},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["selector", 0],
"negative": ["encode_b", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
conditioning_a = object()
conditioning_b = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-selector")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_a", "CLIPTextEncode", {"text": "AAA"}, None
)
metadata_registry.update_node_execution(
"encode_a", "CLIPTextEncode", [(conditioning_a,)]
)
metadata_registry.record_node_execution(
"encode_b", "CLIPTextEncode", {"text": "BBB"}, None
)
metadata_registry.update_node_execution(
"encode_b", "CLIPTextEncode", [(conditioning_b,)]
)
metadata_registry.record_node_execution(
"selector",
"ConditioningSelector",
{"conditioning_a": conditioning_a, "conditioning_b": conditioning_b},
None,
return_types=("CONDITIONING",),
)
metadata_registry.update_node_execution(
"selector", "ConditioningSelector", [(conditioning_a,)],
return_types=("CONDITIONING",),
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": conditioning_a,
"negative": conditioning_b,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-selector")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "AAA"
assert params["negative_prompt"] == "BBB"
def test_conditioning_provenance_uses_conditioning_output_slot(
metadata_registry, monkeypatch
):
"""Unregistered nodes whose CONDITIONING output is not the first slot
must still be tracked through the correct output position.
The graph's conditioning chain ends at an unexecuted phantom node so the
topology fallback in extract_generation_params cannot mask a runtime
provenance failure.
"""
prompt_graph = {
"diag_node": {
"class_type": "DiagThenCond",
"inputs": {"conditioning": ["phantom_source", 0]},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["diag_node", 1],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
input_conditioning = object()
transformed_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-output-slot")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_pos", "CLIPTextEncode", {"text": "AAA"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "CLIPTextEncode", [(input_conditioning,)]
)
metadata_registry.record_node_execution(
"diag_node",
"DiagThenCond",
{"conditioning": input_conditioning},
None,
return_types=("STRING", "CONDITIONING"),
)
metadata_registry.update_node_execution(
"diag_node",
"DiagThenCond",
[("diagnostics", transformed_conditioning)],
return_types=("STRING", "CONDITIONING"),
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": transformed_conditioning,
"negative": input_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-output-slot")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "AAA"
def test_conditioning_provenance_recovers_kj_set_get_prompts(
metadata_registry, monkeypatch
):
@@ -897,6 +1373,97 @@ def test_metadata_overwrite_extractor_empty_inputs(metadata_registry):
metadata_registry.clear_metadata()
def _make_ksampler(func_name: str):
"""Build a duck-typed comfy.samplers.KSAMPLER stub with a named function."""
def _sampler_function(*args, **kwargs):
pass
_sampler_function.__name__ = func_name
return SimpleNamespace(sampler_function=_sampler_function)
def test_metadata_overwrite_extractor_sampler_union(metadata_registry):
"""Wired SAMPLER objects should be converted to sampler names."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler"]
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = _make_ksampler("sample_euler")
MetadataOverwriteExtractor.extract("ow-sampler-1", inputs, None, metadata)
params = metadata[OVERWRITE]["ow-sampler-1"]["parameters"]
assert params["sampler"] == "euler"
metadata_registry.clear_metadata()
def test_metadata_overwrite_extractor_sampler_union_special_cases(metadata_registry):
"""Sampler functions whose names diverge from SAMPLER_NAMES entries."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler2")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler2"]
cases = [
("dpm_fast_function", "dpm_fast"),
("dpm_adaptive_function", "dpm_adaptive"),
("sample_unipc", "uni_pc"),
("sample_unipc_bh2", "uni_pc_bh2"),
("sample_dpmpp_2m_sde", "dpmpp_2m_sde"),
]
for i, (func_name, expected) in enumerate(cases):
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = _make_ksampler(func_name)
MetadataOverwriteExtractor.extract(f"ow-sampler-{i}", inputs, None, metadata)
params_by_node = {node_id: entry["parameters"] for node_id, entry in metadata[OVERWRITE].items()}
for i, (func_name, expected) in enumerate(cases):
assert params_by_node[f"ow-sampler-{i}"]["sampler"] == expected, func_name
metadata_registry.clear_metadata()
def test_metadata_overwrite_extractor_sampler_union_unrecognized_skipped(metadata_registry):
"""Unrecognized sampler functions should skip the field, not crash."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler3")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler3"]
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = _make_ksampler("my_custom_sampler_function")
MetadataOverwriteExtractor.extract("ow-sampler-unrec", inputs, None, metadata)
assert not metadata[OVERWRITE]
metadata_registry.clear_metadata()
def test_metadata_overwrite_extractor_sampler_union_no_sampler_function(metadata_registry):
"""Objects without a sampler_function (e.g. old KUNASampler classes) are skipped."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler4")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler4"]
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = SimpleNamespace()
MetadataOverwriteExtractor.extract("ow-sampler-nofn", inputs, None, metadata)
assert not metadata[OVERWRITE]
metadata_registry.clear_metadata()
def test_extract_generation_params_applies_overwrite(metadata_registry, populated_registry, monkeypatch):
"""overwrite values should replace inferred params in extract_generation_params."""
import py.metadata_collector.metadata_processor as mp
@@ -1046,3 +1613,213 @@ def test_fill_missing_metadata_fills_overwrite_for_muted_node(metadata_registry)
assert "ow-1" not in metadata.get(OVERWRITE, {})
metadata_registry.clear_metadata()
def test_krea_two_stage_sampler_prompt_and_params_collected(
metadata_registry, monkeypatch
):
"""KreaTwoStageSampler should be recognized as the primary sampler and
contribute the prompt, canonical sampling params, and final resolution."""
prompt_graph = {
"encode_pos": {
"class_type": "PromptLM",
"inputs": {"text": "krea masterpiece", "clip": ["clip", 0]},
},
"encode_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "low quality", "clip": ["clip", 0]},
},
"sampler": {
"class_type": "KreaTwoStageSampler",
"inputs": {
"seed": 42,
"handoff_percent": 16.67,
"stage1_steps": 52,
"stage1_cfg": 4.0,
"stage1_sampler_name": "euler",
"stage1_scheduler": "simple",
"stage2_steps": 12,
"stage2_cfg": 1.0,
"stage2_sampler_name": "euler",
"stage2_scheduler": "simple",
"final_width": 2048,
"final_height": 2048,
"upscale_method": "bislerp",
"positive": ["encode_pos", 0],
"negative": ["encode_neg", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
pos_conditioning = object()
neg_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("krea-two-stage")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_pos", "PromptLM", {"text": "krea masterpiece"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "PromptLM", [(pos_conditioning, "krea masterpiece")]
)
metadata_registry.record_node_execution(
"encode_neg", "CLIPTextEncode", {"text": "low quality"}, None
)
metadata_registry.update_node_execution(
"encode_neg", "CLIPTextEncode", [(neg_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"KreaTwoStageSampler",
{
"seed": 42,
"handoff_percent": 16.67,
"stage1_steps": 52,
"stage1_cfg": 4.0,
"stage1_sampler_name": "euler",
"stage1_scheduler": "simple",
"stage2_steps": 12,
"stage2_cfg": 1.0,
"stage2_sampler_name": "euler",
"stage2_scheduler": "simple",
"final_width": 2048,
"final_height": 2048,
"upscale_method": "bislerp",
"positive": pos_conditioning,
"negative": neg_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("krea-two-stage")
sampler_data = metadata[SAMPLING]["sampler"]
assert sampler_data["is_sampler"] is True
parameters = sampler_data["parameters"]
assert parameters["seed"] == 42
assert parameters["steps"] == 64
assert parameters["cfg"] == 4.0
assert parameters["sampler_name"] == "euler"
assert parameters["scheduler"] == "simple"
assert parameters["stage1_steps"] == 52
assert parameters["stage2_cfg"] == 1.0
assert metadata[SIZE]["sampler"] == {
"width": 2048,
"height": 2048,
"node_id": "sampler",
}
prompt_results = MetadataProcessor.match_conditioning_to_prompts(
metadata, "sampler"
)
assert prompt_results["prompt"] == "krea masterpiece"
assert prompt_results["negative_prompt"] == "low quality"
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "krea masterpiece"
assert params["negative_prompt"] == "low quality"
assert params["seed"] == 42
assert params["steps"] == 64
assert params["cfg_scale"] == 4.0
assert params["sampler"] == "euler"
assert params["scheduler"] == "simple"
assert params["size"] == "2048x2048"
def test_krea_three_stage_sampler_uses_stage1_canonical_fields(metadata_registry):
"""KreaThreeStageSampler reuses stage 1 settings for stage 3, so canonical
fields map from stage 1 and the total counts both sampling stages."""
metadata_registry.start_collection("krea-three-stage")
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
metadata_registry.record_node_execution(
"sampler",
"KreaThreeStageSampler",
{
"seed": 7,
"handoff_percent": 16.67,
"stage3_handoff_percent": 83.33,
"stage1_steps": 52,
"stage1_cfg": 4.0,
"stage1_sampler_name": "euler",
"stage1_scheduler": "simple",
"stage2_steps": 12,
"stage2_cfg": 1.0,
"stage2_sampler_name": "euler",
"stage2_scheduler": "simple",
"final_width": 1024,
"final_height": 2048,
"upscale_method": "bislerp",
"positive": object(),
"negative": object(),
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 8, 16))},
},
None,
)
metadata = metadata_registry.get_metadata("krea-three-stage")
sampler_data = metadata[SAMPLING]["sampler"]
assert sampler_data["is_sampler"] is True
parameters = sampler_data["parameters"]
assert parameters["seed"] == 7
assert parameters["stage3_handoff_percent"] == 83.33
assert parameters["steps"] == 64
assert parameters["cfg"] == 4.0
assert parameters["sampler_name"] == "euler"
assert parameters["scheduler"] == "simple"
# Final resolution takes precedence over the latent dimensions (64x128).
assert metadata[SIZE]["sampler"] == {
"width": 1024,
"height": 2048,
"node_id": "sampler",
}
def test_krea_dual_resolution_selector_extracts_size_from_outputs(
metadata_registry,
):
"""KreaDualResolutionSelector computes dimensions at runtime, so the base
resolution is recorded from its outputs in the update phase."""
metadata_registry.start_collection("krea-selector")
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
metadata_registry.record_node_execution(
"selector",
"KreaDualResolutionSelector",
{
"aspect_ratio": "1:1",
"base_megapixels": 1.0,
"final_megapixels": 2.0,
"multiple": 16,
"random_seed": 123,
},
None,
return_types=("INT", "INT", "INT", "INT", "INT"),
)
metadata_registry.update_node_execution(
"selector",
"KreaDualResolutionSelector",
[(1024, 1024, 2048, 2048, 123)],
return_types=("INT", "INT", "INT", "INT", "INT"),
)
metadata = metadata_registry.get_metadata("krea-selector")
assert metadata[SIZE]["selector"] == {
"width": 1024,
"height": 1024,
"node_id": "selector",
}
@@ -0,0 +1,179 @@
"""Tests for the Random Checkpoint/Unet Loader nodes' base-model filtering and
random-selection behavior.
"""
import pytest
from py.nodes.random_checkpoint_loader import RandomCheckpointLoaderLM
from py.nodes.random_unet_loader import RandomUNETLoaderLM
class _FakeCache:
def __init__(self, raw_data):
self.raw_data = raw_data
class _FakeScanner:
def __init__(self, raw_data, model_roots):
self._raw_data = raw_data
self._model_roots = model_roots
async def get_cached_data(self, force_refresh=False):
return _FakeCache(self._raw_data)
def get_model_roots(self):
return self._model_roots
@pytest.fixture
def base_model_library(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
illustrious = tmp_path / "illustrious.safetensors"
illustrious.write_bytes(b"x")
flux = tmp_path / "flux.safetensors"
flux.write_bytes(b"x")
missing = tmp_path / "missing.safetensors" # referenced but never created
raw_data = [
{
"sub_type": "checkpoint",
"file_path": str(illustrious),
"base_model": "Illustrious",
},
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "Flux.1 D"},
{
"sub_type": "checkpoint",
"file_path": str(missing),
"base_model": "SDXL 1.0",
},
{
"sub_type": "diffusion_model",
"file_path": str(flux),
"base_model": "Flux.1 D",
},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
return tmp_path
def test_checkpoint_names_drop_deleted_files(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
existing = tmp_path / "keep.safetensors"
existing.write_bytes(b"x")
deleted = tmp_path / "deleted.safetensors" # referenced but never created
raw_data = [
{"sub_type": "checkpoint", "file_path": str(existing)},
{"sub_type": "checkpoint", "file_path": str(deleted)},
# Wrong type must stay excluded by the sub_type filter.
{"sub_type": "diffusion_model", "file_path": str(existing)},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
assert RandomCheckpointLoaderLM._get_checkpoint_names() == ["keep.safetensors"]
def test_unet_names_drop_deleted_files(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
existing = tmp_path / "keep.safetensors"
existing.write_bytes(b"x")
deleted = tmp_path / "deleted.safetensors"
raw_data = [
{"sub_type": "diffusion_model", "file_path": str(existing)},
{"sub_type": "diffusion_model", "file_path": str(deleted)},
{"sub_type": "checkpoint", "file_path": str(existing)},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
assert RandomUNETLoaderLM._get_unet_names() == ["keep.safetensors"]
def test_checkpoint_names_empty_when_scanner_fails(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
def _boom():
raise RuntimeError("scanner not available")
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
assert RandomCheckpointLoaderLM._get_checkpoint_names() == []
def test_checkpoint_available_base_models(base_model_library):
# "SDXL 1.0" is excluded because its file no longer exists on disk.
assert RandomCheckpointLoaderLM._get_available_base_models() == [
"Any",
"Flux.1 D",
"Illustrious",
]
def test_checkpoint_names_filtered_by_base_model(base_model_library):
assert RandomCheckpointLoaderLM._get_checkpoint_names("Illustrious") == [
"illustrious.safetensors"
]
assert RandomCheckpointLoaderLM._get_checkpoint_names("Any") == [
"flux.safetensors",
"illustrious.safetensors",
]
def test_unet_available_base_models(base_model_library):
assert RandomUNETLoaderLM._get_available_base_models() == ["Any", "Flux.1 D"]
def test_load_checkpoint_random_selection_uses_pool(base_model_library, monkeypatch):
from py.nodes import random_checkpoint_loader as random_checkpoint_loader_module
monkeypatch.setattr(
random_checkpoint_loader_module,
"get_checkpoint_info_absolute",
lambda name: (str(base_model_library / name), {"file_path": name}),
)
monkeypatch.setattr(
random_checkpoint_loader_module.comfy.sd,
"load_checkpoint_guess_config",
lambda *a, **k: ("MODEL", "CLIP", "VAE", None),
raising=False,
)
node = RandomCheckpointLoaderLM()
result = node.load_checkpoint(
"ignored.safetensors", select_at_random=True, base_model="Illustrious"
)
# Only one checkpoint matches "Illustrious", so the random pick is deterministic here.
assert result[3] == "illustrious.safetensors"
def test_load_checkpoint_random_selection_raises_when_pool_empty(base_model_library):
node = RandomCheckpointLoaderLM()
with pytest.raises(FileNotFoundError, match="No checkpoints found"):
node.load_checkpoint(
"ignored.safetensors", select_at_random=True, base_model="SDXL 1.0"
)
def test_checkpoint_is_changed_forces_rerun_when_random():
assert RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=True, base_model="Any"
) != RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=True, base_model="Any"
)
assert RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=False, base_model="Any"
) == RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=False, base_model="Any"
)
+100
View File
@@ -593,3 +593,103 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch):
assert len(payload["updated"]) == 1
assert provider_calls == [[20]]
assert len(saved) == 1
def test_serialize_version_permanent_paid_is_not_early_access():
"""Permanent paid versions (is_paid, no end date) must not be flagged as
early access, mirroring _is_early_access_active in the update service."""
version = ModelVersionRecord(
version_id=7, name="v7", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control="Download",
paid_access=json.dumps({"permanent": True, "endsAt": None}), is_paid=True,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is False
assert serialized["isPaid"] is True
assert serialized["paidAccess"] == {"permanent": True, "endsAt": None}
def test_serialize_version_timed_paid_is_early_access():
"""Timed paid gates (endsAt in the future) stay flagged as early access."""
version = ModelVersionRecord(
version_id=8, name="v8", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at="2099-01-01T00:00:00.000Z", is_early_access=True,
usage_control="Download",
paid_access=json.dumps({"permanent": False, "endsAt": "2099-01-01T00:00:00.000Z"}),
is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is True
assert serialized["isPaid"] is False
def test_serialize_version_malformed_paid_access_does_not_crash():
"""A malformed paid_access row must degrade to None instead of failing
the whole versions-list response."""
version = ModelVersionRecord(
version_id=10, name="v10", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control=None,
paid_access="{not json", is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["paidAccess"] is None
assert serialized["isEarlyAccess"] is True
async def test_enrich_early_access_details_skips_permanent_paid(monkeypatch):
"""Permanent paid versions must not trigger per-version CivitAI fetches in
_enrich_early_access_details: they are not early access and can never get
an end time, so enriching them is wasted API traffic."""
record = ModelUpdateRecord(
model_type="lora",
model_id=1,
versions=[
ModelVersionRecord(
version_id=100, name="paid", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access='{"permanent": true, "endsAt": null}', is_paid=True,
),
ModelVersionRecord(
version_id=200, name="ea", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access=None, is_paid=False,
),
],
last_checked_at=1.0,
should_ignore_model=False,
)
fetched: list[int] = []
async def fake_version_info(version_id: str):
fetched.append(int(version_id))
return {"earlyAccessEndsAt": "2099-01-01T00:00:00.000Z"}, None
provider = SimpleNamespace(get_model_version_info=fake_version_info)
async def metadata_selector(name):
assert name == "civitai_api"
return provider
handler = ModelUpdateHandler(
service=DummyService(SimpleNamespace(raw_data=[], version_index={})),
update_service=SimpleNamespace(),
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
enriched = await handler._enrich_early_access_details(record)
# Only the timed EA version (200) is fetched; the permanent paid one (100) is skipped.
assert fetched == [200]
enriched_map = {v.version_id: v for v in enriched.versions}
assert enriched_map[200].early_access_ends_at == "2099-01-01T00:00:00.000Z"
assert enriched_map[100].early_access_ends_at is None
+6 -2
View File
@@ -82,7 +82,9 @@ class StubUpdateService:
self.bulk_calls = []
self.bulk_error = bulk_error
async def has_updates_bulk(self, model_type, model_ids, hide_early_access: bool = False):
async def has_updates_bulk(
self, model_type, model_ids, hide_early_access: bool = False, hide_paid: bool = False
):
self.bulk_calls.append((model_type, list(model_ids)))
if self.bulk_error:
raise RuntimeError("bulk failure")
@@ -94,7 +96,9 @@ class StubUpdateService:
results[model_id] = result
return results
async def has_update(self, model_type, model_id, hide_early_access: bool = False):
async def has_update(
self, model_type, model_id, hide_early_access: bool = False, hide_paid: bool = False
):
self.calls.append((model_type, model_id))
result = self.decisions.get(model_id, False)
if isinstance(result, Exception):
+218 -4
View File
@@ -123,10 +123,7 @@ def metadata_provider(monkeypatch):
class DummyProvider:
def __init__(self):
self.calls = []
async def get_model_version(self, model_id, model_version_id):
self.calls.append((model_id, model_version_id))
return {
self.payload = {
"id": 42,
"model": {"type": "LoRA", "tags": ["fantasy"]},
"baseModel": "BaseModel",
@@ -141,6 +138,10 @@ def metadata_provider(monkeypatch):
],
}
async def get_model_version(self, model_id, model_version_id):
self.calls.append((model_id, model_version_id))
return self.payload
provider = DummyProvider()
monkeypatch.setattr(
download_manager,
@@ -233,6 +234,217 @@ async def test_successful_download_uses_defaults(
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
@pytest.mark.asyncio
async def test_download_accepts_enhancement_lora_primary_file(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A version whose only file has type 'Enhancement LoRA' (Anima/AIR
image-editing LoRAs) must download previously failed with
"No suitable file found in metadata" because the type was missing from
the primary-file weights allowlist."""
manager = DownloadManager()
metadata_provider.payload = {
"id": 3219121,
"model": {"type": "LORA", "tags": ["style"]},
"baseModel": "Anima",
"creator": {"username": "Deskup"},
"files": [
{
"id": 3100968,
"type": "Enhancement LoRA",
"primary": True,
"name": "deskup-anima-edit-general.safetensors",
"sizeKB": 358501.13,
"downloadUrl": "https://example.invalid/deskup-anima-edit-general.safetensors",
}
],
}
captured = {}
async def fake_execute_download(self, **kwargs):
captured.update(
{
"download_urls": kwargs["download_urls"],
"model_type": kwargs["model_type"],
}
)
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_id=2850692,
model_version_id=3219121,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["model_type"] == "lora"
assert captured["download_urls"] == [
"https://example.invalid/deskup-anima-edit-general.safetensors"
]
@pytest.mark.asyncio
async def test_download_falls_back_to_civitai_primary_flag_regardless_of_type(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""If no weights-type file exists, trust CivitAI's `primary` flag on any
file mirrors CivitAI's getPrimaryFile() which never excludes a file by
type."""
manager = DownloadManager()
metadata_provider.payload = {
"id": 77,
"model": {"type": "LORA", "tags": ["concept"]},
"baseModel": "Anima",
"creator": {"username": "Author"},
"files": [
{
"id": 100,
"type": "Other",
"primary": True,
"name": "custom-type-lora.safetensors",
"downloadUrl": "https://example.invalid/custom-type-lora.safetensors",
}
],
}
captured = {}
async def fake_execute_download(self, **kwargs):
captured["download_urls"] = kwargs["download_urls"]
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=77,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["download_urls"] == [
"https://example.invalid/custom-type-lora.safetensors"
]
@pytest.mark.asyncio
async def test_download_prefers_weights_file_over_non_weights_primary(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A Config/Archive-type primary must never replace an existing weights
file the weights file wins even without the primary flag."""
manager = DownloadManager()
metadata_provider.payload = {
"id": 78,
"model": {"type": "LORA", "tags": ["concept"]},
"baseModel": "BaseModel",
"creator": {"username": "Author"},
"files": [
{
"id": 201,
"type": "Config",
"primary": True,
"name": "config.json",
"downloadUrl": "https://example.invalid/config.json",
},
{
"id": 202,
"type": "Model",
"primary": False,
"name": "weights.safetensors",
"downloadUrl": "https://example.invalid/weights.safetensors",
},
],
}
captured = {}
async def fake_execute_download(self, **kwargs):
captured["download_urls"] = kwargs["download_urls"]
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=78,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["download_urls"] == [
"https://example.invalid/weights.safetensors"
]
@pytest.mark.asyncio
async def test_download_keeps_save_dir_when_use_save_dir_as_root(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""use_default_paths with use_save_dir_as_root resolves the template under
the provided save_dir instead of switching to the default root."""
manager = DownloadManager()
captured = {}
async def fake_execute_download(
self,
*,
download_urls,
save_dir,
metadata,
version_info,
relative_path,
progress_callback,
model_type,
download_id,
transfer_backend=None,
):
captured.update(
{
"save_dir": Path(save_dir),
"relative_path": relative_path,
"model_type": model_type,
}
)
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
custom_root = tmp_path / "custom_root"
result = await manager.download_from_civitai(
model_version_id=99,
save_dir=str(custom_root),
use_default_paths=True,
use_save_dir_as_root=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["relative_path"] == "MappedModel/fantasy"
assert captured["save_dir"] == custom_root / "MappedModel" / "fantasy"
assert captured["model_type"] == "lora"
@pytest.mark.asyncio
async def test_successful_download_schedules_auto_example_images(
monkeypatch, scanners, metadata_provider, tmp_path
@@ -618,6 +830,7 @@ async def test_resume_download_restores_persisted_aria2_task(monkeypatch, tmp_pa
use_default_paths=False,
source=None,
file_params=None,
use_save_dir_as_root=False,
):
created.update(
{
@@ -1037,6 +1250,7 @@ async def test_download_uses_captured_backend_when_settings_change(
transfer_backend="python",
source=None,
file_params=None,
use_save_dir_as_root=False,
):
captured["transfer_backend"] = transfer_backend
return {"success": True}
+177 -1
View File
@@ -59,7 +59,17 @@ class NotFoundProvider:
return {}
def make_version(version_id, *, in_library, base_model=None, should_ignore=False):
def make_version(
version_id,
*,
in_library,
base_model=None,
should_ignore=False,
early_access_ends_at=None,
is_early_access=False,
is_paid=False,
paid_access=None,
):
return ModelVersionRecord(
version_id=version_id,
name=None,
@@ -69,6 +79,10 @@ def make_version(version_id, *, in_library, base_model=None, should_ignore=False
preview_url=None,
is_in_library=in_library,
should_ignore=should_ignore,
early_access_ends_at=early_access_ends_at,
is_early_access=is_early_access,
is_paid=is_paid,
paid_access=paid_access,
)
@@ -622,3 +636,165 @@ async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path):
# has_update must be True (version 20 > max_in_library=15)
assert record.has_update() is True
def test_extract_single_version_paid_access_timed(tmp_path):
"""A timed paidAccess gate (permanent=False + future endsAt) is detected
as early access while availability stays 'Public'."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
entry = {
"id": 42,
"name": "v1 paid",
"availability": "Public",
"paidAccess": {
"permanent": False,
"endsAt": "2026-08-22T18:30:00.000Z",
},
"files": [],
"images": [],
}
version = service._extract_single_version(entry, index=0)
assert version is not None
assert version.is_early_access is True
assert version.early_access_ends_at == "2026-08-22T18:30:00.000Z"
assert version.is_paid is False
assert version.paid_access is not None
def test_extract_single_version_paid_access_permanent(tmp_path):
"""A permanent paidAccess gate (permanent=True, no endsAt) is detected and
flagged as paid but is NOT early access and carries no end date."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
entry = {
"id": 42,
"name": "v1 paid",
"availability": "Public",
"paidAccess": {"permanent": True, "endsAt": None},
"files": [],
"images": [],
}
version = service._extract_single_version(entry, index=0)
assert version is not None
assert version.is_early_access is False
assert version.is_paid is True
assert version.early_access_ends_at is None
assert version.paid_access is not None
def test_normalize_paid_access_accepts_json_string():
"""The by-hash enrichment path may hand paidAccess to _normalize_paid_access
as a JSON string; both the permanent and timed shapes must normalize."""
service = ModelUpdateService.__new__(ModelUpdateService)
permanent = ModelUpdateService._normalize_paid_access(
'{"permanent": true, "endsAt": null}'
)
assert permanent == {"permanent": True, "endsAt": None}
timed = ModelUpdateService._normalize_paid_access(
'{"permanent": false, "endsAt": "2026-08-22T18:30:00.000Z"}'
)
assert timed == {"permanent": False, "endsAt": "2026-08-22T18:30:00.000Z"}
empty = ModelUpdateService._normalize_paid_access(
'{"permanent": false, "endsAt": null}'
)
assert empty is None
malformed = ModelUpdateService._normalize_paid_access("{not json")
assert malformed is None
def test_has_update_for_base_hide_paid():
"""hide_paid also suppresses permanent paid versions in the same-base
update path (has_update_for_base)."""
record = make_record(
make_version(5, in_library=True, base_model="illustrious"),
make_version(
7,
in_library=False,
base_model="illustrious",
is_paid=True,
paid_access='{"permanent": true, "endsAt": null}',
),
)
assert record.has_update_for_base(5, "illustrious") is True
assert record.has_update_for_base(5, "illustrious", hide_paid=True) is False
def test_has_update_hide_paid():
"""hide_paid suppresses update flags raised by a permanent paid version."""
record = make_record(
make_version(5, in_library=True),
make_version(
7,
in_library=False,
is_paid=True,
paid_access='{"permanent": true, "endsAt": null}',
),
)
assert record.has_update() is True
assert record.has_update(hide_paid=True) is False
def test_has_update_hide_early_access_paid_timed():
"""hide_early_access suppresses a newer timed paidAccess version."""
record = make_record(
make_version(5, in_library=True),
make_version(
7,
in_library=False,
is_early_access=True,
early_access_ends_at="2099-01-01T00:00:00Z",
),
)
assert record.has_update() is True
assert record.has_update(hide_early_access=True) is False
def test_build_record_from_remote_preserves_paid_fields(tmp_path):
"""_build_record_from_remote must carry paid_access/is_paid from the
parsed remote versions into the rebuilt record, or the refresh path
silently drops paid data before persistence."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
remote_version = ModelVersionRecord(
version_id=7,
name="v7",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
early_access_ends_at=None,
is_early_access=True,
usage_control="Download",
paid_access='{"permanent": true, "endsAt": null}',
is_paid=True,
)
record = service._build_record_from_remote(
model_type="lora",
model_id=123,
local_versions=[],
remote_versions=[remote_version],
existing=None,
timestamp=1.0,
)
rebuilt = record.versions[0]
assert rebuilt.paid_access == '{"permanent": true, "endsAt": null}'
assert rebuilt.is_paid is True
+584 -4
View File
@@ -1047,6 +1047,106 @@ async def test_get_paginated_data_sorting(recipe_scanner):
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
@pytest.mark.asyncio
async def test_get_paginated_data_random_sort(recipe_scanner):
scanner, _ = recipe_scanner
# Add test recipes
for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
await scanner.add_recipe(
{
"id": rid,
"title": title,
"created_date": 10.0,
"loras": [{}],
"file_path": f"{rid.lower()}.png",
}
)
await asyncio.sleep(0)
await _wait_for_resort(scanner)
# Same seed -> same order (deterministic, stable pagination)
res1 = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="random:seed123"
)
res2 = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="random:seed123"
)
ids1 = [i["id"] for i in res1["items"]]
ids2 = [i["id"] for i in res2["items"]]
assert ids1 == ids2
assert sorted(ids1) == ["A", "B", "C"]
# Plain "random" (no seed) also returns the full set
res3 = await scanner.get_paginated_data(page=1, page_size=10, sort_by="random")
assert sorted(i["id"] for i in res3["items"]) == ["A", "B", "C"]
# Stable pagination: page1 + page2 with the same seed concatenate to the
# full seeded order, with no duplicates across pages
p1 = await scanner.get_paginated_data(
page=1, page_size=2, sort_by="random:seed123"
)
p2 = await scanner.get_paginated_data(
page=2, page_size=2, sort_by="random:seed123"
)
combined = [i["id"] for i in p1["items"]] + [i["id"] for i in p2["items"]]
assert combined == ids1
assert len(set(combined)) == 3
@pytest.mark.asyncio
async def test_get_paginated_data_opened_sort(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner
for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
await scanner.add_recipe(
{
"id": rid,
"title": title,
"created_date": 10.0,
"loras": [{}],
"file_path": f"{rid.lower()}.png",
}
)
await asyncio.sleep(0)
await _wait_for_resort(scanner)
class _FakeStats:
def get_opened_map(self):
return {"B": 300.0, "C": 200.0}
monkeypatch.setattr(
"py.services.recipe_scanner.RecipeOpenStats", lambda: _FakeStats()
)
# Never-opened A is hidden from the view; B (300) > C (200)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
assert [i["id"] for i in res["items"]] == ["B", "C"]
assert res["total"] == 2
# ASC: C (200) < B (300)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:asc")
assert [i["id"] for i in res["items"]] == ["C", "B"]
# Plain "opened" (no direction) behaves like desc by default
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened")
assert [i["id"] for i in res["items"]] == ["B", "C"]
# When nothing was opened the view is empty (not a fallback reorder)
class _EmptyStats:
def get_opened_map(self):
return {}
monkeypatch.setattr(
"py.services.recipe_scanner.RecipeOpenStats", lambda: _EmptyStats()
)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
assert res["items"] == []
assert res["total"] == 0
async def test_build_image_id_map_filters_correctly(recipe_scanner):
"""Only recipes with valid CivitAI source_path appear in image_id_map.
@@ -1783,9 +1883,10 @@ async def test_is_rematch_candidate_rejects_healthy_entry(tmp_path: Path):
assert not scanner._is_rematch_candidate({"hash": "abc", "file_name": "m.safetensors"})
async def test_is_rematch_candidate_rejects_no_identifier(tmp_path: Path):
async def test_is_rematch_candidate_file_name_only_is_identifier(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
assert not scanner._is_rematch_candidate({"isDeleted": True, "file_name": "m.safetensors"})
# file_name alone is now an identifier (enables the L4 filename fallback)
assert scanner._is_rematch_candidate({"isDeleted": True, "file_name": "m.safetensors"})
assert not scanner._is_rematch_candidate({"isDeleted": True})
@@ -2120,6 +2221,481 @@ async def test_match_rematch_type_gate_lora_accepts_lora_typed_item(tmp_path: Pa
assert matched is not None
# _match_rematch_entry — L4 filename fallback (conservative)
async def test_match_rematch_entry_l4_filename_hit(tmp_path: Path):
item = _rematch_item(
sha256=("T1" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_filename_normalized_key(tmp_path: Path):
# case, path and extension differences are normalized on both sides
item = _rematch_item(
sha256=("T2" * 32).lower(),
sub_type="lora",
base_model="SDXL",
file_name="My_Mix.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "subdir/my_mix", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="sdxl",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_dotted_stem_no_collision(tmp_path: Path):
# "my.mix" (dotted stem) and "my" are distinct names — splitext-style
# stripping would collapse both to "my" and bind the wrong model as a
# unique candidate.
item = _rematch_item(
sha256=("T2A" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="my.mix",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "my", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_extension_bearing_entry_reconciled(tmp_path: Path):
# extension-bearing entry names reconcile with extensionless items
item = _rematch_item(
sha256=("T2B" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="my.mix.v1",
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "my.mix.v1.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_base_model_mismatch_rejects(tmp_path: Path):
item = _rematch_item(
sha256=("T3" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SDXL",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_recipe_base_model_unknown_rejects(tmp_path: Path):
item = _rematch_item(
sha256=("T4" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_item_base_model_unknown_rejects(tmp_path: Path):
item = _rematch_item(
sha256=("T5" * 32).lower(), sub_type="lora", file_name="detail.safetensors"
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_ambiguous_same_base_model_rejects(tmp_path: Path):
items = [
_rematch_item(
sha256=("T6" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
),
_rematch_item(
sha256=("T7" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
),
]
scanner, _, _ = _make_rematch_scanner(items, [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_ambiguity_resolved_by_base_model(tmp_path: Path):
sdxl_item = _rematch_item(
sha256=("T8" * 32).lower(),
sub_type="lora",
base_model="SDXL",
file_name="detail.safetensors",
)
sd15_item = _rematch_item(
sha256=("T9" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([sdxl_item, sd15_item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SDXL",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_type_gate_rejects(tmp_path: Path):
# a checkpoint-typed item with a matching name must not satisfy a lora entry
item = _rematch_item(
sha256=("TA" * 32).lower(),
sub_type="checkpoint",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_checkpoint_slot_rejects_type_less_candidate(
tmp_path: Path,
):
# lora raw items often carry no sub_type; an unknown-type candidate must
# not be bound into a checkpoint slot
item = _rematch_item(
sha256=("TA1" * 32).lower(),
base_model="SD 1.5",
file_name="realistic.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "realistic.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=True,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_checkpoint_slot_accepts_typed_candidate(
tmp_path: Path,
):
item = _rematch_item(
sha256=("TA2" * 32).lower(),
sub_type="checkpoint",
base_model="SD 1.5",
file_name="realistic.safetensors",
)
scanner, _, checkpoint = _make_rematch_scanner([], [item], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "realistic.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=True,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is checkpoint._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_lora_slot_accepts_type_less_candidate(tmp_path: Path):
# asymmetry: lora slots still accept type-less candidates (the norm for
# lora raw items); checkpoint items always carry sub_type, so the type
# gate alone protects the reverse direction
item = _rematch_item(
sha256=("TA3" * 32).lower(), base_model="SD 1.5", file_name="detail.safetensors"
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_rematch_l4_entry_base_model_preferred_over_recipe(tmp_path: Path, monkeypatch):
# a Pony lora inside an SD 1.5 recipe matches via its own baseModel
item = _rematch_item(
sha256=("TB1" * 32).lower(),
sub_type="lora",
base_model="Pony",
file_name="pony.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_fts(scanner, monkeypatch)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [
{"file_name": "pony.safetensors", "isDeleted": True, "baseModel": "Pony"}
],
}
rematched, _errors, details = await scanner._rematch_single_recipe(
recipe, {}, {}, filename_cache
)
assert rematched == 1
assert details["matched"][0]["match_level"] == "L4"
assert recipe["loras"][0]["hash"] == ("TB1" * 32).lower()
assert saved == [recipe]
async def test_rematch_l4_entry_base_model_missing_falls_back_to_recipe(
tmp_path: Path, monkeypatch
):
# without entry-level baseModel the recipe-level gate governs: a Pony
# candidate must not match an SD 1.5 recipe
item = _rematch_item(
sha256=("TB2" * 32).lower(),
sub_type="lora",
base_model="Pony",
file_name="pony.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_fts(scanner, monkeypatch)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [{"file_name": "pony.safetensors", "isDeleted": True}],
}
rematched, _errors, details = await scanner._rematch_single_recipe(
recipe, {}, {}, filename_cache
)
assert rematched == 0
assert details["unresolved"] == [{"type": "lora", "entry": "pony.safetensors"}]
async def test_match_rematch_entry_l4_no_filename_hit(tmp_path: Path):
item = _rematch_item(
sha256=("TB" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="other.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "missing.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_entry_without_file_name_skipped(tmp_path: Path):
item = _rematch_item(
sha256=("TC" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"isDeleted": True, "hash": ""},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l1_wins_over_l4_filename(tmp_path: Path):
# a valid stored hash resolves via L1 even when the filename would match
sha256 = ("TD" * 32).lower()
l1_item = _rematch_item(
sha256=sha256, sub_type="lora", base_model="SD 1.5", file_name="l1-item.safetensors"
)
l4_item = _rematch_item(
sha256=("TE" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
local_cache = await scanner.build_local_hash_cache()
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"hash": sha256, "file_name": "detail.safetensors", "isDeleted": True},
local_cache,
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L1"
# _build_local_filename_cache
async def test_build_local_filename_cache_normalized_keys_sha256_only(tmp_path: Path):
lora_items = [
_rematch_item(sha256=("TF" * 32).lower(), file_name="Case.Mix.safetensors"),
_rematch_item(sha256="", file_name="no-hash.safetensors"), # skipped
]
checkpoint_items = [
_rematch_item(
sha256=("TG" * 32).lower(), sub_type="checkpoint", file_name="Base.safetensors"
)
]
scanner, lora, checkpoint = _make_rematch_scanner(
lora_items, checkpoint_items, tmp_path
)
result = await scanner._build_local_filename_cache()
assert set(result) == {"case.mix", "base"}
assert len(result["case.mix"]) == 1
assert result["case.mix"][0] is lora._cache.raw_data[0]
# checkpoint items are indexed too (type-blind cache)
assert result["base"][0] is checkpoint._cache.raw_data[0]
# _build_rematch_autov3_cache
@@ -2989,6 +3565,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
recipe: Dict[str, Any],
local_cache: dict[str, Any],
autov3_cache: dict[str, Any],
filename_cache=None,
) -> tuple[int, int, dict[str, Any]]:
if recipe.get("id") == "boom":
raise RuntimeError("kaboom")
@@ -3046,12 +3623,13 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
recipe: Dict[str, Any],
local_cache: dict[str, Any],
autov3_cache: dict[str, Any],
) -> tuple[int, int]:
filename_cache=None,
) -> tuple[int, int, dict[str, Any]]:
nonlocal entered
if recipe.get("id") == "r0":
entered = True
await release.wait()
return await original(recipe, local_cache, autov3_cache)
return await original(recipe, local_cache, autov3_cache, filename_cache)
monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
@@ -3171,6 +3749,8 @@ async def test_rematch_bulk_generic_exception_continues(tmp_path: Path, monkeypa
autov3_cache: dict[str, Any],
*,
is_checkpoint: bool,
filename_cache=None,
recipe_base_model=None,
) -> Any:
nonlocal calls
calls += 1
+159
View File
@@ -0,0 +1,159 @@
import asyncio
import contextlib
import json
from pathlib import Path
import pytest
from py.utils import recipe_open_stats as stats_module
from py.utils.recipe_open_stats import RecipeOpenStats
async def _finalize(tasks) -> None:
for task in tasks:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
RecipeOpenStats._instance = None
def _prepare(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
RecipeOpenStats._instance = None
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
stats_module, "get_settings_dir", lambda create=True: str(settings_dir)
)
created_tasks = []
real_create_task = stats_module.asyncio.create_task
def _track_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
monkeypatch.setattr(stats_module.asyncio, "create_task", _track_task)
return RecipeOpenStats(), created_tasks, settings_dir
async def _wait_for_save(stats_file: Path) -> None:
for _ in range(100):
if stats_file.exists():
return
await asyncio.sleep(0.01)
raise AssertionError("Recipe open stats file was never written")
@pytest.mark.asyncio
async def test_record_open_persists_timestamp(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("abc-123")
await _wait_for_save(stats_file)
data = json.loads(stats_file.read_text(encoding="utf-8"))
assert isinstance(data["abc-123"], float)
await _finalize(tasks)
@pytest.mark.asyncio
async def test_record_open_updates_existing_entry(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("r1")
await _wait_for_save(stats_file)
first = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
await asyncio.sleep(0.01)
stats.record_open("r1")
await stats.save_stats(force=True)
second = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
assert second > first
await _finalize(tasks)
@pytest.mark.asyncio
async def test_get_opened_map_reloads_on_file_change(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("r1")
await _wait_for_save(stats_file)
stats_file.write_text(json.dumps({"r2": 500.0}), encoding="utf-8")
opened_map = stats.get_opened_map()
assert opened_map == {"r2": 500.0}
await _finalize(tasks)
@pytest.mark.asyncio
async def test_save_merges_entries_written_by_another_process(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("r1")
await _wait_for_save(stats_file)
first_ts = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
# Another process writes its own entry plus a newer timestamp for r1
stats_file.write_text(
json.dumps({"r1": first_ts + 100000.0, "r2": 500.0}), encoding="utf-8"
)
stats.record_open("r3")
await stats.save_stats(force=True)
data = json.loads(stats_file.read_text(encoding="utf-8"))
# r2 from the other process survives; r1 keeps the newer disk timestamp;
# r3 from this process is added
assert data["r1"] == first_ts + 100000.0
assert data["r2"] == 500.0
assert isinstance(data["r3"], float)
await _finalize(tasks)
@pytest.mark.asyncio
async def test_get_opened_map_returns_copy(tmp_path, monkeypatch):
stats, tasks, _ = _prepare(tmp_path, monkeypatch)
stats.record_open("r1")
opened_map = stats.get_opened_map()
opened_map["injected"] = 1.0
assert "injected" not in stats.get_opened_map()
await _finalize(tasks)
@pytest.mark.asyncio
async def test_missing_stats_file_returns_empty_map(tmp_path, monkeypatch):
stats, tasks, _ = _prepare(tmp_path, monkeypatch)
assert stats.get_opened_map() == {}
await _finalize(tasks)
@pytest.mark.asyncio
async def test_save_stats_skips_when_not_dirty(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
assert await stats.save_stats() is False
assert not stats_file.exists()
await _finalize(tasks)
@pytest.mark.asyncio
async def test_load_ignores_corrupt_file(tmp_path, monkeypatch):
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats_file.parent.mkdir(parents=True, exist_ok=True)
stats_file.write_text("{not valid json", encoding="utf-8")
monkeypatch.setattr(
stats_module, "get_settings_dir", lambda create=True: str(settings_dir)
)
RecipeOpenStats._instance = None
stats = RecipeOpenStats()
assert stats.get_opened_map() == {}
@@ -78,7 +78,17 @@ const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false
}
const onInput = () => {
const onInput = (event: Event) => {
// A clear via execCommand captures the full-text selection in the browser's
// undo entry; Ctrl+Z restores the content together with that selection.
// Collapse the caret so the restored text is not left selected.
if ((event as InputEvent).inputType === 'historyUndo') {
const ta = textareaRef.value
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
ta.setSelectionRange(ta.value.length, ta.value.length)
}
}
// Update hasText state
updateHasTextState()
@@ -156,20 +166,44 @@ const setupWidgetOnSetValue = () => {
}
}
/**
* Clear the textarea contents.
*
* Uses a trusted editing command (execCommand: select all + replace with
* empty string) so the browser records the clear as an undoable edit
* Ctrl+Z with focus in the textarea restores the cleared text. Falls back
* to a plain programmatic clear when execCommand is unavailable (e.g. jsdom
* test environment), which is not undoable via native Ctrl+Z.
*/
const clearText = () => {
if (textareaRef.value) {
textareaRef.value.value = ''
hasText.value = false
textareaRef.value.focus()
// Trigger callback with empty value
if (typeof props.widget.callback === 'function') {
props.widget.callback('')
}
// Dispatch input event to ensure autocomplete handles the change
textareaRef.value.dispatchEvent(new Event('input'))
const ta = textareaRef.value
if (!ta || ta.value.length === 0) return
// Select all + replace via a trusted edit command so the browser pushes an
// undo entry that restores the full previous content.
ta.focus()
ta.setSelectionRange(0, ta.value.length)
let ok = false
try {
// Guarded for engines without execCommand (jsdom); some engines also
// throw instead of returning false for unsupported commands.
ok = typeof document.execCommand === 'function' && document.execCommand('insertText', false, '')
} catch {
ok = false
}
if (ok) {
// execCommand fired a trusted 'input' event onInput already synced
// hasText, called the widget callback, and notified the autocomplete.
hasText.value = false
return
}
// Fallback: execCommand unavailable (jsdom / unsupported browser) plain
// programmatic clear. The dispatched input event keeps onInput, the widget
// callback, and the autocomplete in sync.
ta.value = ''
ta.dispatchEvent(new Event('input'))
}
onMounted(() => {
@@ -0,0 +1,136 @@
/**
* Tests for AutocompleteTextWidget clear button behavior.
*
* The clear button must clear the textarea through a trusted editing command
* (execCommand) so the browser records an undo entry and Ctrl+Z (with focus
* in the textarea) can restore the cleared content. jsdom's execCommand is a
* no-op that returns false, so the fallback manual-clear path is exercised by
* default; the execCommand path is covered by emulating the browser edit.
*/
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi, afterEach } from 'vitest'
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
function createMockWidget() {
return {
callback: vi.fn(),
onSetValue: undefined,
inputEl: undefined,
metadataWidget: undefined,
name: 'text',
}
}
function mountWidget() {
const widget = createMockWidget()
const node = { id: 1 }
const wrapper = shallowMount(AutocompleteTextWidget, {
props: { widget, node, modelType: 'prompt' },
// Attach to the document so jsdom implements real focus behavior
attachTo: document.body,
})
return { wrapper, widget }
}
afterEach(() => {
document.body.innerHTML = ''
delete (document as unknown as { execCommand?: unknown }).execCommand
})
describe('AutocompleteTextWidget clear button', () => {
it('is hidden when the textarea is empty and appears once text is entered', async () => {
const { wrapper } = mountWidget()
expect(wrapper.find('.clear-button').exists()).toBe(false)
await wrapper.find('textarea').setValue('hello <lora:foo:1>')
expect(wrapper.find('.clear-button').exists()).toBe(true)
})
it('clears the textarea via the fallback path when execCommand is unavailable', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea')
await textarea.setValue('hello <lora:foo:1>')
expect(widget.callback).toHaveBeenLastCalledWith('hello <lora:foo:1>')
// jsdom does not define document.execCommand at all, so the availability
// guard fails and the fallback manual clear runs: value reset + synthetic
// input event.
await wrapper.find('.clear-button').trigger('click')
expect((textarea.element as HTMLTextAreaElement).value).toBe('')
expect(wrapper.find('.clear-button').exists()).toBe(false)
expect(widget.callback).toHaveBeenLastCalledWith('')
// Focus returns to the textarea so Ctrl+Z can trigger native undo
expect(document.activeElement).toBe(textarea.element)
})
it('clears through a trusted execCommand edit so the browser records an undo entry', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea')
await textarea.setValue('hello world')
widget.callback.mockClear()
// Emulate Chromium: replace the selection with the given text, then fire
// a trusted input event that Vue and the autocomplete listeners observe.
// jsdom has no document.execCommand, so define it for this test.
const execMock = vi.fn((_cmd: string, _showUI: boolean, value: string) => {
const ta = document.activeElement as HTMLTextAreaElement | null
if (!ta || ta.tagName !== 'TEXTAREA') return false
ta.value = String(value ?? '')
ta.dispatchEvent(new Event('input', { bubbles: true }))
return true
})
Object.defineProperty(document, 'execCommand', { configurable: true, value: execMock })
await wrapper.find('.clear-button').trigger('click')
expect(execMock).toHaveBeenCalledWith('insertText', false, '')
expect((textarea.element as HTMLTextAreaElement).value).toBe('')
// Callback is driven by the trusted input event (exactly once, no double call)
expect(widget.callback).toHaveBeenCalledTimes(1)
expect(widget.callback).toHaveBeenCalledWith('')
expect(wrapper.find('.clear-button').exists()).toBe(false)
})
it('collapses the selection when Ctrl+Z restores the cleared text', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea')
const ta = textarea.element as HTMLTextAreaElement
await textarea.setValue('hello world')
widget.callback.mockClear()
// Clear via the trusted edit path (emulated Chromium)
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn(() => {
ta.value = ''
ta.dispatchEvent(new Event('input', { bubbles: true }))
return true
}),
})
await wrapper.find('.clear-button').trigger('click')
// Simulate the browser's undo: restore the text and the captured
// full-text selection, then fire the historyUndo input event
ta.value = 'hello world'
ta.setSelectionRange(0, ta.value.length)
ta.dispatchEvent(
Object.assign(new Event('input', { bubbles: true }), { inputType: 'historyUndo' })
)
await nextTick()
expect(ta.value).toBe('hello world')
// The restored text must not remain selected — caret collapsed to the end
expect(ta.selectionStart).toBe(ta.value.length)
expect(ta.selectionEnd).toBe(ta.value.length)
expect(wrapper.find('.clear-button').exists()).toBe(true)
expect(widget.callback).toHaveBeenLastCalledWith('hello world')
})
})
+6 -25
View File
@@ -33,14 +33,14 @@ const TAG_COMMANDS = {
'/embedding': { type: 'embedding', label: 'Embeddings' },
...WILDCARD_COMMANDS,
// Autocomplete toggle commands - only show one based on current state
'/ac': {
'/autocomplete': {
type: 'toggle_setting',
settingId: 'loramanager.prompt_tag_autocomplete',
value: true,
label: 'Autocomplete: ON',
condition: () => !getPromptTagAutocompletePreference()
},
'/noac': {
'/noautocomplete': {
type: 'toggle_setting',
settingId: 'loramanager.prompt_tag_autocomplete',
value: false,
@@ -50,26 +50,7 @@ const TAG_COMMANDS = {
};
// Command definitions for LoRA active-filters search
// Aliases (/activefilters, /noactivefilters) mirror /emb ↔ /embedding
const LORAS_COMMANDS = {
'/af': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
label: 'Active Filters: ON',
feedbackSummary: 'Active Filters Search: ON',
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
condition: () => !getLoraActiveFiltersAutocompletePreference()
},
'/noaf': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: false,
label: 'Active Filters: OFF',
feedbackSummary: 'Active Filters Search: OFF',
feedbackDetail: 'LoRA autocomplete searches the full library again.',
condition: () => getLoraActiveFiltersAutocompletePreference()
},
'/activefilters': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
@@ -761,7 +742,7 @@ class AutoComplete {
searchTerm = (match[1] || '').trim();
}
// For loras model type, check if we're in command mode (/af, /noaf)
// For loras model type, check if we're in command mode (/activefilters, /noactivefilters)
if (this.modelType === 'loras') {
const commandResult = this._parseCommandInput(rawSearchTerm);
@@ -773,7 +754,7 @@ class AutoComplete {
this._showCommandList(commandResult.commandFilter);
return;
} else if (commandResult.command?.type === 'toggle_setting') {
// Handle toggle setting command (/af, /noaf)
// Handle toggle setting command (/activefilters, /noactivefilters)
this._handleToggleSettingCommand(commandResult.command);
return;
} else if (commandResult.command) {
@@ -813,7 +794,7 @@ class AutoComplete {
this._showCommandList(commandResult.commandFilter);
return;
} else if (commandResult.command?.type === 'toggle_setting') {
// Handle toggle setting command (/ac, /noac)
// Handle toggle setting command (/autocomplete, /noautocomplete)
this._handleToggleSettingCommand(commandResult.command);
return;
} else if (commandResult.command) {
@@ -2866,7 +2847,7 @@ class AutoComplete {
}
/**
* Handle toggle setting command (/ac, /noac)
* Handle toggle setting command (e.g., /autocomplete, /activefilters)
* @param {Object} command - The toggle command with settingId and value
*/
async _handleToggleSettingCommand(command) {
+5 -1
View File
@@ -36,7 +36,11 @@ function isMapLike(collection) {
return collection && typeof collection.entries === "function" && typeof collection.values === "function";
}
function getChildGraphs(graph) {
/**
* Return the subgraph LGraph instances nested under `graph`, across both
* Map-like and plain-object `_subgraphs` containers.
*/
export function getChildGraphs(graph) {
if (!graph || !graph._subgraphs) {
return [];
}
+35 -22
View File
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-55e3316e] {
.autocomplete-text-widget[data-v-4e322fec] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-55e3316e] {
.input-wrapper[data-v-4e322fec] {
position: relative;
flex: 1;
display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-55e3316e] {
.text-input[data-v-4e322fec] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-55e3316e] {
.text-input.vue-dom-mode[data-v-4e322fec] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2161,12 +2161,12 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-55e3316e]:focus {
.text-input[data-v-4e322fec]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-55e3316e] {
.clear-button[data-v-4e322fec] {
position: absolute;
right: 6px;
bottom: 6px; /* Changed from top to bottom */
@@ -2189,31 +2189,31 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-55e3316e] {
.input-wrapper:hover .clear-button[data-v-4e322fec] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-55e3316e]:hover {
.clear-button[data-v-4e322fec]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-55e3316e] {
.clear-button svg[data-v-4e322fec] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e] {
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec] {
right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e]:hover {
.text-input.vue-dom-mode ~ .clear-button[data-v-4e322fec]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-55e3316e] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-4e322fec] {
width: 14px;
height: 14px;
}
@@ -15168,7 +15168,13 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false;
};
const onInput = () => {
const onInput = (event) => {
if (event.inputType === "historyUndo") {
const ta = textareaRef.value;
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
ta.setSelectionRange(ta.value.length, ta.value.length);
}
}
updateHasTextState();
if (textareaRef.value && typeof props.widget.callback === "function") {
props.widget.callback(textareaRef.value.value);
@@ -15215,15 +15221,22 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
}
};
const clearText = () => {
if (textareaRef.value) {
textareaRef.value.value = "";
hasText.value = false;
textareaRef.value.focus();
if (typeof props.widget.callback === "function") {
props.widget.callback("");
}
textareaRef.value.dispatchEvent(new Event("input"));
const ta = textareaRef.value;
if (!ta || ta.value.length === 0) return;
ta.focus();
ta.setSelectionRange(0, ta.value.length);
let ok = false;
try {
ok = typeof document.execCommand === "function" && document.execCommand("insertText", false, "");
} catch {
ok = false;
}
if (ok) {
hasText.value = false;
return;
}
ta.value = "";
ta.dispatchEvent(new Event("input"));
};
onMounted(() => {
if (textareaRef.value) {
@@ -15316,7 +15329,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-55e3316e"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-4e322fec"]]);
const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" };
File diff suppressed because one or more lines are too long
+124 -24
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, chainCallback, getLinkFromGraph } from "./utils.js";
import { getAllGraphNodes, getNodeReference, getNodeFromGraph, getChildGraphs, chainCallback, getLinkFromGraph } from "./utils.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
const DEBOUNCE_DELAY = 500;
@@ -155,6 +155,54 @@ function tryResolvePrimitiveConnection(node, widgetName) {
return isPrimitiveNodeType(originNode) ? originNode : null;
}
/**
* Resolve the widget that "send prompt" targets on `node`: the first
* string-typed widget, falling back to the first non-hidden widget.
* Shared by `isPromptWidgetConnected` and `applyWidgetUpdate` so the
* candidate-set logic and the write path cannot drift apart.
*
* @param {Object} node - LiteGraph node instance
* @returns {Object|null} - the target widget, or null when none is suitable
*/
function resolveTextWidget(node) {
if (!node || !Array.isArray(node.widgets)) {
return null;
}
const TEXT_TYPES = new Set(["string", "customtext"]);
return (
node.widgets.find((w) => {
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
return TEXT_TYPES.has(t) || t.includes("string");
}) ??
node.widgets.find((w) => w?.name && !w.name.startsWith("_")) ??
null
);
}
/**
* True when the widget that "send prompt" would update on `node` is backed by
* a connected input ComfyUI execution reads the linked input, so updating
* the widget would be a silent no-op. Such nodes must not be offered as
* prompt/embedding send targets.
*
* @param {Object} node - LiteGraph node instance
* @returns {boolean}
*/
function isPromptWidgetConnected(node) {
if (!node || !Array.isArray(node.inputs)) {
return false;
}
const targetWidget = resolveTextWidget(node);
if (!targetWidget?.name) {
return false;
}
const slotIndex = findInputSlotForWidget(node, targetWidget.name);
return slotIndex >= 0 && node.inputs[slotIndex]?.link != null;
}
app.registerExtension({
name: "LoraManager.WorkflowRegistry",
@@ -209,6 +257,45 @@ app.registerExtension({
this._log("failed to chain LiteGraph hooks: %s", e.message);
}
// Link connect/disconnect changes whether a widget is externally driven
// (e.g. CLIP Text Encode "text" wired to another node), which affects
// the text-send candidate set — re-register on connection changes.
const hookLinkChanges = (targetGraph) => {
if (!targetGraph) {
return false;
}
if (typeof targetGraph.events?.addEventListener === "function") {
targetGraph.events.addEventListener("node:slot-links:changed", () =>
scheduleRefresh("link")
);
return true;
}
// Classic litegraph: structural edits (incl. link connect/disconnect)
// flow through beforeChange/afterChange.
chainCallback(targetGraph, "onAfterChange", () => scheduleRefresh("afterChange"));
return true;
};
try {
if (hookLinkChanges(graph)) {
hooksInstalled += 1;
// Links wired inside a subgraph dispatch on that subgraph's own
// graph events, not the root's — hook existing and future subgraphs.
for (const subgraph of getChildGraphs(graph)) {
if (hookLinkChanges(subgraph)) {
hooksInstalled += 1;
}
}
graph.events?.addEventListener?.("subgraph-created", (event) => {
if (hookLinkChanges(event?.subgraph)) {
hooksInstalled += 1;
}
});
}
} catch (e) {
this._log("failed to hook graph link changes: %s", e.message);
}
if (typeof api.addEventListener === "function") {
try {
api.addEventListener("graphChanged", () => scheduleRefresh("graphChanged"));
@@ -251,6 +338,14 @@ app.registerExtension({
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
const markerRole = node.properties?.lm_marker_role ?? null;
// A prompt-capable node whose text widget is wired to another
// node cannot have its text changed via the widget — execution
// reads the linked input. Drop it from text-send candidates.
const textWidgetConnected =
hasTextWidget || markerRole === "send_prompt_target"
? isPromptWidgetConnected(node)
: false;
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
continue;
}
@@ -275,7 +370,8 @@ app.registerExtension({
marker_role: markerRole,
capabilities: {
supports_lora: supportsLora,
has_text_widget: hasTextWidget,
has_text_widget: hasTextWidget && !textWidgetConnected,
text_widget_connected: textWidgetConnected,
widget_names: widgetNames,
},
});
@@ -286,8 +382,10 @@ app.registerExtension({
// Content-based dedup: skip POST if identical to last sent payload,
// unless forced (e.g. responding to a lora_registry_refresh WS message
// where the backend explicitly requests a re-registration).
// text_widget_connected is part of the fingerprint so that link
// connect/disconnect changes re-register the affected nodes.
const fingerprint = JSON.stringify(
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}`).sort()
workflowNodes.map(n => `${n.graph_id}:${n.node_id}|${n.marker_role ?? ""}|${n.mode ?? 0}|${n.capabilities.text_widget_connected}`).sort()
);
if (!force && fingerprint === this._lastFingerprint) {
return;
@@ -349,28 +447,30 @@ app.registerExtension({
let targetWidget = null;
if (action === "inject_text") {
// Find the first text-capable widget by type.
// Normalise to lowercase for case-insensitive matching.
const TEXT_TYPES = new Set(["string", "customtext"]);
targetWidget = node.widgets.find((w) => {
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
if (TEXT_TYPES.has(t)) return true;
// Broad fallback for unknown composite types.
if (t.includes("string")) {
return true;
}
return false;
});
targetWidget = resolveTextWidget(node);
if (!targetWidget) {
// Last resort: pick the first widget that is not a hidden/internal type
targetWidget = node.widgets.find((w) => w?.name && !w.name.startsWith("_"));
if (!targetWidget) {
console.warn(
"LoRA Manager: no suitable widget for inject_text on node",
node.id
);
return;
}
console.warn(
"LoRA Manager: no suitable widget for inject_text on node",
node.id
);
return;
}
// The widget is backed by a connected input: ComfyUI execution
// reads the linked value, so updating the widget is a no-op.
// Guard against stale registry entries (e.g. a link was just
// connected before the registry refresh debounce elapsed).
const slotIndex = findInputSlotForWidget(node, targetWidget.name);
if (slotIndex >= 0 && node.inputs[slotIndex]?.link != null) {
console.warn(
"LoRA Manager: widget '%s' on node %d is connected to an input; widget value cannot be changed",
targetWidget.name,
node.id
);
// Self-heal the registry so the node drops out of the
// send-target list instead of being offered again.
this.refreshRegistry(true);
return;
}
} else if (widgetName) {
// Legacy: find widget by name