Compare commits

...

11 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
53 changed files with 3925 additions and 506 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
+15 -1
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": {
@@ -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",
+15 -1
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": {
@@ -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",
+15 -1
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": {
@@ -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",
+15 -1
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": {
@@ -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",
+15 -1
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": {
@@ -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": "מחק את הגרסה המקומית הזו",
+15 -1
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": {
@@ -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": "このローカルバージョンを削除",
+15 -1
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": {
@@ -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": "이 로컬 버전 삭제",
+15 -1
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": {
@@ -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": "Удалить эту локальную версию",
+15 -1
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": {
@@ -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": "删除此本地版本",
+15 -1
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": {
@@ -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": "刪除此本地版本",
@@ -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
+93
View File
@@ -861,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):
@@ -901,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.
@@ -1302,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
@@ -1353,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
+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)}"
)
+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
+39 -14
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
@@ -1434,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')}"
)
+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()
+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)
+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
+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",
@@ -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%;
+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;
+8
View File
@@ -306,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(
+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.
@@ -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',
+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>
+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
@@ -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');
@@ -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();
@@ -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());
@@ -1613,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):
+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() == {}
+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) {