Compare commits

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

Parse and persist paidAccess from model-level, bulk, and by-hash
responses; treat timed paid gates as early access and permanent paid
versions as a distinct is_paid state; add a hide_paid_updates setting
with a "Paid" badge in the versions tab; warn before downloading gated
versions. Includes SQLite migration, i18n for all locales, and
backend/frontend tests.
2026-08-15 18:08:14 +08:00
37 changed files with 2337 additions and 429 deletions
+327 -295
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -622,6 +622,10 @@
"label": "Früher Zugriff Updates ausblenden", "label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates" "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": { "licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden", "useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design." "useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "Nur Favoriten anzeigen", "title": "Nur Favoriten anzeigen",
"action": "Favoriten" "action": "Favoriten"
},
"layout": {
"title": "Rezepte-Layout",
"grid": "Raster-Layout",
"masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version", "newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff", "earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich", "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", "ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site", "onSiteOnly": "Nur On-Site",
@@ -1559,6 +1570,7 @@
"download": "Herunterladen", "download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen", "downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai 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", "downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen", "delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen", "deleteTooltip": "Diese lokale Version löschen",
+12
View File
@@ -622,6 +622,10 @@
"label": "Hide Early Access Updates", "label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge" "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": { "licenseIcons": {
"useNewStyle": "Use updated license icons", "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." "useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "Show Favorites Only", "title": "Show Favorites Only",
"action": "Favorites" "action": "Favorites"
},
"layout": {
"title": "Recipes Layout",
"grid": "Grid layout",
"masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "This version is newer than your latest local version", "newerTooltip": "This version is newer than your latest local version",
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access", "earlyAccessTooltip": "This version currently requires Civitai early access",
"paid": "Paid",
"paidTooltip": "This version requires payment to download",
"ignored": "Ignored", "ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version", "ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only", "onSiteOnly": "On-Site Only",
@@ -1559,6 +1570,7 @@
"download": "Download", "download": "Download",
"downloadTooltip": "Download this version", "downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai", "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", "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete", "delete": "Delete",
"deleteTooltip": "Delete this local version", "deleteTooltip": "Delete this local version",
+12
View File
@@ -622,6 +622,10 @@
"label": "Ocultar actualizaciones de acceso temprano", "label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo 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": { "licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados", "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." "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."
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "Mostrar solo favoritos", "title": "Mostrar solo favoritos",
"action": "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": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "Esta versión es más reciente que tu última versión local", "newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai", "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", "ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio", "onSiteOnly": "Solo en Sitio",
@@ -1559,6 +1570,7 @@
"download": "Descargar", "download": "Descargar",
"downloadTooltip": "Descargar esta versión", "downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai", "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", "downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar", "delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local", "deleteTooltip": "Eliminar esta versión local",
+12
View File
@@ -622,6 +622,10 @@
"label": "Masquer les mises à jour en accès anticipé", "label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement 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": { "licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour", "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." "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."
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "Afficher uniquement les favoris", "title": "Afficher uniquement les favoris",
"action": "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": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "Cette version est plus récente que votre dernière version locale", "newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai", "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", "ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site", "onSiteOnly": "Uniquement sur Site",
@@ -1559,6 +1570,7 @@
"download": "Télécharger", "download": "Télécharger",
"downloadTooltip": "Télécharger cette version", "downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai", "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", "downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer", "delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale", "deleteTooltip": "Supprimer cette version locale",
+12
View File
@@ -622,6 +622,10 @@
"label": "הסתר עדכוני גישה מוקדמת", "label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת" "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": { "licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים", "useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI." "useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "הצג מועדפים בלבד", "title": "הצג מועדפים בלבד",
"action": "מועדפים" "action": "מועדפים"
},
"layout": {
"title": "פריסת מתכונים",
"grid": "פריסת רשת",
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך", "newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai", "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "התעלם", "ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר", "onSiteOnly": "רק באתר",
@@ -1559,6 +1570,7 @@
"download": "הורדה", "download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו", "downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai", "downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה", "delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו", "deleteTooltip": "מחק את הגרסה המקומית הזו",
+12
View File
@@ -622,6 +622,10 @@
"label": "早期アクセス更新を非表示", "label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新" "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": { "licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用", "useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。" "useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "お気に入りのみ表示", "title": "お気に入りのみ表示",
"action": "お気に入り" "action": "お気に入り"
},
"layout": {
"title": "レシピのレイアウト",
"grid": "グリッドレイアウト",
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです", "newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス", "earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です", "earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "無視中", "ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です", "ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ", "onSiteOnly": "サイト内のみ",
@@ -1559,6 +1570,7 @@
"download": "ダウンロード", "download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード", "downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません", "downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除", "delete": "削除",
"deleteTooltip": "このローカルバージョンを削除", "deleteTooltip": "このローカルバージョンを削除",
+12
View File
@@ -622,6 +622,10 @@
"label": "얼리 액세스 업데이트 숨기기", "label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만" "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": { "licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용", "useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다." "useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "즐겨찾기만 표시", "title": "즐겨찾기만 표시",
"action": "즐겨찾기" "action": "즐겨찾기"
},
"layout": {
"title": "레시피 레이아웃",
"grid": "그리드 레이아웃",
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다", "newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다", "earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "무시됨", "ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용", "onSiteOnly": "사이트 내 전용",
@@ -1559,6 +1570,7 @@
"download": "다운로드", "download": "다운로드",
"downloadTooltip": "이 버전 다운로드", "downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", "downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제", "delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제", "deleteTooltip": "이 로컬 버전 삭제",
+12
View File
@@ -622,6 +622,10 @@
"label": "Скрыть обновления раннего доступа", "label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа" "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": { "licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии", "useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI." "useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "Только избранные", "title": "Только избранные",
"action": "Избранное" "action": "Избранное"
},
"layout": {
"title": "Макет рецептов",
"grid": "Макет сеткой",
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "Эта версия новее вашей последней локальной версии", "newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai", "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Игнорируется", "ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте", "onSiteOnly": "Только на Сайте",
@@ -1559,6 +1570,7 @@
"download": "Скачать", "download": "Скачать",
"downloadTooltip": "Скачать эту версию", "downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai", "downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить", "delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию", "deleteTooltip": "Удалить эту локальную версию",
+12
View File
@@ -622,6 +622,10 @@
"label": "隐藏抢先体验更新", "label": "隐藏抢先体验更新",
"help": "抢先体验更新" "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": { "licenseIcons": {
"useNewStyle": "使用新版许可协议图标", "useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。" "useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "仅显示收藏", "title": "仅显示收藏",
"action": "收藏" "action": "收藏"
},
"layout": {
"title": "配方布局",
"grid": "网格布局",
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验", "earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限", "earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知", "ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成", "onSiteOnly": "仅站内生成",
@@ -1559,6 +1570,7 @@
"download": "下载", "download": "下载",
"downloadTooltip": "下载此版本", "downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载", "downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除", "delete": "删除",
"deleteTooltip": "删除此本地版本", "deleteTooltip": "删除此本地版本",
+12
View File
@@ -622,6 +622,10 @@
"label": "隱藏搶先體驗更新", "label": "隱藏搶先體驗更新",
"help": "搶先體驗更新" "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": { "licenseIcons": {
"useNewStyle": "使用新版許可協議圖標", "useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。" "useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
@@ -933,6 +937,11 @@
"favorites": { "favorites": {
"title": "僅顯示收藏", "title": "僅顯示收藏",
"action": "收藏" "action": "收藏"
},
"layout": {
"title": "配方版面",
"grid": "網格版面",
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
} }
}, },
"duplicates": { "duplicates": {
@@ -1550,6 +1559,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗", "earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限", "earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知", "ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成", "onSiteOnly": "僅站內生成",
@@ -1559,6 +1570,7 @@
"download": "下載", "download": "下載",
"downloadTooltip": "下載此版本", "downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載", "downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除", "delete": "刪除",
"deleteTooltip": "刪除此本地版本", "deleteTooltip": "刪除此本地版本",
@@ -215,6 +215,24 @@ class MetadataProcessor:
primary_sampler = sampler_info primary_sampler = sampler_info
primary_sampler_id = node_id 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 return primary_sampler_id, primary_sampler
@staticmethod @staticmethod
+93
View File
@@ -861,6 +861,65 @@ class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtr
# Update method is inherited from TSCSamplerBaseExtractor # 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): class LoraLoaderExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata):
@@ -901,6 +960,37 @@ class ImageSizeExtractor(NodeMetadataExtractor):
"node_id": node_id "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): class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
"""Extract LoRA metadata from rgthree Power Lora Loader. """Extract LoRA metadata from rgthree Power Lora Loader.
@@ -1302,6 +1392,8 @@ NODE_EXTRACTORS = {
"ClownsharKSampler_Beta": SamplerExtractor, "ClownsharKSampler_Beta": SamplerExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes "TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # 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 "KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack "KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack "KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
@@ -1353,6 +1445,7 @@ NODE_EXTRACTORS = {
"GetNode": GetNodeExtractor, "GetNode": GetNodeExtractor,
# Latent # Latent
"EmptyLatentImage": ImageSizeExtractor, "EmptyLatentImage": ImageSizeExtractor,
"KreaDualResolutionSelector": KreaDualResolutionSelectorExtractor, # Auryg/Krea-2-Two-Stage-Sampler
# Flux # Flux
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance "FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider "CFGGuider": CFGGuiderExtractor, # Add CFGGuider
+37 -5
View File
@@ -2535,6 +2535,7 @@ class ModelUpdateHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False hide_early_access = False
hide_paid = False
if self._settings is not None: if self._settings is not None:
try: try:
hide_early_access = bool( hide_early_access = bool(
@@ -2542,12 +2543,17 @@ class ModelUpdateHandler:
) )
except Exception: except Exception:
pass pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
serialized_records = [] serialized_records = []
for record in records.values(): for record in records.values():
has_update_fn = getattr(record, "has_update", None) has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn( 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)) serialized_records.append(self._serialize_record(record))
@@ -2701,10 +2707,16 @@ class ModelUpdateHandler:
if not record or not record.versions: if not record or not record.versions:
return record 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 = [] versions_needing_update = []
for version in record.versions: 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) versions_needing_update.append(version)
if not versions_needing_update: if not versions_needing_update:
@@ -2934,6 +2946,7 @@ class ModelUpdateHandler:
context = version_context or {} context = version_context or {}
# Check user setting for hiding early access versions # Check user setting for hiding early access versions
hide_early_access = False hide_early_access = False
hide_paid = False
if self._settings is not None: if self._settings is not None:
try: try:
hide_early_access = bool( hide_early_access = bool(
@@ -2941,6 +2954,10 @@ class ModelUpdateHandler:
) )
except Exception: except Exception:
pass pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
return { return {
"modelType": record.model_type, "modelType": record.model_type,
"modelId": record.model_id, "modelId": record.model_id,
@@ -2949,7 +2966,10 @@ class ModelUpdateHandler:
"inLibraryVersionIds": record.in_library_version_ids, "inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at, "lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model, "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": [ "versions": [
self._serialize_version(version, context.get(version.version_id)) self._serialize_version(version, context.get(version.version_id))
for version in record.versions for version in record.versions
@@ -2968,8 +2988,11 @@ class ModelUpdateHandler:
# Determine if version is currently in early access # Determine if version is currently in early access
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag # 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 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: try:
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -2984,6 +3007,13 @@ class ModelUpdateHandler:
# Fallback to basic EA flag from bulk API # Fallback to basic EA flag from bulk API
is_early_access = True 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 { return {
"versionId": version.version_id, "versionId": version.version_id,
"name": version.name, "name": version.name,
@@ -2997,6 +3027,8 @@ class ModelUpdateHandler:
"earlyAccessEndsAt": version.early_access_ends_at, "earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access, "isEarlyAccess": is_early_access,
"usageControl": version.usage_control, "usageControl": version.usage_control,
"isPaid": bool(getattr(version, "is_paid", False)),
"paidAccess": paid_access_payload,
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
} }
+17 -2
View File
@@ -633,6 +633,13 @@ class BaseModelService(ABC):
except Exception: except Exception:
hide_early_access = False 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 records = None
resolved: Optional[Dict[int, bool]] = None resolved: Optional[Dict[int, bool]] = None
if same_base_mode: if same_base_mode:
@@ -641,7 +648,10 @@ class BaseModelService(ABC):
try: try:
records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids)) records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
resolved = { 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() for model_id, record in records.items()
} }
except Exception as exc: except Exception as exc:
@@ -663,6 +673,7 @@ class BaseModelService(ABC):
self.model_type, self.model_type,
ordered_ids, ordered_ids,
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
hide_paid=hide_paid,
)) ))
except Exception as exc: except Exception as exc:
logger.error( logger.error(
@@ -677,7 +688,10 @@ class BaseModelService(ABC):
if resolved is None: if resolved is None:
tasks = [ tasks = [
self.update_service.has_update( 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 for model_id in ordered_ids
] ]
@@ -717,6 +731,7 @@ class BaseModelService(ABC):
threshold_version, threshold_version,
base_model, base_model,
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
hide_paid=hide_paid,
) )
else: else:
flag = default_flag flag = default_flag
+29 -4
View File
@@ -3,6 +3,7 @@
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms # reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor. # import cycles. Breaking them would require an architectural refactor.
import copy import copy
import json
import logging import logging
import os import os
import asyncio import asyncio
@@ -1434,9 +1435,33 @@ class DownloadManager:
# Create directory if it doesn't exist # Create directory if it doesn't exist
os.makedirs(save_dir, exist_ok=True) os.makedirs(save_dir, exist_ok=True)
# Check if this is an early access model # Check if this is a paid or early access model
if version_info.get("earlyAccessEndsAt"): paid_access = version_info.get("paidAccess")
early_access_date = version_info.get("earlyAccessEndsAt", "") if isinstance(paid_access, str):
# Some providers (e.g. CivArchive fallback) carry the DTO as JSON text
try:
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 = (
"This model requires payment. Please ensure you have "
"purchased access and are logged in to Civitai."
)
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 # Convert to a readable date if possible
try: try:
from datetime import datetime from datetime import datetime
@@ -1448,7 +1473,7 @@ class DownloadManager:
early_access_msg = ( early_access_msg = (
f"This model requires payment (until {formatted_date}). " f"This model requires payment (until {formatted_date}). "
) )
except: except Exception:
early_access_msg = "This model requires payment. " early_access_msg = "This model requires payment. "
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai." early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
+121 -7
View File
@@ -6,6 +6,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import sqlite3 import sqlite3
@@ -74,6 +75,8 @@ class ModelVersionRecord:
sort_index: int = 0 sort_index: int = 0
is_early_access: bool = False is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration" 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 @dataclass
@@ -107,13 +110,17 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library] return [version.version_id for version in self.versions if version.is_in_library]
def has_update( 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: ) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available. """Return True when a non-ignored remote version newer than the newest local copy is available.
Args: Args:
hide_early_access: If True, exclude early access versions from update check. 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_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: if self.should_ignore_model:
@@ -129,6 +136,7 @@ class ModelUpdateRecord:
not version.is_in_library not version.is_in_library
and not version.should_ignore and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version)) 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)) and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions for version in self.versions
) )
@@ -138,6 +146,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version): if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue continue
if version.version_id > max_in_library: 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 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) 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 # Phase 2: Precise check with exact end time
if version.early_access_ends_at: if version.early_access_ends_at:
try: try:
@@ -178,6 +193,7 @@ class ModelUpdateRecord:
local_base_model: Optional[str], local_base_model: Optional[str],
hide_early_access: bool = False, hide_early_access: bool = False,
hide_non_downloadable: bool = True, hide_non_downloadable: bool = True,
hide_paid: bool = False,
) -> bool: ) -> bool:
"""Return True when a newer remote version with the same base model exists. """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. local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check. 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_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: if self.should_ignore_model:
@@ -216,6 +233,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version): if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue continue
version_base = _normalize_base_model(version.base_model) version_base = _normalize_base_model(version.base_model)
@@ -252,6 +271,8 @@ class ModelUpdateService:
is_in_library INTEGER NOT NULL DEFAULT 0, is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT, usage_control TEXT,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
); );
@@ -491,6 +512,14 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT" "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(): for column, statement in migrations.items():
@@ -592,6 +621,8 @@ class ModelUpdateService:
should_ignore INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0,
early_access_ends_at TEXT, early_access_ends_at TEXT,
is_early_access INTEGER NOT NULL DEFAULT 0, is_early_access INTEGER NOT NULL DEFAULT 0,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
) )
@@ -611,6 +642,8 @@ class ModelUpdateService:
"should_ignore", "should_ignore",
"early_access_ends_at", "early_access_ends_at",
"is_early_access", "is_early_access",
"paid_access",
"is_paid",
] ]
defaults = { defaults = {
"sort_index": "0", "sort_index": "0",
@@ -623,6 +656,8 @@ class ModelUpdateService:
"should_ignore": "0", "should_ignore": "0",
"early_access_ends_at": "NULL", "early_access_ends_at": "NULL",
"is_early_access": "0", "is_early_access": "0",
"paid_access": "NULL",
"is_paid": "0",
} }
select_parts = [] select_parts = []
@@ -936,17 +971,30 @@ class ModelUpdateService:
async with self._lock: async with self._lock:
return self._get_record(model_type, model_id) 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.""" """Determine if a model has updates pending."""
record = await self.get_record(model_type, model_id) 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( async def has_updates_bulk(
self, self,
model_type: str, model_type: str,
model_ids: Sequence[int], model_ids: Sequence[int],
hide_early_access: bool = False, hide_early_access: bool = False,
hide_paid: bool = False,
) -> Dict[int, bool]: ) -> Dict[int, bool]:
"""Return update availability for each model id in a single database pass.""" """Return update availability for each model id in a single database pass."""
@@ -959,7 +1007,9 @@ class ModelUpdateService:
return { return {
model_id: ( 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 if model_id in records
else False else False
) )
@@ -1190,6 +1240,7 @@ class ModelUpdateService:
"earlyAccessEndsAt": _normalize_string( "earlyAccessEndsAt": _normalize_string(
entry.get("earlyAccessEndsAt") entry.get("earlyAccessEndsAt")
), ),
"paidAccess": entry.get("paidAccess"),
} }
except RateLimitError: except RateLimitError:
raise raise
@@ -1214,6 +1265,17 @@ class ModelUpdateService:
"earlyAccessEndsAt" "earlyAccessEndsAt"
): ):
version["earlyAccessEndsAt"] = extra["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 @staticmethod
def _collect_hashes_from_response(response: Mapping[str, Any]) -> Dict[int, str]: 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, early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access, is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control, 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" is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl")) 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( return ModelVersionRecord(
version_id=version_id, version_id=version_id,
name=name, name=name,
@@ -1577,8 +1653,36 @@ class ModelUpdateService:
sort_index=index, sort_index=index,
is_early_access=is_early_access, is_early_access=is_early_access,
usage_control=usage_control, 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]: def _extract_size_bytes(self, files) -> Optional[int]:
if not isinstance(files, Iterable): if not isinstance(files, Iterable):
return None return None
@@ -1691,7 +1795,7 @@ class ModelUpdateService:
f""" f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at, 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, 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 FROM model_update_versions
WHERE model_id IN ({placeholders}) WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC 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, sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]), is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"], 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,), (record.model_id,),
) )
for version in record.versions: 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( conn.execute(
""" """
INSERT INTO model_update_versions ( INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at, version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -1793,6 +1905,8 @@ class ModelUpdateService:
version.early_access_ends_at, version.early_access_ends_at,
1 if version.is_early_access else 0, 1 if version.is_early_access else 0,
version.usage_control, version.usage_control,
paid_access_value,
1 if version.is_paid else 0,
), ),
) )
conn.commit() conn.commit()
+224 -45
View File
@@ -36,6 +36,11 @@ logger = logging.getLogger(__name__)
# explicitly to "diffusion_model" (mirrors Oracle R2-F1). # explicitly to "diffusion_model" (mirrors Oracle R2-F1).
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"} _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: class RecipeScanner:
"""Service for scanning and managing recipe images""" """Service for scanning and managing recipe images"""
@@ -116,6 +121,12 @@ class RecipeScanner:
self._rematch_autov3_cache: dict[str, dict[str, Any]] | None = None self._rematch_autov3_cache: dict[str, dict[str, Any]] | None = None
self._rematch_autov3_versions: tuple[int, int] | None = None self._rematch_autov3_versions: tuple[int, int] | None = None
self._rematch_autov3_lock = asyncio.Lock() 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 self._initialized = True
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]: async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
@@ -162,6 +173,70 @@ class RecipeScanner:
self._local_hash_cache_versions = versions self._local_hash_cache_versions = versions
return cache 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: def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
"""Return True when a recipe entry is eligible for local re-matching.""" """Return True when a recipe entry is eligible for local re-matching."""
if not isinstance(entry, dict): if not isinstance(entry, dict):
@@ -170,7 +245,10 @@ class RecipeScanner:
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name") entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
) )
has_identifier = ( 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) return bool(unresolved and has_identifier)
@@ -221,6 +299,97 @@ class RecipeScanner:
self._rematch_autov3_versions = versions self._rematch_autov3_versions = versions
return cache 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( async def _match_rematch_entry(
self, self,
entry: dict[str, Any], entry: dict[str, Any],
@@ -247,19 +416,23 @@ class RecipeScanner:
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
*, *,
is_checkpoint: bool, 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]]: ) -> 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 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 falls back to the version index via ``modelVersionId`` or ``id``; L3
resolves 12-char hashes through the computed AutoV3 cache. Matched resolves 12-char hashes through the computed AutoV3 cache; L4
items are type-verified against the entry kind before being returned. (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: Returns:
Tuple of (matched item, match level) where level is "L1", "L2" or Tuple of (matched item, match level) where level is "L1", "L2",
"L3" or ``(None, None)`` when no usable match exists. A missing "L3" or "L4" or ``(None, None)`` when no usable match exists. A
local match is an expected outcome (the model may simply not be missing local match is an expected outcome (the model may simply
present locally), not an error. not be present locally), not an error.
""" """
entry_hash = (entry.get("hash") or "").lower() entry_hash = (entry.get("hash") or "").lower()
@@ -279,32 +452,19 @@ class RecipeScanner:
item = autov3_cache.get(entry_hash) item = autov3_cache.get(entry_hash)
level = "L3" if item is not None else None 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: if item is None:
return (None, None) return (None, None)
# Type gate: the L1 cache merges lora and checkpoint items and is if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
# 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) return (None, None)
return (item, level) return (item, level)
@@ -617,10 +777,11 @@ class RecipeScanner:
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]: async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally. """Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built Match snapshots (local hash cache, computed autov3 cache, filename
BEFORE acquiring the mutation lock both are read-only snapshots and cache) are built BEFORE acquiring the mutation lock all three are
the version-cached hash dict would otherwise rebuild mid-run if a scan read-only snapshots and the version-cached dicts would otherwise
bumps a scanner's cache_version while we hold the lock. rebuild mid-run if a scan bumps a scanner's cache_version while we
hold the lock.
Args: Args:
recipe_id: ID of the recipe to rematch recipe_id: ID of the recipe to rematch
@@ -636,6 +797,7 @@ class RecipeScanner:
""" """
local_cache = await self.build_local_hash_cache() local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache() autov3_cache = await self._build_rematch_autov3_cache()
filename_cache = await self._build_local_filename_cache()
async with self._mutation_lock: async with self._mutation_lock:
# Get raw recipe from cache directly to avoid formatted fields # Get raw recipe from cache directly to avoid formatted fields
@@ -649,7 +811,7 @@ class RecipeScanner:
try: try:
rematched, _errors, details = await self._rematch_single_recipe( rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache recipe, local_cache, autov3_cache, filename_cache
) )
except RecipePersistenceError as exc: except RecipePersistenceError as exc:
logger.error( logger.error(
@@ -706,6 +868,7 @@ class RecipeScanner:
recipe: Dict[str, Any], recipe: Dict[str, Any],
local_cache: dict[str, dict[str, Any]], local_cache: dict[str, dict[str, Any]],
autov3_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]]: ) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models. """Rematch a single recipe's lora/checkpoint entries against local models.
@@ -719,6 +882,8 @@ class RecipeScanner:
recipe: The recipe dictionary to rematch (modified in-place) recipe: The recipe dictionary to rematch (modified in-place)
local_cache: L1 hash cache snapshot (build_local_hash_cache) local_cache: L1 hash cache snapshot (build_local_hash_cache)
autov3_cache: L3 computed-autov3 cache snapshot autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback
Returns: Returns:
Tuple of (rematched_entries, errors, details). The errors element Tuple of (rematched_entries, errors, details). The errors element
@@ -744,7 +909,13 @@ class RecipeScanner:
if not self._is_rematch_candidate(entry): if not self._is_rematch_candidate(entry):
continue continue
item, level = await self._match_rematch_entry_with_level( 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: if item is None:
details["unresolved"].append( details["unresolved"].append(
@@ -770,7 +941,13 @@ class RecipeScanner:
if isinstance(checkpoint, dict): if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint): if self._is_rematch_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level( 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: if item is None:
details["unresolved"].append( details["unresolved"].append(
@@ -832,12 +1009,13 @@ class RecipeScanner:
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally. """Rematch every recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built Match snapshots (local hash cache, computed autov3 cache, filename
ONCE before the loop both are read-only and the version-cached hash cache) are built ONCE before the loop all three are read-only and
dict would otherwise rebuild mid-run if a scan bumps a scanner's the version-cached dicts would otherwise rebuild mid-run if a scan
cache_version while the mutation lock is held. ``_schedule_resort`` is bumps a scanner's cache_version while the mutation lock is held.
called exactly once after the loop: it spawns an asyncio task per call, ``_schedule_resort`` is called exactly once after the loop: it spawns
so per-recipe calls would race one resort task per recipe. an asyncio task per call, so per-recipe calls would race one resort
task per recipe.
Args: Args:
progress_callback: Optional callback for progress updates progress_callback: Optional callback for progress updates
@@ -858,6 +1036,7 @@ class RecipeScanner:
# Match snapshots built once and shared by every recipe in the loop. # Match snapshots built once and shared by every recipe in the loop.
local_cache = await self.build_local_hash_cache() local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache() autov3_cache = await self._build_rematch_autov3_cache()
filename_cache = await self._build_local_filename_cache()
async with self._mutation_lock: async with self._mutation_lock:
cache = await self.get_cached_data() cache = await self.get_cached_data()
@@ -925,7 +1104,7 @@ class RecipeScanner:
) )
rematched, _errors, details = await self._rematch_single_recipe( rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache recipe, local_cache, autov3_cache, filename_cache
) )
if rematched > 0: if rematched > 0:
matched_recipes += 1 matched_recipes += 1
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.0" version = "1.2.1"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
@@ -447,6 +447,19 @@
border-color: color-mix(in oklch, #F59F00 45%, transparent); 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 { .version-meta-ea {
color: #E67700; color: #E67700;
font-weight: 600; font-weight: 600;
@@ -911,6 +911,93 @@
outline: none; 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 Slider Control */
.range-control { .range-control {
width: 100%; width: 100%;
+28
View File
@@ -168,6 +168,34 @@
border-color: var(--lora-accent); 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 */ /* Keyboard shortcut indicator styling */
.shortcut-key { .shortcut-key {
display: inline-flex; display: inline-flex;
@@ -182,6 +182,10 @@ function isEarlyAccessActive(version) {
} }
} }
function isPaidPermanent(version) {
return version && version.isPaid === true;
}
function isDownloadAllowed(version) { function isDownloadAllowed(version) {
if (!version.usageControl) { if (!version.usageControl) {
return true; return true;
@@ -342,6 +346,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
const strategy = state?.global?.settings?.version_grouping; const strategy = state?.global?.settings?.version_grouping;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE; const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates; const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
const hidePaid = state?.global?.settings?.hide_paid_updates;
if (!sameBaseMode) { if (!sameBaseMode) {
return Boolean(record?.hasUpdate); return Boolean(record?.hasUpdate);
@@ -388,6 +393,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) { if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false; return false;
} }
if (hidePaid && isPaidPermanent(version)) {
return false;
}
if (!isDownloadAllowed(version)) { if (!isDownloadAllowed(version)) {
return false; return false;
} }
@@ -469,6 +477,7 @@ function renderRow(version, options) {
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded'); const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version'); const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access'); 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 ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'); 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)) { if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only'); const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', { badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
@@ -564,6 +583,12 @@ function renderRow(version, options) {
{}, {},
'This version is only available for on-site generation on Civitai' '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) { } else if (isEarlyAccess) {
downloadTitle = translate( downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip', 'modals.model.versions.actions.downloadEarlyAccessTooltip',
+55 -12
View File
@@ -1017,11 +1017,8 @@ export class SettingsManager {
displayDensitySelect.value = state.global.settings.display_density || 'default'; displayDensitySelect.value = state.global.settings.display_density || 'default';
} }
// Set recipes layout setting // Set recipes layout setting (segmented control active state)
const recipesLayoutSelect = document.getElementById('recipesLayout'); this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid');
if (recipesLayoutSelect) {
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
}
// Set card info display setting // Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay'); const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
@@ -1064,6 +1061,12 @@ export class SettingsManager {
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false; 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'); const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
if (skipPreviouslyDownloadedModelVersionsCheckbox) { if (skipPreviouslyDownloadedModelVersionsCheckbox) {
skipPreviouslyDownloadedModelVersionsCheckbox.checked = skipPreviouslyDownloadedModelVersionsCheckbox.checked =
@@ -2288,19 +2291,18 @@ export class SettingsManager {
: element.value; : element.value;
try { 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 // Update frontend state with mapped keys
await this.saveSetting(settingKey, value); await this.saveSetting(settingKey, value);
// Apply frontend settings immediately // Apply frontend settings immediately
this.applyFrontendSettings(); 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 // Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) { if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout(); 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) { async saveRangeSetting(elementId, displayId, settingKey) {
const element = document.getElementById(elementId); const element = document.getElementById(elementId);
if (!element) return; if (!element) return;
+24
View File
@@ -282,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 // Rebuild the scroller on layout switch; in duplicates mode defer until
// exitDuplicateMode re-enables the scroller (direct recreation would dispose // exitDuplicateMode re-enables the scroller (direct recreation would dispose
// the old instance while initializeVirtualScroll skips duplicates mode) // the old instance while initializeVirtualScroll skips duplicates mode)
+1
View File
@@ -49,6 +49,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
version_grouping: 'same_base', version_grouping: 'same_base',
hide_early_access_updates: false, hide_early_access_updates: false,
hide_paid_updates: false,
auto_organize_exclusions: [], auto_organize_exclusions: [],
metadata_refresh_skip_paths: [], metadata_refresh_skip_paths: [],
skip_previously_downloaded_model_versions: false, skip_previously_downloaded_model_versions: false,
+10
View File
@@ -134,6 +134,16 @@
</div> </div>
<div class="controls-right"> <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"> <div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}"> <button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i> <i class="fas fa-stethoscope"></i>
@@ -629,16 +629,22 @@
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="recipesLayout"> <label id="recipesLayoutLabel">
{{ t('settings.layoutSettings.recipesLayout') }} {{ t('settings.layoutSettings.recipesLayout') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i> <i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
</label> </label>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control layout-options-control">
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')"> <div id="recipesLayoutOptions" class="layout-options" role="radiogroup" aria-label="{{ t('settings.layoutSettings.recipesLayout') }}" aria-labelledby="recipesLayoutLabel">
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option> <button type="button" class="layout-option" data-recipes-layout="grid" onclick="settingsManager.saveRecipesLayout('grid')" role="radio" aria-checked="true">
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option> <span class="layout-option-preview layout-preview-grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
</select> <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> </div>
</div> </div>
@@ -1263,6 +1269,24 @@
</div> </div>
</div> </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> </div>
<!-- Example Images --> <!-- Example Images -->
@@ -1667,7 +1667,7 @@ describe('AutoComplete widget interactions', () => {
expect(input.value).toBe('looking_to_the_side,'); 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'); const input = document.createElement('textarea');
input.value = '/'; input.value = '/';
input.selectionStart = input.value.length; input.selectionStart = input.value.length;
@@ -1682,8 +1682,6 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command); const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(commandNames).not.toContain('/noaf');
expect(commandNames).toContain('/activefilters'); expect(commandNames).toContain('/activefilters');
expect(commandNames).not.toContain('/noactivefilters'); expect(commandNames).not.toContain('/noactivefilters');
}); });
@@ -1710,11 +1708,11 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve(); await Promise.resolve();
const commandNames = autoComplete.items.map((item) => item.command); const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af'); expect(commandNames).toContain('/activefilters');
expect(previewTooltipMock.show).not.toHaveBeenCalled(); 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) => { settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') { if (key === 'loramanager.lora_active_filters_autocomplete') {
return true; return true;
@@ -1736,8 +1734,6 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command); const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/noaf');
expect(commandNames).not.toContain('/af');
expect(commandNames).toContain('/noactivefilters'); expect(commandNames).toContain('/noactivefilters');
expect(commandNames).not.toContain('/activefilters'); expect(commandNames).not.toContain('/activefilters');
}); });
@@ -1766,7 +1762,7 @@ describe('AutoComplete widget interactions', () => {
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true); 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'); const input = document.createElement('textarea');
input.value = '/'; input.value = '/';
input.selectionStart = input.value.length; input.selectionStart = input.value.length;
@@ -1782,7 +1778,7 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true })); 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(); expect(afItem).toBeDefined();
// Simulate the input being cleared after the command is accepted so the // Simulate the input being cleared after the command is accepted so the
@@ -530,4 +530,49 @@ describe('SettingsManager recipes layout switch', () => {
dispatchSpy.mockRestore(); dispatchSpy.mockRestore();
delete state.virtualScroller; 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();
});
}); });
+33
View File
@@ -163,6 +163,7 @@ describe('RecipeManager', () => {
afterEach(() => { afterEach(() => {
delete window.recipeManager; delete window.recipeManager;
delete window.importManager; delete window.importManager;
delete window.settingsManager;
}); });
it('initializes page controls, restores filters, and wires sort interactions', async () => { it('initializes page controls, restores filters, and wires sort interactions', async () => {
@@ -227,6 +228,38 @@ describe('RecipeManager', () => {
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1); 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 () => { it('skips loading when duplicates mode is active and refreshes otherwise', async () => {
const manager = new RecipeManager(); const manager = new RecipeManager();
@@ -1613,3 +1613,213 @@ def test_fill_missing_metadata_fills_overwrite_for_muted_node(metadata_registry)
assert "ow-1" not in metadata.get(OVERWRITE, {}) assert "ow-1" not in metadata.get(OVERWRITE, {})
metadata_registry.clear_metadata() 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",
}
+100
View File
@@ -593,3 +593,103 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch):
assert len(payload["updated"]) == 1 assert len(payload["updated"]) == 1
assert provider_calls == [[20]] assert provider_calls == [[20]]
assert len(saved) == 1 assert len(saved) == 1
def test_serialize_version_permanent_paid_is_not_early_access():
"""Permanent paid versions (is_paid, no end date) must not be flagged as
early access, mirroring _is_early_access_active in the update service."""
version = ModelVersionRecord(
version_id=7, name="v7", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control="Download",
paid_access=json.dumps({"permanent": True, "endsAt": None}), is_paid=True,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is False
assert serialized["isPaid"] is True
assert serialized["paidAccess"] == {"permanent": True, "endsAt": None}
def test_serialize_version_timed_paid_is_early_access():
"""Timed paid gates (endsAt in the future) stay flagged as early access."""
version = ModelVersionRecord(
version_id=8, name="v8", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at="2099-01-01T00:00:00.000Z", is_early_access=True,
usage_control="Download",
paid_access=json.dumps({"permanent": False, "endsAt": "2099-01-01T00:00:00.000Z"}),
is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is True
assert serialized["isPaid"] is False
def test_serialize_version_malformed_paid_access_does_not_crash():
"""A malformed paid_access row must degrade to None instead of failing
the whole versions-list response."""
version = ModelVersionRecord(
version_id=10, name="v10", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control=None,
paid_access="{not json", is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["paidAccess"] is None
assert serialized["isEarlyAccess"] is True
async def test_enrich_early_access_details_skips_permanent_paid(monkeypatch):
"""Permanent paid versions must not trigger per-version CivitAI fetches in
_enrich_early_access_details: they are not early access and can never get
an end time, so enriching them is wasted API traffic."""
record = ModelUpdateRecord(
model_type="lora",
model_id=1,
versions=[
ModelVersionRecord(
version_id=100, name="paid", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access='{"permanent": true, "endsAt": null}', is_paid=True,
),
ModelVersionRecord(
version_id=200, name="ea", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access=None, is_paid=False,
),
],
last_checked_at=1.0,
should_ignore_model=False,
)
fetched: list[int] = []
async def fake_version_info(version_id: str):
fetched.append(int(version_id))
return {"earlyAccessEndsAt": "2099-01-01T00:00:00.000Z"}, None
provider = SimpleNamespace(get_model_version_info=fake_version_info)
async def metadata_selector(name):
assert name == "civitai_api"
return provider
handler = ModelUpdateHandler(
service=DummyService(SimpleNamespace(raw_data=[], version_index={})),
update_service=SimpleNamespace(),
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
enriched = await handler._enrich_early_access_details(record)
# Only the timed EA version (200) is fetched; the permanent paid one (100) is skipped.
assert fetched == [200]
enriched_map = {v.version_id: v for v in enriched.versions}
assert enriched_map[200].early_access_ends_at == "2099-01-01T00:00:00.000Z"
assert enriched_map[100].early_access_ends_at is None
+6 -2
View File
@@ -82,7 +82,9 @@ class StubUpdateService:
self.bulk_calls = [] self.bulk_calls = []
self.bulk_error = bulk_error 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))) self.bulk_calls.append((model_type, list(model_ids)))
if self.bulk_error: if self.bulk_error:
raise RuntimeError("bulk failure") raise RuntimeError("bulk failure")
@@ -94,7 +96,9 @@ class StubUpdateService:
results[model_id] = result results[model_id] = result
return results 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)) self.calls.append((model_type, model_id))
result = self.decisions.get(model_id, False) result = self.decisions.get(model_id, False)
if isinstance(result, Exception): if isinstance(result, Exception):
+177 -1
View File
@@ -59,7 +59,17 @@ class NotFoundProvider:
return {} 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( return ModelVersionRecord(
version_id=version_id, version_id=version_id,
name=None, name=None,
@@ -69,6 +79,10 @@ def make_version(version_id, *, in_library, base_model=None, should_ignore=False
preview_url=None, preview_url=None,
is_in_library=in_library, is_in_library=in_library,
should_ignore=should_ignore, 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) # has_update must be True (version 20 > max_in_library=15)
assert record.has_update() is True 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
+484 -4
View File
@@ -1883,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"}) 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) 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}) assert not scanner._is_rematch_candidate({"isDeleted": True})
@@ -2220,6 +2221,481 @@ async def test_match_rematch_type_gate_lora_accepts_lora_typed_item(tmp_path: Pa
assert matched is not None 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 # _build_rematch_autov3_cache
@@ -3089,6 +3565,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
recipe: Dict[str, Any], recipe: Dict[str, Any],
local_cache: dict[str, Any], local_cache: dict[str, Any],
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
filename_cache=None,
) -> tuple[int, int, dict[str, Any]]: ) -> tuple[int, int, dict[str, Any]]:
if recipe.get("id") == "boom": if recipe.get("id") == "boom":
raise RuntimeError("kaboom") raise RuntimeError("kaboom")
@@ -3146,12 +3623,13 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
recipe: Dict[str, Any], recipe: Dict[str, Any],
local_cache: dict[str, Any], local_cache: dict[str, Any],
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
) -> tuple[int, int]: filename_cache=None,
) -> tuple[int, int, dict[str, Any]]:
nonlocal entered nonlocal entered
if recipe.get("id") == "r0": if recipe.get("id") == "r0":
entered = True entered = True
await release.wait() 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) monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
@@ -3271,6 +3749,8 @@ async def test_rematch_bulk_generic_exception_continues(tmp_path: Path, monkeypa
autov3_cache: dict[str, Any], autov3_cache: dict[str, Any],
*, *,
is_checkpoint: bool, is_checkpoint: bool,
filename_cache=None,
recipe_base_model=None,
) -> Any: ) -> Any:
nonlocal calls nonlocal calls
calls += 1 calls += 1
+6 -25
View File
@@ -33,14 +33,14 @@ const TAG_COMMANDS = {
'/embedding': { type: 'embedding', label: 'Embeddings' }, '/embedding': { type: 'embedding', label: 'Embeddings' },
...WILDCARD_COMMANDS, ...WILDCARD_COMMANDS,
// Autocomplete toggle commands - only show one based on current state // Autocomplete toggle commands - only show one based on current state
'/ac': { '/autocomplete': {
type: 'toggle_setting', type: 'toggle_setting',
settingId: 'loramanager.prompt_tag_autocomplete', settingId: 'loramanager.prompt_tag_autocomplete',
value: true, value: true,
label: 'Autocomplete: ON', label: 'Autocomplete: ON',
condition: () => !getPromptTagAutocompletePreference() condition: () => !getPromptTagAutocompletePreference()
}, },
'/noac': { '/noautocomplete': {
type: 'toggle_setting', type: 'toggle_setting',
settingId: 'loramanager.prompt_tag_autocomplete', settingId: 'loramanager.prompt_tag_autocomplete',
value: false, value: false,
@@ -50,26 +50,7 @@ const TAG_COMMANDS = {
}; };
// Command definitions for LoRA active-filters search // Command definitions for LoRA active-filters search
// Aliases (/activefilters, /noactivefilters) mirror /emb ↔ /embedding
const LORAS_COMMANDS = { 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': { '/activefilters': {
type: 'toggle_setting', type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete', settingId: 'loramanager.lora_active_filters_autocomplete',
@@ -761,7 +742,7 @@ class AutoComplete {
searchTerm = (match[1] || '').trim(); 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') { if (this.modelType === 'loras') {
const commandResult = this._parseCommandInput(rawSearchTerm); const commandResult = this._parseCommandInput(rawSearchTerm);
@@ -773,7 +754,7 @@ class AutoComplete {
this._showCommandList(commandResult.commandFilter); this._showCommandList(commandResult.commandFilter);
return; return;
} else if (commandResult.command?.type === 'toggle_setting') { } else if (commandResult.command?.type === 'toggle_setting') {
// Handle toggle setting command (/af, /noaf) // Handle toggle setting command (/activefilters, /noactivefilters)
this._handleToggleSettingCommand(commandResult.command); this._handleToggleSettingCommand(commandResult.command);
return; return;
} else if (commandResult.command) { } else if (commandResult.command) {
@@ -813,7 +794,7 @@ class AutoComplete {
this._showCommandList(commandResult.commandFilter); this._showCommandList(commandResult.commandFilter);
return; return;
} else if (commandResult.command?.type === 'toggle_setting') { } else if (commandResult.command?.type === 'toggle_setting') {
// Handle toggle setting command (/ac, /noac) // Handle toggle setting command (/autocomplete, /noautocomplete)
this._handleToggleSettingCommand(commandResult.command); this._handleToggleSettingCommand(commandResult.command);
return; return;
} else if (commandResult.command) { } 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 * @param {Object} command - The toggle command with settingId and value
*/ */
async _handleToggleSettingCommand(command) { async _handleToggleSettingCommand(command) {