mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
11 Commits
d43ab6e32f
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 94dd08646d | |||
| 658f88ca48 | |||
| f53352efb2 | |||
| 38809a9d1b | |||
| 395682509c | |||
| ef3e7d7bf4 | |||
| c85b6b64a1 | |||
| 34c87d4934 | |||
| 93472e5d67 | |||
| ae185ee714 | |||
| 795036275a |
+10
@@ -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
File diff suppressed because it is too large
Load Diff
+15
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,)
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() == {}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user