Compare commits

...
Author SHA1 Message Date
Will Miao 56acefbd6c feat(autocomplete): search loras within active filters of LoRA Manager page
Add /af and /noaf toggle commands (plus /activefilters aliases) to the
loras autocomplete widget. When enabled (default off), suggestions are
matched within the active filters (folder, base model, tags, auto-tags,
license, tag logic) persisted by the LoRA Manager page in localStorage,
keeping the match pool consistent with the list endpoint, including the
global show_only_sfw setting.

Backend: /lm/{prefix}/relative-paths accepts the filter query params and
pre-filters the scanner cache with ModelFilterSet. The presence of the
recursive param signals the filter pipeline to run even without concrete
filters so global settings stay in parity with the list endpoint.
2026-08-07 20:07:34 +08:00
Will Miao 5ab06c4aae docs: rename "Standalone Web UI" to "LoRA Manager Web UI" in AGENTS.md 2026-08-07 17:57:01 +08:00
Will Miao c11f4b5c68 feat(ui): widen filter panel and preset name limit 2026-08-07 16:53:14 +08:00
Will Miao 86376284f4 fix(ui): clamp filter panel height to viewport 2026-08-07 16:53:04 +08:00
Will Miao 2b8a2fc7d8 feat(filters): remove preset count limit 2026-08-07 16:52:53 +08:00
Will Miao f26e1b41c8 fix(i18n): translate zh-TW api key placeholder 2026-08-07 16:25:40 +08:00
Will Miao c1671af99f feat(downloads): translate batch download summary strings 2026-08-07 16:23:55 +08:00
Will Miao ac7707d0f6 fix(cards): clear model-card min-width on the item element itself 2026-08-07 16:09:51 +08:00
Will Miao 381cd710a2 feat(recipes): translate recipes layout setting strings 2026-08-07 15:36:30 +08:00
Will Miao ad0d18cb79 chore: ignore .playwright-mcp working directory 2026-08-07 15:33:43 +08:00
Will Miao 7980ee77d0 perf(recipes): batch preview dimension reads via asyncio.gather 2026-08-07 15:31:13 +08:00
Will Miao 916b8bb327 fix(recipes): skip stale scroller re-enable on deferred layout switch 2026-08-07 14:03:13 +08:00
Will Miao 87e3d4dea9 feat(recipes): wire recipes layout switch event and rebuild 2026-08-07 12:49:30 +08:00
Will Miao 76a913f5e0 feat(recipes): complete MasonryScroller public API parity with VirtualScroller 2026-08-07 12:40:28 +08:00
Will Miao d8c192e647 feat(recipes): branch masonry scroller instantiation for recipes page 2026-08-07 12:38:17 +08:00
Will Miao c453437620 feat(recipes): add MasonryScroller with column-based virtual scrolling 2026-08-07 12:31:13 +08:00
Will Miao 720fa6d909 feat(recipes): expose preview width/height in recipe listing API 2026-08-07 11:59:26 +08:00
Will Miao b4f71089f4 feat(recipes): add recipes_layout setting (grid|masonry) with i18n 2026-08-07 11:49:12 +08:00
Will Miao 83e6657ead feat(recipes): add get_image_dimensions helper with LRU cache 2026-08-07 11:47:28 +08:00
Will Miao 7ea6df4111 feat(downloads): default to latest version when URL lacks modelVersionId
Auto-select the first (newest) version for URLs without an explicit
modelVersionId, matching the existing batch flow, so users can proceed
to location/download without manually picking a version.
2026-08-07 11:24:45 +08:00
Will Miao d9ab92602a feat(downloads): show failure summary modal for single downloads too 2026-08-07 10:49:14 +08:00
pixelpaws 5ffadaed31 Merge pull request #1054 from willmiao/feat/gemini-provider
feat(llm): add Gemini as a preset AI provider
2026-08-07 10:30:45 +08:00
46 changed files with 4144 additions and 196 deletions
+1
View File
@@ -25,6 +25,7 @@ model_cache/
reasonix.toml reasonix.toml
.reasonix/ .reasonix/
.codegraph/ .codegraph/
.playwright-mcp/
# Vue widgets development cache (but keep build output) # Vue widgets development cache (but keep build output)
vue-widgets/node_modules/ vue-widgets/node_modules/
+3 -3
View File
@@ -31,7 +31,7 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov-report=xml:coverage/backend/coverage.xml --cov-report=xml:coverage/backend/coverage.xml
``` ```
### Frontend Development (Standalone Web UI) ### Frontend Development (LoRA Manager Web UI)
```bash ```bash
npm install npm install
@@ -154,9 +154,9 @@ npm run test:coverage # Generate coverage report
## Frontend UI Architecture ## Frontend UI Architecture
### 1. Standalone Web UI ### 1. LoRA Manager Web UI
- Location: `./static/` and `./templates/` - Location: `./static/` and `./templates/`
- Tech: Vanilla JS + CSS, served by standalone server - Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
- Tests via npm in root directory - Tests via npm in root directory
### 2. ComfyUI Custom Node Widgets ### 2. ComfyUI Custom Node Widgets
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.", "displayDensityWarning": "Warnung: Höhere Dichten können bei Systemen mit begrenzten Ressourcen zu Performance-Problemen führen.",
"recipesLayout": "Rezepte-Layout",
"recipesLayoutHelp": "Wählen Sie, wie Rezeptkarten angeordnet werden: ein einheitliches Raster oder ein Masonry-Layout (Pinterest-Stil), das das Seitenverhältnis jedes Bildes beibehält.",
"recipesLayoutOptions": {
"grid": "Raster",
"masonry": "Masonry"
},
"showFolderSidebar": "Ordner-Seitenleiste anzeigen", "showFolderSidebar": "Ordner-Seitenleiste anzeigen",
"showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.", "showFolderSidebarHelp": "Blenden Sie die Ordner-Navigationsleiste auf den Modellseiten ein oder aus. Wenn deaktiviert, bleiben Seitenleiste und Hoverbereich verborgen.",
"cardInfoDisplay": "Karten-Info-Anzeige", "cardInfoDisplay": "Karten-Info-Anzeige",
@@ -1584,19 +1590,19 @@
"columnError": "Fehler" "columnError": "Fehler"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "Zusammenfassung des Batch-Downloads",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "Erfolgreich",
"statFailed": "[TODO: Translate] Failed", "statFailed": "Fehlgeschlagen",
"statTotal": "[TODO: Translate] Total", "statTotal": "Gesamt",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "Alle {count} Modelle erfolgreich heruntergeladen",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Abgeschlossen, aber mit Fehlern",
"failed": "[TODO: Translate] Download failed", "failed": "Download fehlgeschlagen",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "Fehlgeschlagene Elemente ({count})",
"columnName": "[TODO: Translate] Model Name", "columnName": "Modellname",
"columnError": "[TODO: Translate] Error", "columnError": "Fehler",
"close": "[TODO: Translate] Close", "close": "Schließen",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "Bericht kopieren",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "Fehlgeschlagene erneut versuchen ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben", "presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
"presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen", "presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
"presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits", "presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
"maxPresetsReached": "Maximal {max} Voreinstellungen erlaubt. Löschen Sie eine, um weitere hinzuzufügen.",
"presetNotFound": "Voreinstellung nicht gefunden", "presetNotFound": "Voreinstellung nicht gefunden",
"invalidPreset": "Ungültige Voreinstellungsdaten", "invalidPreset": "Ungültige Voreinstellungsdaten",
"deletePresetFailed": "Fehler beim Löschen der Voreinstellung", "deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
+6 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.", "displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
"recipesLayout": "Recipes Layout",
"recipesLayoutHelp": "Choose how recipe cards are arranged: a uniform grid or a masonry (Pinterest-style) layout that preserves each image's aspect ratio.",
"recipesLayoutOptions": {
"grid": "Grid",
"masonry": "Masonry"
},
"showFolderSidebar": "Show Folder Sidebar", "showFolderSidebar": "Show Folder Sidebar",
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.", "showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
"cardInfoDisplay": "Card Info Display", "cardInfoDisplay": "Card Info Display",
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "Preset name must be {max} characters or less", "presetNameTooLong": "Preset name must be {max} characters or less",
"presetNameInvalidChars": "Preset name contains invalid characters", "presetNameInvalidChars": "Preset name contains invalid characters",
"presetNameExists": "A preset with this name already exists", "presetNameExists": "A preset with this name already exists",
"maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
"presetNotFound": "Preset not found", "presetNotFound": "Preset not found",
"invalidPreset": "Invalid preset data", "invalidPreset": "Invalid preset data",
"deletePresetFailed": "Failed to delete preset", "deletePresetFailed": "Failed to delete preset",
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.", "displayDensityWarning": "Advertencia: Densidades más altas pueden causar problemas de rendimiento en sistemas con recursos limitados.",
"recipesLayout": "Diseño de recetas",
"recipesLayoutHelp": "Elige cómo se organizan las tarjetas de recetas: una cuadrícula uniforme o un diseño masonry (estilo Pinterest) que conserva la proporción de aspecto de cada imagen.",
"recipesLayoutOptions": {
"grid": "Cuadrícula",
"masonry": "Masonry"
},
"showFolderSidebar": "Mostrar barra lateral de carpetas", "showFolderSidebar": "Mostrar barra lateral de carpetas",
"showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.", "showFolderSidebarHelp": "Activa o desactiva la barra lateral de navegación de carpetas en las páginas de modelos. Cuando está desactivada, la barra lateral y el área de desplazamiento permanecen ocultas.",
"cardInfoDisplay": "Visualización de información de tarjeta", "cardInfoDisplay": "Visualización de información de tarjeta",
@@ -1584,19 +1590,19 @@
"columnError": "Error" "columnError": "Error"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "Resumen de descarga por lotes",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "Correctos",
"statFailed": "[TODO: Translate] Failed", "statFailed": "Fallidos",
"statTotal": "[TODO: Translate] Total", "statTotal": "Total",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "Todos los {count} modelos se descargaron correctamente",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Completado con errores",
"failed": "[TODO: Translate] Download failed", "failed": "Descarga fallida",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "Elementos fallidos ({count})",
"columnName": "[TODO: Translate] Model Name", "columnName": "Nombre del modelo",
"columnError": "[TODO: Translate] Error", "columnError": "Error",
"close": "[TODO: Translate] Close", "close": "Cerrar",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "Copiar informe",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "Reintentar fallidos ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos", "presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
"presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos", "presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
"presetNameExists": "Ya existe un preajuste con este nombre", "presetNameExists": "Ya existe un preajuste con este nombre",
"maxPresetsReached": "Máximo {max} preajustes permitidos. Elimine uno para agregar más.",
"presetNotFound": "Preajuste no encontrado", "presetNotFound": "Preajuste no encontrado",
"invalidPreset": "Datos de preajuste inválidos", "invalidPreset": "Datos de preajuste inválidos",
"deletePresetFailed": "Error al eliminar el preajuste", "deletePresetFailed": "Error al eliminar el preajuste",
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.", "displayDensityWarning": "Attention : Des densités plus élevées peuvent causer des problèmes de performance sur les systèmes avec des ressources limitées.",
"recipesLayout": "Disposition des recettes",
"recipesLayoutHelp": "Choisissez comment les cartes de recettes sont organisées : une grille uniforme ou une disposition masonry (style Pinterest) qui préserve le rapport d'aspect de chaque image.",
"recipesLayoutOptions": {
"grid": "Grille",
"masonry": "Masonry"
},
"showFolderSidebar": "Afficher la barre latérale des dossiers", "showFolderSidebar": "Afficher la barre latérale des dossiers",
"showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.", "showFolderSidebarHelp": "Activez ou désactivez la barre latérale de navigation des dossiers sur les pages de modèles. Lorsqu'elle est désactivée, la barre latérale et la zone de survol restent masquées.",
"cardInfoDisplay": "Affichage des informations de carte", "cardInfoDisplay": "Affichage des informations de carte",
@@ -1584,19 +1590,19 @@
"columnError": "Erreur" "columnError": "Erreur"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "Résumé du téléchargement groupé",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "Réussis",
"statFailed": "[TODO: Translate] Failed", "statFailed": "Échoués",
"statTotal": "[TODO: Translate] Total", "statTotal": "Total",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "Les {count} modèles ont été téléchargés avec succès",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Terminé avec des erreurs",
"failed": "[TODO: Translate] Download failed", "failed": "Échec du téléchargement",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "Éléments échoués ({count})",
"columnName": "[TODO: Translate] Model Name", "columnName": "Nom du modèle",
"columnError": "[TODO: Translate] Error", "columnError": "Erreur",
"close": "[TODO: Translate] Close", "close": "Fermer",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "Copier le rapport",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "Réessayer les échecs ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères", "presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
"presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides", "presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
"presetNameExists": "Un préréglage avec ce nom existe déjà", "presetNameExists": "Un préréglage avec ce nom existe déjà",
"maxPresetsReached": "Maximum {max} préréglages autorisés. Supprimez-en un pour en ajouter plus.",
"presetNotFound": "Préréglage non trouvé", "presetNotFound": "Préréglage non trouvé",
"invalidPreset": "Données de préréglage invalides", "invalidPreset": "Données de préréglage invalides",
"deletePresetFailed": "Échec de la suppression du préréglage", "deletePresetFailed": "Échec de la suppression du préréglage",
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.", "displayDensityWarning": "אזהרה: צפיפויות גבוהות יותר עלולות לגרום לבעיות ביצועים במערכות עם משאבים מוגבלים.",
"recipesLayout": "פריסת מתכונים",
"recipesLayoutHelp": "בחר כיצד יסודרו כרטיסי המתכונים: רשת אחידה או פריסת Masonry (בסגנון Pinterest) השומרת על יחס הגובה-רוחב של כל תמונה.",
"recipesLayoutOptions": {
"grid": "רשת",
"masonry": "Masonry"
},
"showFolderSidebar": "הצג סרגל צד תיקיות", "showFolderSidebar": "הצג סרגל צד תיקיות",
"showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.", "showFolderSidebarHelp": "הפעל או כבה את סרגל הצד לניווט תיקיות בדפי המודל. כאשר הוא כבוי, סרגל הצד ואזור הריחוף נשארים מוסתרים.",
"cardInfoDisplay": "תצוגת מידע בכרטיס", "cardInfoDisplay": "תצוגת מידע בכרטיס",
@@ -1584,19 +1590,19 @@
"columnError": "שגיאה" "columnError": "שגיאה"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "סיכום הורדה בכמות",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "הצליחו",
"statFailed": "[TODO: Translate] Failed", "statFailed": "נכשלו",
"statTotal": "[TODO: Translate] Total", "statTotal": "סה\"כ",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "כל {count} הדגמים הורדו בהצלחה",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "הושלם עם שגיאות",
"failed": "[TODO: Translate] Download failed", "failed": "ההורדה נכשלה",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "פריטים שנכשלו ({count})",
"columnName": "[TODO: Translate] Model Name", "columnName": "שם הדגם",
"columnError": "[TODO: Translate] Error", "columnError": "שגיאה",
"close": "[TODO: Translate] Close", "close": "סגור",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "העתק דוח",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "נסה שוב ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות", "presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים", "presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת", "presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
"maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
"presetNotFound": "קביעה מראש לא נמצאה", "presetNotFound": "קביעה מראש לא נמצאה",
"invalidPreset": "נתוני קביעה מראש לא חוקיים", "invalidPreset": "נתוני קביעה מראש לא חוקיים",
"deletePresetFailed": "מחיקת קביעה מראש נכשלה", "deletePresetFailed": "מחיקת קביעה מראש נכשלה",
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "71080p)、82K)、104K" "compact": "71080p)、82K)、104K"
}, },
"displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。", "displayDensityWarning": "警告:高密度設定は、リソースが限られたシステムでパフォーマンスの問題を引き起こす可能性があります。",
"recipesLayout": "レシピのレイアウト",
"recipesLayoutHelp": "レシピカードの配置方法を選択:均一なグリッド、または各画像のアスペクト比を保持するメイソンリー(Pinterest スタイル)レイアウト。",
"recipesLayoutOptions": {
"grid": "グリッド",
"masonry": "メイソンリー"
},
"showFolderSidebar": "フォルダサイドバーを表示", "showFolderSidebar": "フォルダサイドバーを表示",
"showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。", "showFolderSidebarHelp": "モデルページのフォルダナビゲーションサイドバーを表示/非表示にします。無効にするとサイドバーとホバーエリアは表示されません。",
"cardInfoDisplay": "カード情報表示", "cardInfoDisplay": "カード情報表示",
@@ -1584,19 +1590,19 @@
"columnError": "エラー" "columnError": "エラー"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "バッチダウンロードの概要",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "成功",
"statFailed": "[TODO: Translate] Failed", "statFailed": "失敗",
"statTotal": "[TODO: Translate] Total", "statTotal": "合計",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "{count} 個のモデルがすべて正常にダウンロードされました",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "エラーありで完了",
"failed": "[TODO: Translate] Download failed", "failed": "ダウンロードに失敗しました",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "失敗した項目({count}",
"columnName": "[TODO: Translate] Model Name", "columnName": "モデル名",
"columnError": "[TODO: Translate] Error", "columnError": "エラー",
"close": "[TODO: Translate] Close", "close": "閉じる",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "レポートをコピー",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "失敗した項目を再試行({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "プリセット名は{max}文字以内にしてください", "presetNameTooLong": "プリセット名は{max}文字以内にしてください",
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています", "presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
"presetNameExists": "同じ名前のプリセットが既に存在します", "presetNameExists": "同じ名前のプリセットが既に存在します",
"maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
"presetNotFound": "プリセットが見つかりません", "presetNotFound": "プリセットが見つかりません",
"invalidPreset": "無効なプリセットデータです", "invalidPreset": "無効なプリセットデータです",
"deletePresetFailed": "プリセットの削除に失敗しました", "deletePresetFailed": "プリセットの削除に失敗しました",
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "7개 (1080p), 8개 (2K), 10개 (4K)" "compact": "7개 (1080p), 8개 (2K), 10개 (4K)"
}, },
"displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.", "displayDensityWarning": "경고: 높은 밀도는 리소스가 제한된 시스템에서 성능 문제를 일으킬 수 있습니다.",
"recipesLayout": "레시피 레이아웃",
"recipesLayoutHelp": "레시피 카드의 배열 방식을 선택하세요: 균일한 그리드 또는 각 이미지의 종횡비를 유지하는 메이슨리(Pinterest 스타일) 레이아웃.",
"recipesLayoutOptions": {
"grid": "그리드",
"masonry": "메이슨리"
},
"showFolderSidebar": "폴더 사이드바 표시", "showFolderSidebar": "폴더 사이드바 표시",
"showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.", "showFolderSidebarHelp": "모델 페이지에서 폴더 탐색 사이드바를 켜거나 끕니다. 비활성화하면 사이드바와 호버 영역이 표시되지 않습니다.",
"cardInfoDisplay": "카드 정보 표시", "cardInfoDisplay": "카드 정보 표시",
@@ -1584,19 +1590,19 @@
"columnError": "오류" "columnError": "오류"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "일괄 다운로드 요약",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "성공",
"statFailed": "[TODO: Translate] Failed", "statFailed": "실패",
"statTotal": "[TODO: Translate] Total", "statTotal": "전체",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "{count}개 모델이 모두 성공적으로 다운로드되었습니다",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "오류와 함께 완료됨",
"failed": "[TODO: Translate] Download failed", "failed": "다운로드 실패",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "실패한 항목 ({count})",
"columnName": "[TODO: Translate] Model Name", "columnName": "모델 이름",
"columnError": "[TODO: Translate] Error", "columnError": "오류",
"close": "[TODO: Translate] Close", "close": "닫기",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "보고서 복사",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "실패 항목 재시도 ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다", "presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다", "presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다", "presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
"maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
"presetNotFound": "프리셋을 찾을 수 없습니다", "presetNotFound": "프리셋을 찾을 수 없습니다",
"invalidPreset": "잘못된 프리셋 데이터입니다", "invalidPreset": "잘못된 프리셋 데이터입니다",
"deletePresetFailed": "프리셋 삭제에 실패했습니다", "deletePresetFailed": "프리셋 삭제에 실패했습니다",
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)" "compact": "7 (1080p), 8 (2K), 10 (4K)"
}, },
"displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.", "displayDensityWarning": "Предупреждение: Высокая плотность может вызвать проблемы с производительностью на системах с ограниченными ресурсами.",
"recipesLayout": "Макет рецептов",
"recipesLayoutHelp": "Выберите, как располагаются карточки рецептов: единая сетка или masonry-макет (в стиле Pinterest), сохраняющий пропорции каждого изображения.",
"recipesLayoutOptions": {
"grid": "Сетка",
"masonry": "Masonry"
},
"showFolderSidebar": "Показывать боковую панель папок", "showFolderSidebar": "Показывать боковую панель папок",
"showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.", "showFolderSidebarHelp": "Включает или выключает боковую панель навигации по папкам на страницах моделей. При отключении панель и область наведения скрыты.",
"cardInfoDisplay": "Отображение информации карточки", "cardInfoDisplay": "Отображение информации карточки",
@@ -1584,19 +1590,19 @@
"columnError": "Ошибка" "columnError": "Ошибка"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "Сводка пакетной загрузки",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "Успешно",
"statFailed": "[TODO: Translate] Failed", "statFailed": "Ошибки",
"statTotal": "[TODO: Translate] Total", "statTotal": "Всего",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "Все {count} моделей успешно загружены",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "Завершено с ошибками",
"failed": "[TODO: Translate] Download failed", "failed": "Не удалось загрузить",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "Неудачные элементы ({count})",
"columnName": "[TODO: Translate] Model Name", "columnName": "Имя модели",
"columnError": "[TODO: Translate] Error", "columnError": "Ошибка",
"close": "[TODO: Translate] Close", "close": "Закрыть",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "Скопировать отчёт",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "Повторить неудачные ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов", "presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы", "presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
"presetNameExists": "Пресет с таким именем уже существует", "presetNameExists": "Пресет с таким именем уже существует",
"maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
"presetNotFound": "Пресет не найден", "presetNotFound": "Пресет не найден",
"invalidPreset": "Недопустимые данные пресета", "invalidPreset": "Недопустимые данные пресета",
"deletePresetFailed": "Не удалось удалить пресет", "deletePresetFailed": "Не удалось удалить пресет",
+19 -14
View File
@@ -449,6 +449,12 @@
"compact": "71080p),82K),104K" "compact": "71080p),82K),104K"
}, },
"displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。", "displayDensityWarning": "警告:高密度可能导致资源有限的系统性能下降。",
"recipesLayout": "配方布局",
"recipesLayoutHelp": "选择配方卡片的排列方式:统一网格,或保留每张图片原始宽高比的瀑布流(Pinterest 风格)布局。",
"recipesLayoutOptions": {
"grid": "网格",
"masonry": "瀑布流"
},
"showFolderSidebar": "显示文件夹侧边栏", "showFolderSidebar": "显示文件夹侧边栏",
"showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。", "showFolderSidebarHelp": "在模型页面启用或禁用文件夹导航侧边栏。关闭后,侧边栏和悬停区域将保持隐藏。",
"cardInfoDisplay": "卡片信息显示", "cardInfoDisplay": "卡片信息显示",
@@ -1584,19 +1590,19 @@
"columnError": "错误" "columnError": "错误"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "批量下载摘要",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "成功",
"statFailed": "[TODO: Translate] Failed", "statFailed": "失败",
"statTotal": "[TODO: Translate] Total", "statTotal": "总数",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "全部 {count} 个模型下载成功",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "已完成,但有错误",
"failed": "[TODO: Translate] Download failed", "failed": "下载失败",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "失败项({count}",
"columnName": "[TODO: Translate] Model Name", "columnName": "模型名称",
"columnError": "[TODO: Translate] Error", "columnError": "错误",
"close": "[TODO: Translate] Close", "close": "关闭",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "复制报告",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "重试失败项({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "预设名称不能超过 {max} 个字符", "presetNameTooLong": "预设名称不能超过 {max} 个字符",
"presetNameInvalidChars": "预设名称包含无效字符", "presetNameInvalidChars": "预设名称包含无效字符",
"presetNameExists": "已存在同名预设", "presetNameExists": "已存在同名预设",
"maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
"presetNotFound": "预设未找到", "presetNotFound": "预设未找到",
"invalidPreset": "无效的预设数据", "invalidPreset": "无效的预设数据",
"deletePresetFailed": "删除预设失败", "deletePresetFailed": "删除预设失败",
+20 -15
View File
@@ -449,6 +449,12 @@
"compact": "71080p)、82K)、104K" "compact": "71080p)、82K)、104K"
}, },
"displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。", "displayDensityWarning": "警告:較高密度可能導致資源有限的系統效能下降。",
"recipesLayout": "配方版面",
"recipesLayoutHelp": "選擇配方卡片的排列方式:統一網格,或保留每張圖片原始寬高比的瀑布流(Pinterest 風格)版面。",
"recipesLayoutOptions": {
"grid": "網格",
"masonry": "瀑布流"
},
"showFolderSidebar": "顯示資料夾側邊欄", "showFolderSidebar": "顯示資料夾側邊欄",
"showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。", "showFolderSidebarHelp": "在模型頁面啟用或停用資料夾導覽側邊欄。停用後,側邊欄與滑鼠懸停區域將保持隱藏。",
"cardInfoDisplay": "卡片資訊顯示", "cardInfoDisplay": "卡片資訊顯示",
@@ -687,7 +693,7 @@
"apiBasePlaceholder": "https://api.openai.com/v1", "apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 金鑰", "apiKey": "API 金鑰",
"apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。", "apiKeyHelp": "LLM 提供者的 API 金鑰。儲存在本地,除您選擇的 LLM 提供者外不會傳送到任何伺服器。",
"apiKeyPlaceholder": "[TODO: Translate] sk-...", "apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未設定", "apiKeyNotSet": "未設定",
"apiKeyConfigured": "已設定", "apiKeyConfigured": "已設定",
"apiKeySet": "設定", "apiKeySet": "設定",
@@ -1584,19 +1590,19 @@
"columnError": "錯誤" "columnError": "錯誤"
}, },
"downloadBatchSummary": { "downloadBatchSummary": {
"title": "[TODO: Translate] Batch Download Summary", "title": "批次下載摘要",
"statSuccess": "[TODO: Translate] Success", "statSuccess": "成功",
"statFailed": "[TODO: Translate] Failed", "statFailed": "失敗",
"statTotal": "[TODO: Translate] Total", "statTotal": "總數",
"successMessage": "[TODO: Translate] All {count} models downloaded successfully", "successMessage": "全部 {count} 個模型下載成功",
"completedWithErrors": "[TODO: Translate] Completed with errors", "completedWithErrors": "已完成,但有錯誤",
"failed": "[TODO: Translate] Download failed", "failed": "下載失敗",
"failedItems": "[TODO: Translate] Failed Items ({count})", "failedItems": "失敗項目({count}",
"columnName": "[TODO: Translate] Model Name", "columnName": "模型名稱",
"columnError": "[TODO: Translate] Error", "columnError": "錯誤",
"close": "[TODO: Translate] Close", "close": "關閉",
"copyReport": "[TODO: Translate] Copy Report", "copyReport": "複製報告",
"retryFailed": "[TODO: Translate] Retry Failed ({count})" "retryFailed": "重試失敗項目({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -2053,7 +2059,6 @@
"presetNameTooLong": "預設名稱不能超過 {max} 個字元", "presetNameTooLong": "預設名稱不能超過 {max} 個字元",
"presetNameInvalidChars": "預設名稱包含無效字元", "presetNameInvalidChars": "預設名稱包含無效字元",
"presetNameExists": "已存在同名預設", "presetNameExists": "已存在同名預設",
"maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
"presetNotFound": "預設未找到", "presetNotFound": "預設未找到",
"invalidPreset": "無效的預設資料", "invalidPreset": "無效的預設資料",
"deletePresetFailed": "刪除預設失敗", "deletePresetFailed": "刪除預設失敗",
+66 -1
View File
@@ -1488,8 +1488,73 @@ class ModelQueryHandler:
search = request.query.get("search", "").strip() search = request.query.get("search", "").strip()
limit = min(int(request.query.get("limit", "15")), 100) limit = min(int(request.query.get("limit", "15")), 100)
offset = max(0, int(request.query.get("offset", "0"))) offset = max(0, int(request.query.get("offset", "0")))
folder = request.query.get("folder")
recursive = request.query.get("recursive", "true").lower() == "true"
base_models = list(request.query.getall("base_model", []))
model_types = list(request.query.getall("model_type", []))
tag_filters: Dict[str, str] = {}
for tag in request.query.getall("tag_include", []):
if tag:
tag_filters[tag] = "include"
for tag in request.query.getall("tag_exclude", []):
if tag:
tag_filters[tag] = "exclude"
auto_tag_filters: Dict[str, str] = {}
for tag in request.query.getall("auto_tag_include", []):
if tag:
auto_tag_filters[tag] = "include"
for tag in request.query.getall("auto_tag_exclude", []):
if tag:
auto_tag_filters[tag] = "exclude"
tag_logic = request.query.get("tag_logic", "any").lower()
if tag_logic not in ("any", "all"):
tag_logic = "any"
credit_required = request.query.get("credit_required")
if credit_required is not None:
credit_required = credit_required.lower() not in ("false", "0", "")
allow_selling_generated_content = request.query.get(
"allow_selling_generated_content"
)
if allow_selling_generated_content is not None:
allow_selling_generated_content = (
allow_selling_generated_content.lower() not in ("false", "0", "")
)
# The presence of the recursive param (always sent by the loras
# widget when filter mode is on) signals that the filter pipeline
# must run even when no concrete filter is set, so global settings
# like show_only_sfw stay consistent with the list endpoint.
apply_filters = (
"recursive" in request.query
or folder is not None
or bool(base_models)
or bool(model_types)
or bool(tag_filters)
or bool(auto_tag_filters)
or credit_required is not None
or allow_selling_generated_content is not None
)
matching_paths = await self._service.search_relative_paths( matching_paths = await self._service.search_relative_paths(
search, limit, offset search,
limit,
offset,
folder=folder,
recursive=recursive,
base_models=base_models,
model_types=model_types,
tags=tag_filters,
auto_tags=auto_tag_filters,
tag_logic=tag_logic,
credit_required=credit_required,
allow_selling_generated_content=allow_selling_generated_content,
apply_filters=apply_filters,
) )
return web.json_response( return web.json_response(
{"success": True, "relative_paths": matching_paths} {"success": True, "relative_paths": matching_paths}
+39 -2
View File
@@ -10,7 +10,7 @@ import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
from aiohttp import web from aiohttp import web
@@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
RecipeScannerGetter = Callable[[], Any] RecipeScannerGetter = Callable[[], Any]
CivitaiClientGetter = Callable[[], Any] CivitaiClientGetter = Callable[[], Any]
# Cap concurrent preview-dimension reads across requests. With a cold LRU
# cache one page can touch up to page_size image files; 16 balances SSD and
# HDD throughput without starving the event loop.
_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16)
async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
"""Read preview dimensions off the event loop under the concurrency cap.
PIL I/O runs in a worker thread so it never blocks the event loop, and the
semaphore bounds how many files are opened at once even when many list
requests land together.
"""
async with _DIMS_READ_SEMAPHORE:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
@dataclass(frozen=True) @dataclass(frozen=True)
class RecipeHandlerSet: class RecipeHandlerSet:
@@ -246,7 +262,8 @@ class RecipeListingHandler:
recursive=recursive, recursive=recursive,
) )
for item in result.get("items", []): items = result.get("items", [])
for item in items:
file_path = item.get("file_path") file_path = item.get("file_path")
if file_path: if file_path:
item["file_url"] = self.format_recipe_file_url(file_path) item["file_url"] = self.format_recipe_file_url(file_path)
@@ -255,6 +272,26 @@ class RecipeListingHandler:
item.setdefault("loras", []) item.setdefault("loras", [])
item.setdefault("base_model", "") item.setdefault("base_model", "")
# Batch preview dimension reads with asyncio.gather. The previous
# loop awaited asyncio.to_thread once per item, so a page_size=100
# request submitted 100 sequential thread calls (50-300ms cold-page
# latency). gather runs them concurrently while the semaphore caps
# disk opens; dimensions stay omitted (not null) when a preview has
# no readable size (video, missing file).
to_read = [
(i, item.get("file_path"))
for i, item in enumerate(items)
if item.get("file_path")
]
if to_read:
dims_list = await asyncio.gather(
*(_read_preview_dims(path) for _, path in to_read)
)
for (idx, _), dims in zip(to_read, dims_list):
if dims:
item = items[idx]
item["width"], item["height"] = dims
return web.json_response(result) return web.json_response(result)
except Exception as exc: except Exception as exc:
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True) self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
+71 -3
View File
@@ -1259,19 +1259,87 @@ class BaseModelService(ABC):
) )
async def search_relative_paths( async def search_relative_paths(
self, search_term: str, limit: int = 15, offset: int = 0 self,
search_term: str,
limit: int = 15,
offset: int = 0,
*,
folder: Optional[str] = None,
folder_include: Optional[list] = None,
folder_exclude: Optional[list] = None,
base_models: Optional[list] = None,
model_types: Optional[list] = None,
tags: Optional[dict] = None,
auto_tags: Optional[dict] = None,
tag_logic: str = "any",
credit_required: Optional[bool] = None,
allow_selling_generated_content: Optional[bool] = None,
recursive: bool = True,
apply_filters: bool = False,
) -> List[str]: ) -> List[str]:
"""Search model relative file paths for autocomplete functionality""" """Search model relative file paths for autocomplete functionality.
Optional filter kwargs mirror the filters used by the list endpoint
(/api/lm/{prefix}/list). When no filter kwargs are provided the
behavior is identical to plain token-based path matching.
"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
include_terms, exclude_terms = self._parse_search_tokens(search_term) include_terms, exclude_terms = self._parse_search_tokens(search_term)
data = cache.raw_data
has_filters = any(
[
apply_filters,
folder is not None,
folder_include,
folder_exclude,
base_models,
model_types,
tags,
auto_tags,
credit_required is not None,
allow_selling_generated_content is not None,
]
)
if has_filters:
# Auto-tags are not stored in the scanner cache — they are computed
# on the fly. Pre-compute them only when an auto-tag filter is
# active to avoid mutating cache entries unnecessarily.
if auto_tags:
from .auto_tag_service import extract_auto_tags
for item in data:
if not item.get("auto_tags"):
item["auto_tags"] = extract_auto_tags(item)
criteria = FilterCriteria(
folder=folder,
folder_include=folder_include,
folder_exclude=folder_exclude,
base_models=base_models,
model_types=model_types,
tags=tags,
auto_tags=auto_tags,
search_options={"recursive": recursive},
tag_logic=tag_logic,
)
data = self.filter_set.apply(data, criteria)
if credit_required is not None:
data = await self._apply_credit_required_filter(
data, credit_required
)
if allow_selling_generated_content is not None:
data = await self._apply_allow_selling_filter(
data, allow_selling_generated_content
)
matching_paths = [] matching_paths = []
# Get model roots for path calculation # Get model roots for path calculation
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset) # Collect all matching paths first (needed for proper sorting and offset)
for model in cache.raw_data: for model in data:
file_path = model.get("file_path", "") file_path = model.get("file_path", "")
if not file_path: if not file_path:
continue continue
+1
View File
@@ -92,6 +92,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"mature_blur_level": "R", "mature_blur_level": "R",
"autoplay_on_hover": False, "autoplay_on_hover": False,
"display_density": "default", "display_density": "default",
"recipes_layout": "grid",
"card_info_display": "always", "card_info_display": "always",
"include_trigger_words": False, "include_trigger_words": False,
"compact_mode": False, "compact_mode": False,
+37 -1
View File
@@ -1,9 +1,10 @@
import functools
import json import json
import logging import logging
import os import os
import struct import struct
from io import BytesIO from io import BytesIO
from typing import Any, Optional from typing import Any, Optional, Tuple
import piexif import piexif
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
@@ -17,6 +18,22 @@ except ImportError:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@functools.lru_cache(maxsize=2048)
def _get_image_dimensions_cached(path: str, _mtime_ns: int, _size: int) -> Optional[Tuple[int, int]]:
"""Return ``(width, height)`` for ``path``, or ``None`` on any failure.
The ``_mtime_ns`` and ``_size`` arguments are part of the cache key only;
they invalidate the entry when the file is replaced with a new image, so a
stale preview never serves outdated dimensions.
"""
try:
with Image.open(path) as img:
return img.size
except Exception:
return None
class ExifUtils: class ExifUtils:
"""Utility functions for working with EXIF data in images""" """Utility functions for working with EXIF data in images"""
@@ -422,6 +439,25 @@ class ExifUtils:
# Metadata is in the middle of the string # Metadata is in the middle of the string
return user_comment[:recipe_marker_index] + user_comment[next_line_index:] return user_comment[:recipe_marker_index] + user_comment[next_line_index:]
@staticmethod
def get_image_dimensions(image_path: str) -> Optional[Tuple[int, int]]:
"""Return ``(width, height)`` for an image, or ``None`` if unavailable.
Video containers (``.mp4``/``.webm``/``.avi``) and formats PIL cannot
read (``.avif``/``.jxl``) return ``None`` before PIL is invoked.
Missing or corrupt files return ``None``. Never raises.
"""
try:
ext = os.path.splitext(image_path)[1].lower()
if ext in ('.mp4', '.webm', '.avi', '.avif', '.jxl'):
return None
stat = os.stat(image_path)
return _get_image_dimensions_cached(
image_path, stat.st_mtime_ns, stat.st_size
)
except Exception:
return None
@staticmethod @staticmethod
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False): def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
""" """
+4
View File
@@ -31,6 +31,10 @@
overflow: hidden; overflow: hidden;
} }
.card-grid.masonry-layout .model-card {
aspect-ratio: auto;
}
.model-card:hover { .model-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
+3 -2
View File
@@ -145,13 +145,14 @@
position: fixed; position: fixed;
right: 20px; right: 20px;
top: 50px; /* Position below header */ top: 50px; /* Position below header */
width: 366px; width: 420px;
background-color: var(--card-bg); background-color: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
z-index: var(--z-overlay); z-index: var(--z-overlay);
padding: 16px; padding: 16px;
box-sizing: border-box; /* Include padding in max-height calculation */
transition: transform 0.3s ease, opacity 0.3s ease; transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: top right; transform-origin: top right;
max-height: calc(100vh - 70px); /* Adjusted for header height */ max-height: calc(100vh - 70px); /* Adjusted for header height */
@@ -563,7 +564,7 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
white-space: nowrap; white-space: nowrap;
max-width: 120px; /* Prevent long names from breaking layout */ max-width: 180px; /* Prevent long names from breaking layout */
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
+12 -3
View File
@@ -2,6 +2,7 @@
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { RecipeCard } from './RecipeCard.js'; import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { recreateVirtualScroll } from '../utils/infiniteScroll.js';
export class DuplicatesManager { export class DuplicatesManager {
constructor(recipeManager) { constructor(recipeManager) {
@@ -71,7 +72,7 @@ export class DuplicatesManager {
this.updateSelectedCount(); this.updateSelectedCount();
} }
exitDuplicateMode() { async exitDuplicateMode() {
this.inDuplicateMode = false; this.inDuplicateMode = false;
this.selectedForDeletion.clear(); this.selectedForDeletion.clear();
@@ -94,8 +95,16 @@ export class DuplicatesManager {
recipeGrid.innerHTML = ''; recipeGrid.innerHTML = '';
} }
// Re-enable virtual scrolling // Re-enable virtual scrolling, or apply a layout switch deferred
state.virtualScroller.enable(); // while duplicates mode was active (enabling the old scroller first
// would let its pending rAF render repopulate the grid after the
// new scroller is created, leaving orphaned/overlapping cards).
if (state.pendingLayoutRecreate) {
state.pendingLayoutRecreate = false;
await recreateVirtualScroll('recipes');
} else if (state.virtualScroller) {
state.virtualScroller.enable();
}
} }
renderDuplicateGroups() { renderDuplicateGroups() {
+112 -8
View File
@@ -6,7 +6,7 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js'; import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js'; import { formatFileSize } from '../utils/formatters.js';
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js'; import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
@@ -235,6 +235,9 @@ export class DownloadManager {
if (this.modelVersionId) { if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
} else {
// No explicit version id in the URL → default to the latest version (Civitai returns newest first)
this.currentVersion = this.versions[0];
} }
this.showVersionStep(); this.showVersionStep();
@@ -428,6 +431,9 @@ export class DownloadManager {
await this.retrieveVersionsForModel(this.modelId, this.source); await this.retrieveVersionsForModel(this.modelId, this.source);
if (this.modelVersionId) { if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
} else {
// No explicit version id → default to the latest version (Civitai returns newest first)
this.currentVersion = this.versions[0];
} }
this.showVersionStep(); this.showVersionStep();
} catch (error) { } catch (error) {
@@ -879,6 +885,26 @@ export class DownloadManager {
this.updateTargetPath(); this.updateTargetPath();
} }
/**
* Synthesize a clickable URL for a single-download failure entry.
* Single downloads have no pasted URL, so the modal link is derived from
* the model/version ids (CivitAI) or the HF repo/file (HuggingFace).
*/
_buildSingleItemUrl({ modelId, versionId, source, repo = null, filename = null }) {
if (source === 'huggingface' && repo) {
const base = `https://huggingface.co/${encodeURI(repo)}`;
return filename ? `${base}/blob/${encodeURI('main')}/${encodeURI(filename)}` : base;
}
if (modelId) {
return buildCivitaiUrl({
modelId,
versionId,
host: normalizeCivitaiPageHost(state?.global?.settings?.civitai_host),
});
}
return null;
}
async executeDownloadWithProgress({ async executeDownloadWithProgress({
modelId, modelId,
versionId, versionId,
@@ -897,6 +923,7 @@ export class DownloadManager {
} }
const displayName = versionName || `#${versionId}`; const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
let ws = null; let ws = null;
let updateProgress = () => { }; let updateProgress = () => { };
let cancelled = false; let cancelled = false;
@@ -985,6 +1012,26 @@ export class DownloadManager {
return true; return true;
} }
if (!response?.success) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId,
versionId,
source,
url: this._buildSingleItemUrl({ modelId, versionId, source }),
},
error: response?.error || 'Unknown error',
name: displayName,
}],
onRetry: () => this.executeDownloadWithProgress(retryParams),
});
return false;
}
showToast('toast.loras.downloadCompleted', {}, 'success'); showToast('toast.loras.downloadCompleted', {}, 'success');
if (closeModal) { if (closeModal) {
@@ -1019,7 +1066,21 @@ export class DownloadManager {
console.log('Download cancelled by user:', downloadId); console.log('Download cancelled by user:', downloadId);
} else { } else {
console.error('Failed to download model version:', error); console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error'); showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId,
versionId,
source,
url: this._buildSingleItemUrl({ modelId, versionId, source }),
},
error: error?.message || 'Unknown error',
name: displayName,
}],
onRetry: () => this.executeDownloadWithProgress(retryParams),
});
} }
return false; return false;
} finally { } finally {
@@ -1034,14 +1095,16 @@ export class DownloadManager {
} }
} }
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) { async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar(); this.loadingManager.restoreProgressBar();
const totalFiles = this.hfSelectedFiles.length; const filesToDownload = files || this.hfSelectedFiles;
const totalFiles = filesToDownload.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles); const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
let cancelled = false; let cancelled = false;
let currentDownloadId = null; let currentDownloadId = null;
const failedFiles = [];
this.loadingManager.showCancelButton(async () => { this.loadingManager.showCancelButton(async () => {
if (cancelled) return; if (cancelled) return;
@@ -1060,7 +1123,7 @@ export class DownloadManager {
for (let i = 0; i < totalFiles; i++) { for (let i = 0; i < totalFiles; i++) {
if (cancelled) break; if (cancelled) break;
const filename = this.hfSelectedFiles[i]; const filename = filesToDownload[i];
updateProgress(0, completedDownloads, filename); updateProgress(0, completedDownloads, filename);
this.loadingManager.setStatus(`Downloading ${filename}...`); this.loadingManager.setStatus(`Downloading ${filename}...`);
@@ -1106,6 +1169,31 @@ export class DownloadManager {
if (response?.success) { if (response?.success) {
completedDownloads++; completedDownloads++;
updateProgress(100, completedDownloads, filename); updateProgress(100, completedDownloads, filename);
} else {
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
},
error: response?.error || 'Unknown error',
name: filename,
});
}
} catch (err) {
if (!cancelled) {
console.error(`Failed to download HF file ${filename}:`, err);
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
},
error: err?.message || 'Unknown error',
name: filename,
});
} }
} finally { } finally {
ws.close(); ws.close();
@@ -1115,11 +1203,27 @@ export class DownloadManager {
if (cancelled) { if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info', showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`); `Download cancelled. ${completedDownloads} item(s) completed.`);
} else { await resetAndReload(true);
showToast('toast.loras.downloadCompleted', {}, 'success'); return true;
} }
if (failedFiles.length === 0) {
showToast('toast.loras.downloadCompleted', {}, 'success');
await resetAndReload(true);
return true;
}
showDownloadBatchSummary({
total: totalFiles,
completed: completedDownloads,
failedItems: failedFiles,
onRetry: () => this._downloadHfSingle({
modelRoot,
targetFolder,
useDefaultPaths,
files: failedFiles.map((f) => f.item.filename),
}),
});
await resetAndReload(true); await resetAndReload(true);
return true; return false;
} catch (error) { } catch (error) {
if (!cancelled) { if (!cancelled) {
console.error('Failed to download HF model:', error); console.error('Failed to download HF model:', error);
-21
View File
@@ -6,7 +6,6 @@ import { state } from '../state/index.js';
// Constants for preset management // Constants for preset management
const PRESETS_STORAGE_VERSION = 'v1'; const PRESETS_STORAGE_VERSION = 'v1';
const MAX_PRESET_NAME_LENGTH = 30; const MAX_PRESET_NAME_LENGTH = 30;
const MAX_PRESETS_COUNT = 10;
// Marker for when wildcard patterns resolve to no matches // Marker for when wildcard patterns resolve to no matches
// This ensures we return empty results instead of all models // This ensures we return empty results instead of all models
@@ -362,15 +361,6 @@ export class FilterPresetManager {
} }
} }
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return false;
}
const preset = { const preset = {
name: trimmedName, name: trimmedName,
filters: this.filterManager.cloneFilters(), filters: this.filterManager.cloneFilters(),
@@ -613,17 +603,6 @@ export class FilterPresetManager {
return; return;
} }
// Check max presets limit before showing input
const presets = this.loadPresets();
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return;
}
this.isInlineNamingActive = true; this.isInlineNamingActive = true;
const presetsContainer = document.getElementById('filterPresets'); const presetsContainer = document.getElementById('filterPresets');
+13
View File
@@ -1017,6 +1017,12 @@ export class SettingsManager {
displayDensitySelect.value = state.global.settings.display_density || 'default'; displayDensitySelect.value = state.global.settings.display_density || 'default';
} }
// Set recipes layout setting
const recipesLayoutSelect = document.getElementById('recipesLayout');
if (recipesLayoutSelect) {
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
}
// Set card info display setting // Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay'); const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
if (cardInfoDisplaySelect) { if (cardInfoDisplaySelect) {
@@ -2288,6 +2294,13 @@ export class SettingsManager {
// 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();
+15 -1
View File
@@ -7,7 +7,7 @@ import { state, getCurrentPageState } from './state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js'; import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js'; import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js'; import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js'; import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js'; import { initSortDropdown } from './components/controls/SortDropdown.js';
@@ -272,6 +272,20 @@ class RecipeManager {
}); });
} }
// Rebuild the scroller on layout switch; in duplicates mode defer until
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
// the old instance while initializeVirtualScroll skips duplicates mode)
window.addEventListener('lm:recipes-layout-changed', () => {
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
state.pendingLayoutRecreate = true;
return;
}
if (typeof recreateVirtualScroll === 'function') {
recreateVirtualScroll('recipes');
}
});
// Initialize dropdown functionality for refresh button // Initialize dropdown functionality for refresh button
this.initDropdowns(); this.initDropdowns();
} }
+1
View File
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
card_blur_amount: 8, card_blur_amount: 8,
autoplay_on_hover: false, autoplay_on_hover: false,
display_density: 'default', display_density: 'default',
recipes_layout: 'grid',
card_info_display: 'always', card_info_display: 'always',
model_name_display: 'model_name', model_name_display: 'model_name',
lora_syntax_format: 'legacy', lora_syntax_format: 'legacy',
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -483,10 +483,18 @@ export class VirtualScroller {
element.style.width = `${this.itemWidth}px`; element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`; element.style.height = `${this.itemHeight}px`;
// Remove max-width constraint from model-card to allow dynamic sizing // Remove max-width/min-width constraints from the model-card to allow
const modelCard = element.querySelector('.model-card'); // dynamic sizing. The card is either the item element itself (e.g.
// RecipeCard returns the .model-card root) or a descendant (ModelCard).
// Without this, the CSS min-width of 200px forces compact-density cards
// wider than their allocated column, and the last column gets clipped
// by the grid's overflow-x: hidden.
const modelCard = element.classList.contains('model-card')
? element
: element.querySelector('.model-card');
if (modelCard) { if (modelCard) {
modelCard.style.maxWidth = 'none'; modelCard.style.maxWidth = 'none';
modelCard.style.minWidth = '0';
} }
return element; return element;
+24 -1
View File
@@ -1,5 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { VirtualScroller } from './VirtualScroller.js'; import { VirtualScroller } from './VirtualScroller.js';
import { MasonryScroller } from './MasonryScroller.js';
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js'; import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { showToast } from './uiHelpers.js'; import { showToast } from './uiHelpers.js';
@@ -141,8 +142,14 @@ async function initializeVirtualScroll(pageType) {
throw new Error(`Required components not available for ${pageType} page`); throw new Error(`Required components not available for ${pageType} page`);
} }
// Masonry applies to the recipes page only; the read pattern mirrors
// VirtualScroller.js (display_density).
const useMasonry = pageType === 'recipes'
&& (state.global.settings?.recipes_layout ?? 'grid') === 'masonry';
const ScrollerClass = useMasonry ? MasonryScroller : VirtualScroller;
// Initialize virtual scroller with renamed container elements // Initialize virtual scroller with renamed container elements
state.virtualScroller = new VirtualScroller({ state.virtualScroller = new ScrollerClass({
gridElement: grid, gridElement: grid,
containerElement: gridContainer, containerElement: gridContainer,
scrollContainer: scrollContainer, scrollContainer: scrollContainer,
@@ -250,3 +257,19 @@ export async function refreshVirtualScroll(options = {}) {
} }
} }
} }
// Rebuild the virtual scroller from scratch (used for layout switching).
// refreshVirtualScroll only resets the existing instance, so it cannot swap
// the scroller class. Keyboard navigation must be cleaned up first:
// setupKeyboardNavigation appends a new document keydown listener on every
// initializeVirtualScroll call and never removes the previous one.
export async function recreateVirtualScroll(pageType) {
cleanupKeyboardNavigation();
if (state.virtualScroller) {
state.virtualScroller.dispose();
state.virtualScroller = null;
}
await initializeVirtualScroll(pageType);
}
+2
View File
@@ -356,6 +356,8 @@ export function updatePanelPositions() {
if (filterPanel) { if (filterPanel) {
filterPanel.style.top = `${topPosition}px`; filterPanel.style.top = `${topPosition}px`;
// Clamp panel height to the viewport below the header
filterPanel.style.maxHeight = `calc(100vh - ${topPosition + 10}px)`;
} }
// Adjust panel horizontal position based on the search container // Adjust panel horizontal position based on the search container
@@ -625,7 +625,24 @@
<span class="warning-text">{{ t('settings.layoutSettings.displayDensityWarning') }}</span> <span class="warning-text">{{ t('settings.layoutSettings.displayDensityWarning') }}</span>
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="recipesLayout">
{{ t('settings.layoutSettings.recipesLayout') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -38,6 +38,7 @@ vi.mock('../../../static/js/state/index.js', () => {
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: captureScrollPositionMock, captureScrollPosition: captureScrollPositionMock,
restoreScrollPosition: restoreScrollPositionMock, restoreScrollPosition: restoreScrollPositionMock,
recreateVirtualScroll: vi.fn(),
})); }));
import { import {
@@ -1666,4 +1666,374 @@ describe('AutoComplete widget interactions', () => {
// Entire phrase should be replaced with selected tag // Entire phrase should be replaced with selected tag
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 () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(commandNames).not.toContain('/noaf');
expect(commandNames).toContain('/activefilters');
expect(commandNames).not.toContain('/noactivefilters');
});
it('does not trigger preview for command items when selecting the loras command list', async () => {
// Regression: with showPreview enabled (the default for loras widgets), the
// auto-selected first command item was passed to showPreviewForItem() as a
// relative path, crashing on relativePath.split.
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: true, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
// Allow the async preview tooltip import to resolve
await Promise.resolve();
await Promise.resolve();
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(previewTooltipMock.show).not.toHaveBeenCalled();
});
it('shows /noaf command for loras when active-filters autocomplete is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/noaf');
expect(commandNames).not.toContain('/af');
expect(commandNames).toContain('/noactivefilters');
expect(commandNames).not.toContain('/activefilters');
});
it('toggles the active-filters setting when /activefilters alias is used', async () => {
const input = document.createElement('textarea');
input.value = '/activefilters';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
const commandResult = autoComplete._parseCommandInput('/activefilters');
expect(commandResult.command).toBeDefined();
expect(commandResult.command.type).toBe('toggle_setting');
expect(commandResult.command.value).toBe(true);
await autoComplete._handleToggleSettingCommand(commandResult.command);
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('toggles the active-filters setting when /af is accepted', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const afItem = autoComplete.items.find((item) => item.command === '/af');
expect(afItem).toBeDefined();
// Simulate the input being cleared after the command is accepted so the
// cleared-token input event does not re-trigger command parsing.
caretHelperInstance.getBeforeCursor.mockReturnValue('');
await autoComplete._handleToggleSettingCommand(afItem);
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('appends active filter params to loras autocomplete requests when enabled', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
autoTags: { I2V: 'include' },
modelTypes: ['standard'],
tagLogic: 'all',
license: { noCredit: 'include', allowSelling: 'exclude' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
localStorage.setItem('lora_manager_loras_recursiveSearch', 'true');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
expect(calledUrl).toContain('folder=MyLoras');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('tag_include=anime');
expect(calledUrl).toContain('tag_exclude=nsfw');
expect(calledUrl).toContain('tag_exclude=__no_tags__');
expect(calledUrl).toContain('auto_tag_include=I2V');
expect(calledUrl).toContain('tag_logic=all');
expect(calledUrl).toContain('credit_required=false');
expect(calledUrl).toContain('allow_selling_generated_content=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
expect(parsed.searchParams.get('model_type')).toBe('standard');
});
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
});
it('sends the filter-pipeline signal even when no filters are stored', async () => {
// Regression: with filter mode on but no folder/filters stored, the request
// carried no params, so the backend skipped the filter pipeline and global
// settings like show_only_sfw diverged from the list endpoint.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.removeItem('lora_manager_loras_filters');
localStorage.removeItem('lora_manager_loras_activeFolder');
localStorage.removeItem('lora_manager_loras_recursiveSearch');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('recursive=true');
});
it('omits folder param when active folder is root and recursion is enabled', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', '');
localStorage.removeItem('lora_manager_loras_recursiveSearch');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('recursive=true');
});
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', '');
localStorage.setItem('lora_manager_loras_recursiveSearch', 'false');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=');
expect(calledUrl).toContain('recursive=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('folder')).toBe('');
});
it('applies the active folder even when no filter-panel filters are set', async () => {
// Regression: folder was skipped when lora_manager_loras_filters was
// missing because the filters key gate returned early.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.removeItem('lora_manager_loras_filters');
localStorage.setItem('lora_manager_loras_activeFolder', 'Flux.1 D/style');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['Flux.1 D/style/3D_Fairytales.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('3D');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = '3D';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
expect(calledUrl).toContain('recursive=true');
});
}); });
@@ -0,0 +1,87 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const recreateVirtualScrollMock = vi.fn();
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: class {},
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock,
}));
const { DuplicatesManager } = await import('../../../static/js/components/DuplicatesManager.js');
const { state, getCurrentPageState, setCurrentPageType } = await import('../../../static/js/state/index.js');
function setupDom() {
document.body.innerHTML = `
<div id="duplicatesBanner" style="display: block;"></div>
<div id="recipeGrid"><div class="model-card">stale</div></div>
`;
document.body.classList.add('duplicate-mode');
}
describe('DuplicatesManager exitDuplicateMode', () => {
beforeEach(() => {
vi.clearAllMocks();
setCurrentPageType('recipes');
setupDom();
state.pendingLayoutRecreate = false;
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
});
afterEach(() => {
state.pendingLayoutRecreate = false;
state.virtualScroller = null;
});
it('skips enable() on the old scroller when a layout recreate was deferred', async () => {
state.pendingLayoutRecreate = true;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(state.virtualScroller.enable).not.toHaveBeenCalled();
expect(recreateVirtualScrollMock).toHaveBeenCalledWith('recipes');
expect(state.pendingLayoutRecreate).toBe(false);
});
it('re-enables the existing scroller when no layout recreate is pending', async () => {
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(state.virtualScroller.enable).toHaveBeenCalledTimes(1);
expect(recreateVirtualScrollMock).not.toHaveBeenCalled();
});
it('tolerates a missing scroller on the plain re-enable path', async () => {
state.virtualScroller = null;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await expect(manager.exitDuplicateMode()).resolves.toBeUndefined();
expect(recreateVirtualScrollMock).not.toHaveBeenCalled();
});
it('clears duplicates-mode state and the grid regardless of path', async () => {
state.pendingLayoutRecreate = true;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(manager.inDuplicateMode).toBe(false);
expect(getCurrentPageState().duplicatesMode).toBe(false);
expect(document.body.classList.contains('duplicate-mode')).toBe(false);
expect(document.getElementById('recipeGrid').innerHTML).toBe('');
expect(document.getElementById('duplicatesBanner').style.display).toBe('none');
});
});
+1
View File
@@ -92,6 +92,7 @@ vi.mock('../../../static/js/utils/eventManagementInit.js', () => ({
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
initializeInfiniteScroll: vi.fn(), initializeInfiniteScroll: vi.fn(),
recreateVirtualScroll: vi.fn(),
})); }));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
@@ -28,6 +28,7 @@ const {
downloadModel: vi.fn(), downloadModel: vi.fn(),
downloadHfModel: vi.fn(), downloadHfModel: vi.fn(),
cancelDownload: vi.fn(), cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
}; };
// Shared loading manager served both via state.loadingManager and the // Shared loading manager served both via state.loadingManager and the
@@ -314,4 +315,120 @@ describe('DownloadManager batch download summary flow', () => {
); );
expect(resetAndReloadMock).toHaveBeenCalledWith(true); expect(resetAndReloadMock).toHaveBeenCalledWith(true);
}); });
it('shows the batch summary for a single CivitAI download resolved as a failure', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'rate limited' });
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(1);
expect(summary.completed).toBe(0);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].item.modelId).toBe('111');
expect(summary.failedItems[0].item.versionId).toBe('v1');
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
expect(summary.failedItems[0].error).toBe('rate limited');
expect(summary.failedItems[0].name).toBe('V1');
expect(summary.onRetry).toEqual(expect.any(Function));
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
});
it('shows the batch summary when a single download throws', async () => {
mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(1);
expect(summary.completed).toBe(0);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].error).toBe('network down');
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
});
it('keeps the success toast and skips the summary for a successful single download', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: true });
const result = await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(result).toBe(true);
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('retries a failed single download through onRetry with the same params', async () => {
mockApiClient.downloadModel
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
.mockResolvedValueOnce({ success: true });
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.failedItems).toHaveLength(1);
await summary.onRetry();
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
const retryCall = mockApiClient.downloadModel.mock.calls[1];
expect(retryCall[0]).toBe('111');
expect(retryCall[1]).toBe('v1');
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
});
it('shows a summary for HF partial failure and retries only the failed files', async () => {
manager.hfRepoId = 'user/repo';
manager.hfSelectedFiles = ['a.safetensors', 'b.safetensors'];
mockApiClient.downloadHfModel
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'denied' });
const result = await manager._downloadHfSingle({ modelRoot: '/m', useDefaultPaths: true });
expect(result).toBe(false);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(2);
expect(summary.completed).toBe(1);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].name).toBe('b.safetensors');
expect(summary.failedItems[0].item.url).toEqual(
expect.stringContaining('huggingface.co/user/repo/blob/main/b.safetensors')
);
await summary.onRetry();
expect(mockApiClient.downloadHfModel).toHaveBeenCalledTimes(3);
expect(mockApiClient.downloadHfModel.mock.calls[2][0].filename).toBe('b.safetensors');
});
}); });
@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
showToastMock,
showDownloadBatchSummaryMock,
resetAndReloadMock,
} = vi.hoisted(() => {
// Shared API client returned by the mocked getModelApiClient factory.
const mockApiClient = {
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
fetchCivitaiVersions: vi.fn(),
downloadModel: vi.fn(),
downloadHfModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
// Shared loading manager served both via state.loadingManager and the
// LoadingManager constructor mock.
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
setStatus: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
showCancelButton: vi.fn(),
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
showToastMock: vi.fn(),
showDownloadBatchSummaryMock: vi.fn(),
resetAndReloadMock: vi.fn(),
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
loadingManager: mockLoadingManager,
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: resetAndReloadMock,
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));
/** Minimal DOM used by validateAndFetchVersions / showVersionStep / batch preview. */
function setupDownloadDom() {
document.body.innerHTML = `
<div id="downloadModal">
<div class="download-step" id="urlStep"></div>
<div class="download-step" id="versionStep"></div>
<div class="download-step" id="fileSelectionStep"></div>
<div class="download-step" id="locationStep"></div>
<div id="batchPreviewStep"></div>
<textarea id="modelUrl"></textarea>
<div id="urlError"></div>
<div id="versionList"></div>
<div id="fileSelectionList"></div>
<div id="fileSelectionVersionName"></div>
<button id="nextFromVersion"></button>
<button id="nextFromBatchBtn"></button>
<div id="downloadModalTitle"></div>
<div id="batchPreviewList"></div>
<div id="modelRoot"></div>
<div id="folderPath"></div>
<div id="targetPathDisplay"></div>
<input id="useDefaultPath" />
<div id="manualPathSelection"></div>
</div>
`;
}
function makeVersion(id, name) {
return {
id,
name,
baseModel: 'SDXL',
createdAt: '2025-01-01T00:00:00Z',
availability: 'Public',
images: [{ url: 'https://image.civitai.com/preview.jpg' }],
files: [{ id: 1, type: 'Model', name: 'model.safetensors', sizeKB: 1000 }],
modelSizeKB: 1000,
existsLocally: false,
hasBeenDownloaded: false,
};
}
// Newest-first, matching the Civitai API response order. The default selection
// takes the first version, so the newest (largest id) must come first.
const versions = [makeVersion(250, 'v3'), makeVersion(100, 'v1'), makeVersion(30, 'v0')];
describe('DownloadManager latest-version default', () => {
let DownloadManager;
let manager;
beforeEach(async () => {
document.body.innerHTML = '';
setupDownloadDom();
mockApiClient.fetchCivitaiVersions.mockReset();
mockLoadingManager.showSimpleLoading.mockClear();
mockLoadingManager.hide.mockClear();
vi.resetModules();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
manager = new DownloadManager();
manager.apiClient = mockApiClient;
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('single URL without modelVersionId', () => {
it('auto-selects the latest version so no manual version click is needed', async () => {
document.getElementById('modelUrl').value = 'https://civitai.red/models/837884/midjourney-artful-nsfw';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.validateAndFetchVersions();
expect(manager.modelId).toBe('837884');
expect(manager.currentVersion.id).toBe(250);
// The version step is shown with the latest version pre-selected.
expect(document.getElementById('versionStep').style.display).toBe('block');
const selected = document.querySelector('.version-item.selected');
expect(selected.dataset.versionId).toBe('250');
expect(document.getElementById('nextFromVersion').disabled).toBe(false);
});
it('still honours an explicit modelVersionId from the URL', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=30';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.validateAndFetchVersions();
expect(manager.currentVersion.id).toBe(30);
});
});
describe('fetchVersionsForCurrentModel without modelVersionId', () => {
it('auto-selects the latest version', async () => {
manager.modelId = '837884';
manager.modelVersionId = null;
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.fetchVersionsForCurrentModel();
expect(manager.currentVersion.id).toBe(250);
});
it('still honours an explicit modelVersionId', async () => {
manager.modelId = '837884';
manager.modelVersionId = '100';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.fetchVersionsForCurrentModel();
expect(manager.currentVersion.id).toBe(100);
});
});
describe('multi-URL batch without modelVersionId', () => {
it('defaults each item to its latest version (first in API response)', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/111/foo\nhttps://civitai.red/models/222/bar';
const versionsA = [makeVersion(40, 'a2'), makeVersion(10, 'a1')];
const versionsB = [makeVersion(500, 'b1'), makeVersion(7, 'b2')];
mockApiClient.fetchCivitaiVersions.mockImplementation(async modelId =>
modelId === '111' ? versionsA : versionsB
);
await manager.validateAndFetchVersions();
expect(manager.isBatchMode).toBe(true);
expect(manager.batchModels).toHaveLength(2);
expect(manager.batchModels[0].selectedVersion.id).toBe(40);
expect(manager.batchModels[1].selectedVersion.id).toBe(500);
});
it('still honours an explicit modelVersionId per URL', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/111/foo?modelVersionId=10\nhttps://civitai.red/models/222/bar';
const versionsA = [makeVersion(40, 'a2'), makeVersion(10, 'a1')];
const versionsB = [makeVersion(500, 'b1'), makeVersion(7, 'b2')];
mockApiClient.fetchCivitaiVersions.mockImplementation(async modelId =>
modelId === '111' ? versionsA : versionsB
);
await manager.validateAndFetchVersions();
expect(manager.batchModels[0].selectedVersion.id).toBe(10);
expect(manager.batchModels[1].selectedVersion.id).toBe(500);
});
});
});
@@ -501,3 +501,33 @@ describe('SettingsManager library controls', () => {
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none'); expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
}); });
}); });
describe('SettingsManager recipes layout switch', () => {
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
const manager = createManager();
const select = document.createElement('select');
select.id = 'recipesLayout';
const option = document.createElement('option');
option.value = 'masonry';
select.appendChild(option);
select.value = 'masonry';
document.body.appendChild(select);
const calculateLayout = vi.fn();
state.virtualScroller = { calculateLayout };
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
await manager.saveSelectSetting('recipesLayout', 'recipes_layout');
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;
});
});
+1
View File
@@ -69,6 +69,7 @@ vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
refreshVirtualScroll: refreshVirtualScrollMock, refreshVirtualScroll: refreshVirtualScrollMock,
recreateVirtualScroll: vi.fn(),
})); }));
vi.mock('../../../static/js/api/recipeApi.js', () => ({ vi.mock('../../../static/js/api/recipeApi.js', () => ({
+2 -1
View File
@@ -17,7 +17,8 @@ describe('state module', () => {
civitai_host: 'civitai.com', civitai_host: 'civitai.com',
language: 'en', language: 'en',
blur_mature_content: true, blur_mature_content: true,
mature_blur_level: 'R' mature_blur_level: 'R',
recipes_layout: 'grid'
}); });
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES); expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
+198
View File
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
VirtualScrollerMock,
MasonryScrollerMock,
RecipeCardMock,
fetchRecipesPageMock,
getModelApiClientMock,
} = vi.hoisted(() => {
const makeScrollerClass = () => vi.fn(function (options) {
this.options = options;
this.initialize = vi.fn(async () => {});
this.dispose = vi.fn();
this.reset = vi.fn();
this.handlePageUpDown = vi.fn();
});
return {
VirtualScrollerMock: makeScrollerClass(),
MasonryScrollerMock: makeScrollerClass(),
RecipeCardMock: vi.fn(function () {
this.element = document.createElement('div');
}),
fetchRecipesPageMock: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
getModelApiClientMock: vi.fn(() => ({
fetchModelsPage: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
})),
};
});
vi.mock('../../../static/js/utils/VirtualScroller.js', () => ({
VirtualScroller: VirtualScrollerMock,
}));
vi.mock('../../../static/js/utils/MasonryScroller.js', () => ({
MasonryScroller: MasonryScrollerMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: RecipeCardMock,
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipesPage: fetchRecipesPageMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: getModelApiClientMock,
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
createModelCard: vi.fn(() => document.createElement('div')),
setupModelCardEventDelegation: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
import {
initializeInfiniteScroll,
recreateVirtualScroll,
} from '../../../static/js/utils/infiniteScroll.js';
import { state } from '../../../static/js/state/index.js';
function setupPageDom() {
const pageContent = document.createElement('div');
pageContent.className = 'page-content';
const container = document.createElement('div');
container.className = 'container';
pageContent.appendChild(container);
document.body.appendChild(pageContent);
const grid = document.createElement('div');
vi.spyOn(document, 'getElementById').mockImplementation((id) =>
id === 'recipeGrid' || id === 'modelGrid' ? grid : null);
return { grid };
}
describe('infiniteScroll scroller class branching', () => {
let originalSettings;
beforeEach(() => {
originalSettings = state.global.settings;
state.global.settings = { ...originalSettings };
if (state.pages.recipes) {
state.pages.recipes.duplicatesMode = false;
}
state.virtualScroller = null;
state.keyboardNavHandler = null;
setupPageDom();
});
afterEach(() => {
state.global.settings = originalSettings;
state.virtualScroller = null;
state.keyboardNavHandler = null;
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('constructs MasonryScroller for the recipes page when recipes_layout is masonry', async () => {
state.global.settings.recipes_layout = 'masonry';
await initializeInfiniteScroll('recipes');
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(VirtualScrollerMock).not.toHaveBeenCalled();
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
});
it('constructs VirtualScroller for the recipes page when recipes_layout is grid', async () => {
state.global.settings.recipes_layout = 'grid';
await initializeInfiniteScroll('recipes');
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
expect(state.virtualScroller).toBeInstanceOf(VirtualScrollerMock);
});
it('falls back to the grid branch when recipes_layout is missing', async () => {
delete state.global.settings.recipes_layout;
await expect(initializeInfiniteScroll('recipes')).resolves.toBeUndefined();
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
});
it('always constructs VirtualScroller for the loras page regardless of recipes_layout', async () => {
state.global.settings.recipes_layout = 'masonry';
await initializeInfiniteScroll('loras');
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
});
});
describe('recreateVirtualScroll', () => {
let originalSettings;
beforeEach(() => {
originalSettings = state.global.settings;
state.global.settings = { ...originalSettings };
if (state.pages.recipes) {
state.pages.recipes.duplicatesMode = false;
}
state.virtualScroller = null;
state.keyboardNavHandler = null;
setupPageDom();
});
afterEach(() => {
state.global.settings = originalSettings;
state.virtualScroller = null;
state.keyboardNavHandler = null;
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('cleans up keyboard navigation, disposes the old scroller, and rebuilds with the new layout', async () => {
state.global.settings.recipes_layout = 'grid';
await initializeInfiniteScroll('recipes');
const oldScroller = state.virtualScroller;
const oldKeyboardHandler = state.keyboardNavHandler;
expect(oldKeyboardHandler).toBeTypeOf('function');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
state.global.settings.recipes_layout = 'masonry';
await recreateVirtualScroll('recipes');
// cleanupKeyboardNavigation removed the previous document keydown listener
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', oldKeyboardHandler);
// Old scroller disposed and replaced by a new masonry instance
expect(oldScroller.dispose).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
expect(state.virtualScroller).not.toBe(oldScroller);
// A fresh keyboard navigation listener was registered for the new instance
expect(state.keyboardNavHandler).toBeTypeOf('function');
expect(state.keyboardNavHandler).not.toBe(oldKeyboardHandler);
});
it('works when there is no existing virtual scroller', async () => {
state.global.settings.recipes_layout = 'masonry';
await expect(recreateVirtualScroll('recipes')).resolves.toBeUndefined();
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
});
});
@@ -0,0 +1,627 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MasonryScroller } from '../../../static/js/utils/MasonryScroller.js';
import { VirtualScroller } from '../../../static/js/utils/VirtualScroller.js';
import { getCurrentPageState, setCurrentPageType } from '../../../static/js/state/index.js';
// jsdom does not always provide requestAnimationFrame; polyfill when missing
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame !== 'function') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
const CONTAINER_WIDTH = 768; // yields 3 columns at default density: floor((768+12)/(240+12)) = 3
const COLUMN_GAP = 12;
const ROW_GAP = 20;
const PAD_TOP = 4;
const PAD_BOTTOM = 4;
const ITEM_WIDTH = (CONTAINER_WIDTH - 2 * COLUMN_GAP) / 3; // 248
const FALLBACK_HEIGHT = ITEM_WIDTH / (896 / 1152);
function createItemFn() {
const el = document.createElement('div');
const card = document.createElement('div');
card.className = 'model-card';
const preview = document.createElement('div');
preview.className = 'card-preview';
card.appendChild(preview);
el.appendChild(card);
return el;
}
function makeItems(dimensions) {
return dimensions.map((dims, i) => ({
file_path: `/recipes/item-${i}.png`,
...dims,
}));
}
/**
* Build a scroller attached to a stubbed container. clientWidth/clientHeight
* are 0 in jsdom, so they are defined explicitly for deterministic layout.
*/
function createScroller({ items = [], fetchItemsFn, overscan, viewportHeight = 600, createItemFn: customCreateItemFn } = {}) {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'clientWidth', { value: CONTAINER_WIDTH, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: viewportHeight, configurable: true });
const grid = document.createElement('div');
wrapper.appendChild(grid);
document.body.appendChild(wrapper);
const fetchMock = fetchItemsFn || vi.fn(async () => ({ items, totalItems: items.length, hasMore: false }));
const scroller = new MasonryScroller({
gridElement: grid,
containerElement: wrapper,
scrollContainer: wrapper,
createItemFn: customCreateItemFn || createItemFn,
fetchItemsFn: fetchMock,
overscan,
});
return { scroller, wrapper, grid, fetchMock };
}
describe('MasonryScroller', () => {
const liveScrollers = [];
beforeEach(() => {
setCurrentPageType('recipes');
getCurrentPageState().duplicatesMode = false;
});
afterEach(() => {
while (liveScrollers.length > 0) {
liveScrollers.pop().dispose();
}
getCurrentPageState().duplicatesMode = false;
});
function track(setup) {
liveScrollers.push(setup.scroller);
return setup;
}
it('adds virtual-scroll and masonry-layout classes and creates the spacer', () => {
const { scroller, grid } = track(createScroller());
expect(grid.classList.contains('virtual-scroll')).toBe(true);
expect(grid.classList.contains('masonry-layout')).toBe(true);
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
expect(grid.style.position).toBe('relative');
expect(grid.contains(scroller.spacerElement)).toBe(true);
});
it('computes density-based column count and item width', () => {
const { scroller } = track(createScroller());
expect(scroller.columnsCount).toBe(3);
expect(scroller.itemWidth).toBeCloseTo(ITEM_WIDTH);
});
it('places each item into the shortest column', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const cols = scroller.positions.map((p) => p.col);
expect(cols).toEqual([0, 1, 2, 2, 1, 2]);
// Tops follow the accumulated shortest-column heights
expect(scroller.positions[0].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[1].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[2].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 124 + ROW_GAP); // 148
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
expect(scroller.positions[5].top).toBeCloseTo(148 + 248 + ROW_GAP); // 416
// Left offsets are column index * (itemWidth + columnGap)
expect(scroller.positions[1].left).toBeCloseTo(ITEM_WIDTH + COLUMN_GAP);
expect(scroller.positions[2].left).toBeCloseTo(2 * (ITEM_WIDTH + COLUMN_GAP));
});
it('sets spacer height to the tallest column minus trailing gap plus padding', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
// Column heights after placement: [520, 540, 684] (each starts at padTop)
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
});
it('falls back to the 896/1152 ratio for items without dimensions', () => {
const items = makeItems([{}, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
expect(scroller.positions[0].height).toBeCloseTo(FALLBACK_HEIGHT);
expect(scroller.positions[1].height).toBeCloseTo(ITEM_WIDTH);
});
it('visible range respects the overscan band', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller, wrapper } = track(createScroller({ items, viewportHeight: 300 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
// Without overscan, item 5 (top=416) is below the 300px viewport
scroller.overscan = 0;
let visible = scroller.getVisibleRange();
expect([...visible].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]);
// overscan=1 extends the band by one itemWidth (248px), pulling item 5 in
scroller.overscan = 1;
visible = scroller.getVisibleRange();
expect(visible.has(5)).toBe(true);
});
it('renders visible items with inline masonry styles and clears model-card max-width', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const rendered = grid.querySelectorAll('.virtual-scroll-item');
expect(rendered.length).toBe(3);
rendered.forEach((el, i) => {
expect(el.style.position).toBe('absolute');
expect(el.style.width).toBe(`${scroller.positions[i].width}px`);
expect(el.style.height).toBe(`${scroller.positions[i].height}px`);
expect(el.style.left).toBe(`${scroller.positions[i].left}px`);
expect(el.style.top).toBe(`${scroller.positions[i].top}px`);
expect(el.querySelector('.model-card').style.maxWidth).toBe('none');
expect(el.querySelector('.model-card').style.minWidth).toBe('0');
});
});
it('clears model-card max-width/min-width when the item element is the card root', () => {
// Production shape for recipe cards: RecipeCard returns the .model-card
// element itself, so the scroller must clear constraints on the element
// rather than a descendant (querySelector would find nothing).
const items = makeItems([{ width: 100, height: 200 }]);
const cardRoot = document.createElement('div');
cardRoot.className = 'model-card';
const { scroller, grid, wrapper } = track(createScroller({
items,
viewportHeight: 3000,
createItemFn: () => cardRoot.cloneNode(true),
}));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const rendered = grid.querySelectorAll('.virtual-scroll-item');
expect(rendered.length).toBe(1);
expect(rendered[0].style.maxWidth).toBe('none');
expect(rendered[0].style.minWidth).toBe('0');
});
it('triggers loadMoreItems when scrolled to the bottom', async () => {
const firstPage = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const fetchMock = vi.fn(async () => ({ items: makeItems([{ width: 100, height: 100 }]), totalItems: 7, hasMore: false }));
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
scroller.refreshWithData(firstPage, 100, true);
const pageState = getCurrentPageState();
const expectedPage = pageState.currentPage;
// contentBottom = 664; scrollBottom = 100 + 600 = 700 >= 664 - threshold
wrapper.scrollTop = 100;
scroller.handleScroll();
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
});
});
it('does not trigger loadMoreItems when far from the bottom', async () => {
const fetchMock = vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false }));
const manyItems = makeItems(Array.from({ length: 30 }, () => ({ width: 100, height: 200 })));
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
scroller.refreshWithData(manyItems, 1000, true);
wrapper.scrollTop = 0;
scroller.handleScroll();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns false from calculateLayout in duplicates mode', () => {
const { scroller } = track(createScroller());
getCurrentPageState().duplicatesMode = true;
expect(scroller.calculateLayout()).toBe(false);
});
it('computes placement and spacer synchronously during initialize (before rAF)', async () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
await scroller.initialize();
// Assert immediately after initialize resolves, without waiting for rAF
expect(scroller.positions.length).toBe(items.length);
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
expect(getCurrentPageState().currentPage).toBe(2);
});
it('shows the error placeholder and resets isLoading when the initial fetch fails', async () => {
const fetchMock = vi.fn(async () => {
throw new Error('network down');
});
const { scroller, grid } = track(createScroller({ fetchItemsFn: fetchMock }));
await expect(scroller.initialize()).resolves.toBeUndefined();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('Failed to load items');
expect(scroller.isLoading).toBe(false);
});
it('shows the recipes empty placeholder when no items are returned', async () => {
const { scroller, grid } = track(createScroller({ items: [] }));
await scroller.initialize();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('No recipes found');
});
it('dispose removes classes, spacer and event listeners', () => {
const { scroller, grid } = track(createScroller());
scroller.dispose();
expect(grid.classList.contains('virtual-scroll')).toBe(false);
expect(grid.classList.contains('masonry-layout')).toBe(false);
expect(grid.querySelector('.virtual-scroll-spacer')).toBeNull();
});
it('exposes every VirtualScroller prototype method (API parity)', () => {
const virtualMethods = Object.getOwnPropertyNames(VirtualScroller.prototype);
const masonryMethods = new Set(Object.getOwnPropertyNames(MasonryScroller.prototype));
const missing = virtualMethods.filter((name) => !masonryMethods.has(name));
expect(missing).toEqual([]);
});
it('exposes the VirtualScroller property surface after construction', () => {
const { scroller } = track(createScroller());
const expectedProperties = [
'items',
'renderedItems',
'totalItems',
'hasMore',
'isLoading',
'gridElement',
'containerElement',
'scrollContainer',
'columnsCount',
'itemWidth',
'disabled',
'spacerElement',
'pageSize',
];
for (const prop of expectedProperties) {
expect(scroller[prop]).not.toBeUndefined();
}
});
it('updateSingleItem re-places items and shows the updated indicator on rendered cards', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
// Item 2 (height 124) sits in column 2 with items 3 and 5 stacked below it
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const spacerBefore = scroller.spacerElement.style.height;
const colsBefore = scroller.positions.map((p) => p.col);
const result = scroller.updateSingleItem('/recipes/item-2.png', { width: 100, height: 150 });
expect(result).toBe(true);
// Item 2 height grew 124 -> 372, so the full synchronous re-placement
// re-flows every later item (item 3 moves from column 2 to column 1)
expect(scroller.positions[2].height).toBeCloseTo(ITEM_WIDTH * 1.5);
expect(scroller.positions.map((p) => p.col)).not.toEqual(colsBefore);
expect(scroller.spacerElement.style.height).not.toBe(spacerBefore);
// The re-placement equals a fresh full layout of the same items
const { scroller: reference } = track(createScroller({ items }));
reference.refreshWithData(scroller.items.slice(), items.length, false);
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
for (let i = 0; i < items.length; i++) {
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
}
// The rendered card was recreated in place with the update indicator
const updatedCard = grid.querySelector('.virtual-scroll-item.updated');
expect(updatedCard).not.toBeNull();
const indicator = updatedCard.querySelector('.update-indicator');
expect(indicator).not.toBeNull();
expect(indicator.textContent).toBe('Updated');
expect(updatedCard.querySelector('.card-preview').contains(indicator)).toBe(true);
expect(updatedCard.style.height).toBe(`${scroller.positions[2].height}px`);
expect(updatedCard.style.top).toBe(`${scroller.positions[2].top}px`);
});
it('updateSingleItem returns false for an unknown file path without throwing', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
let result;
expect(() => {
result = scroller.updateSingleItem('/recipes/does-not-exist.png', { title: 'x' });
}).not.toThrow();
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('removeItemByFilePath re-places the remaining items and decrements the total', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 60, false);
const result = scroller.removeItemByFilePath('/recipes/item-2.png');
expect(result).toBe(true);
expect(scroller.items.length).toBe(5);
expect(scroller.totalItems).toBe(59);
expect(scroller.positions.length).toBe(5);
// Remaining heights: 496, 248, 248, 248, 248 -> shortest-column placement
expect(scroller.positions.map((p) => p.col)).toEqual([0, 1, 2, 1, 2]);
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
// Spacer reflects the tallest remaining column
const maxColumnHeight = Math.max(...scroller.columnHeights);
const expected = maxColumnHeight - ROW_GAP + PAD_TOP + PAD_BOTTOM;
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
});
it('removeItemByFilePath returns false for an unknown file path', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(scroller.removeItemByFilePath('/recipes/missing.png')).toBe(false);
warnSpy.mockRestore();
});
it('removeMultipleItemsByFilePath re-places items with no layout gaps', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 60, false);
const result = scroller.removeMultipleItemsByFilePath([
'/recipes/item-1.png',
'/recipes/item-3.png',
]);
expect(result).toBe(true);
expect(scroller.items.map((i) => i.file_path)).toEqual([
'/recipes/item-0.png',
'/recipes/item-2.png',
'/recipes/item-4.png',
'/recipes/item-5.png',
]);
expect(scroller.totalItems).toBe(58);
// The remaining items are laid out exactly as a fresh full placement:
// compare against a second scroller fed the same remaining items
const remaining = scroller.items.slice();
const { scroller: reference } = track(createScroller({ items: remaining }));
reference.refreshWithData(remaining, remaining.length, false);
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
for (let i = 0; i < remaining.length; i++) {
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
expect(scroller.positions[i].left).toBeCloseTo(reference.positions[i].left);
}
});
it('removeMultipleItemsByFilePath returns false when nothing matches', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
expect(scroller.removeMultipleItemsByFilePath(['/recipes/missing.png'])).toBe(false);
expect(scroller.removeMultipleItemsByFilePath([])).toBe(false);
});
it('disable stops rendering and enable recreates the spacer after innerHTML is cleared', async () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
scroller.disable();
expect(scroller.disabled).toBe(true);
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
expect(scroller.spacerElement.style.display).toBe('none');
// Duplicates mode wipes the grid contents, destroying the spacer
grid.innerHTML = '';
expect(grid.contains(scroller.spacerElement)).toBe(false);
scroller.enable();
expect(scroller.disabled).toBe(false);
expect(grid.contains(scroller.spacerElement)).toBe(true);
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
// Full re-placement ran synchronously on re-enable
expect(scroller.positions.length).toBe(items.length);
// Rendering resumes after the scheduled rAF
await new Promise((resolve) => setTimeout(resolve, 20));
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
});
it('getAdjacentItemByFilePath loads more pages when the target is beyond loaded items', async () => {
const page1 = [0, 1, 2].map((i) => ({
file_path: `/recipes/page1-${i}.png`,
width: 100,
height: 100,
}));
const page2 = [0, 1].map((i) => ({
file_path: `/recipes/page2-${i}.png`,
width: 100,
height: 100,
}));
const fetchMock = vi.fn(async () => ({ items: page2, totalItems: 5, hasMore: false }));
const { scroller } = track(createScroller({ fetchItemsFn: fetchMock }));
scroller.refreshWithData(page1, 5, true);
const pageState = getCurrentPageState();
const expectedPage = pageState.currentPage;
const result = await scroller.getAdjacentItemByFilePath('/recipes/page1-2.png', 'next');
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
expect(result).not.toBeNull();
expect(result.index).toBe(3);
expect(result.item.file_path).toBe('/recipes/page2-0.png');
});
it('getAdjacentItemByFilePath returns null at boundaries and for unknown paths', async () => {
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-0.png', 'prev')).resolves.toBeNull();
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-1.png', 'next')).resolves.toBeNull();
await expect(scroller.getAdjacentItemByFilePath('/recipes/missing.png', 'next')).resolves.toBeNull();
});
it('getNavigationState reports index, prev/next availability and totals', () => {
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 10, true);
expect(scroller.getNavigationState('/recipes/item-0.png')).toEqual({
index: 0,
hasPrev: false,
hasNext: true,
loadedItems: 2,
totalItems: 10,
});
const last = scroller.getNavigationState('/recipes/item-1.png');
expect(last.index).toBe(1);
expect(last.hasPrev).toBe(true);
// hasMore keeps forward navigation available past the loaded window
expect(last.hasNext).toBe(true);
expect(scroller.getNavigationState('/recipes/missing.png').index).toBe(-1);
expect(scroller.findIndexByFilePath('/recipes/item-1.png')).toBe(1);
expect(scroller.findIndexByFilePath('')).toBe(-1);
});
});
+159 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional
from aiohttp import FormData, web from aiohttp import FormData, web
from aiohttp.test_utils import TestClient, TestServer from aiohttp.test_utils import TestClient, TestServer
from PIL import Image
from py.config import config from py.config import config
from py.routes import base_recipe_routes from py.routes import base_recipe_routes
@@ -368,6 +370,163 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
assert payload["items"][0]["loras"] == [] assert payload["items"][0]["loras"] == []
async def test_list_recipes_exposes_preview_dimensions(
monkeypatch, tmp_path: Path
) -> None:
"""(a) Image recipe items carry integer width/height from the on-disk file."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_path = harness.tmp_dir / "recipes" / "real.png"
recipe_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (64, 32), color="red").save(recipe_path)
harness.scanner.listing_items = [
{
"id": "recipe-1",
"file_path": str(recipe_path),
"title": "Image Recipe",
"loras": [],
}
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
item = payload["items"][0]
assert item["width"] == 64
assert item["height"] == 32
assert isinstance(item["width"], int)
assert isinstance(item["height"], int)
async def test_list_recipes_omits_dimensions_for_video_and_missing(
monkeypatch, tmp_path: Path
) -> None:
"""(b) Video/missing-image recipes omit width/height yet still return 200."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.listing_items = [
{
"id": "recipe-video",
"file_path": str(harness.tmp_dir / "recipes" / "preview.mp4"),
"title": "Video Recipe",
"loras": [],
},
{
"id": "recipe-missing",
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
"title": "Missing Recipe",
"loras": [],
},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
for item in payload["items"]:
assert "width" not in item
assert "height" not in item
async def test_list_recipes_offloads_dimensions_to_thread(
monkeypatch, tmp_path: Path
) -> None:
"""(c) Dimension reads run through asyncio.to_thread for every item."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_path = harness.tmp_dir / "recipes" / "real.png"
recipe_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (16, 48), color="blue").save(recipe_path)
harness.scanner.listing_items = [
{
"id": "recipe-1",
"file_path": str(recipe_path),
"title": "Image Recipe",
"loras": [],
},
{
"id": "recipe-2",
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
"title": "Missing Recipe",
"loras": [],
},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
real_to_thread = asyncio.to_thread
to_thread_calls: list[tuple] = []
async def counting_to_thread(fn, *args, **kwargs):
to_thread_calls.append((fn, args, kwargs))
return await real_to_thread(fn, *args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", counting_to_thread)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
assert len(to_thread_calls) >= len(harness.scanner.listing_items)
assert payload["items"][0]["width"] == 16
assert payload["items"][0]["height"] == 48
assert "width" not in payload["items"][1]
assert "height" not in payload["items"][1]
async def test_list_recipes_batches_dimensions_for_mixed_items(
monkeypatch, tmp_path: Path
) -> None:
"""(d) Mixed file_path presence: dims align per-item with their own files."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
wide_path = harness.tmp_dir / "recipes" / "wide.png"
tall_path = harness.tmp_dir / "recipes" / "tall.png"
wide_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (120, 40), color="green").save(wide_path)
Image.new("RGB", (30, 90), color="blue").save(tall_path)
harness.scanner.listing_items = [
{
"id": "recipe-wide",
"file_path": str(wide_path),
"title": "Wide",
"loras": [],
},
{"id": "recipe-none", "title": "No Preview", "loras": []},
{
"id": "recipe-tall",
"file_path": str(tall_path),
"title": "Tall",
"loras": [],
},
{"id": "recipe-none-2", "title": "No Preview 2", "loras": []},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
items = payload["items"]
# Items with a file_path carry integer dims read from their own file;
# the two images differ in both dimensions so a shifted pair would
# fail these assertions.
assert items[0]["width"] == 120
assert items[0]["height"] == 40
assert isinstance(items[0]["width"], int)
assert isinstance(items[0]["height"], int)
assert items[2]["width"] == 30
assert items[2]["height"] == 90
# Items without a file_path get the no-preview fallback and omit dims.
for item in (items[1], items[3]):
assert "width" not in item
assert "height" not in item
assert item["file_url"] == "/loras_static/images/no-preview.png"
async def test_list_recipes_passes_checkpoint_hash_filter( async def test_list_recipes_passes_checkpoint_hash_filter(
monkeypatch, tmp_path: Path monkeypatch, tmp_path: Path
) -> None: ) -> None:
@@ -909,8 +1068,6 @@ async def test_batch_import_start_missing_source(monkeypatch, tmp_path: Path) ->
async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None: async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None:
import asyncio
async with recipe_harness(monkeypatch, tmp_path) as harness: async with recipe_harness(monkeypatch, tmp_path) as harness:
original_analyze = harness.analysis.analyze_remote_image original_analyze = harness.analysis.analyze_remote_image
+278
View File
@@ -27,6 +27,13 @@ class FakeScanner:
return list(self._roots) return list(self._roots)
class StubSettings:
"""Settings stub that returns defaults, avoiding the real settings singleton."""
def get(self, key, default=None):
return default
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_search_relative_paths_supports_multiple_tokens(): async def test_search_relative_paths_supports_multiple_tokens():
scanner = FakeScanner( scanner = FakeScanner(
@@ -101,3 +108,274 @@ async def test_search_safe_does_not_match_all_files():
matching = await service.search_relative_paths("safe") matching = await service.search_relative_paths("safe")
assert len(matching) == 0 assert len(matching) == 0
class SfwStubSettings(StubSettings):
"""Settings stub with the global SFW filter enabled."""
def get(self, key, default=None):
if key == "show_only_sfw":
return True
return default
@pytest.mark.asyncio
async def test_search_relative_paths_respects_global_sfw_setting():
"""Filtered search applies show_only_sfw like the list endpoint (parity)."""
scanner = FakeScanner(
[
{"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0},
{"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings()
)
matching = await service.search_relative_paths("model", apply_filters=True)
assert matching == ["sfw-model.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_sfw_only_applied_when_filter_mode_is_on():
"""Global settings (show_only_sfw) apply only when the filter pipeline runs."""
scanner = FakeScanner(
[
{"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0},
{"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings()
)
default_matching = await service.search_relative_paths("model")
assert default_matching == [
"sfw-model.safetensors",
"nsfw-model.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_paths_folder_filter_recursive():
"""folder filter with recursive=True (default) matches subfolders."""
scanner = FakeScanner(
[
{"file_path": "/models/anime/model-a.safetensors", "folder": "anime"},
{
"file_path": "/models/anime/nsfw/model-b.safetensors",
"folder": "anime/nsfw",
},
{"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", folder="anime")
assert matching == [
f"anime{os.sep}model-a.safetensors",
f"anime{os.sep}nsfw{os.sep}model-b.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_paths_folder_filter_exact():
"""folder filter with recursive=False matches only the exact folder."""
scanner = FakeScanner(
[
{"file_path": "/models/anime/model-a.safetensors", "folder": "anime"},
{
"file_path": "/models/anime/nsfw/model-b.safetensors",
"folder": "anime/nsfw",
},
{"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", folder="anime", recursive=False
)
assert matching == [f"anime{os.sep}model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_base_model_filter():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "base_model": "SD 1.5"},
{"file_path": "/models/model-b.safetensors", "base_model": "SDXL"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", base_models=["SD 1.5"])
assert matching == ["model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_tag_include():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "tags": ["anime"]},
{"file_path": "/models/model-b.safetensors", "tags": ["realistic"]},
{"file_path": "/models/model-c.safetensors", "tags": ["anime", "realistic"]},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", tags={"anime": "include"})
assert set(matching) == {"model-a.safetensors", "model-c.safetensors"}
@pytest.mark.asyncio
async def test_search_relative_paths_tag_exclude():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "tags": ["anime"]},
{"file_path": "/models/model-b.safetensors", "tags": ["realistic"]},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", tags={"anime": "exclude"})
assert matching == ["model-b.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_auto_tag_include():
scanner = FakeScanner(
[
{
"file_path": "/models/model-i2v.safetensors",
"file_name": "model-i2v.safetensors",
},
{
"file_path": "/models/model-t2v.safetensors",
"file_name": "model-t2v.safetensors",
},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", auto_tags={"I2V": "include"}
)
assert matching == ["model-i2v.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_tag_logic_all():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "tags": ["anime", "style"]},
{"file_path": "/models/model-b.safetensors", "tags": ["anime"]},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", tags={"anime": "include", "style": "include"}, tag_logic="all"
)
assert matching == ["model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_credit_required_filter():
# license_flags bit0: 1 = no credit required, 0 = credit required
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "license_flags": 127},
{"file_path": "/models/model-b.safetensors", "license_flags": 0},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", credit_required=True)
assert matching == ["model-b.safetensors"]
matching = await service.search_relative_paths("model", credit_required=False)
assert matching == ["model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_allow_selling_filter():
# license_flags bit1: 1 = commercial image use allowed, 0 = not allowed
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "license_flags": 2},
{"file_path": "/models/model-b.safetensors", "license_flags": 1},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", allow_selling_generated_content=True
)
assert matching == ["model-a.safetensors"]
matching = await service.search_relative_paths(
"model", allow_selling_generated_content=False
)
assert matching == ["model-b.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_no_filters_regression():
"""No filter kwargs -> behavior is byte-identical to plain token matching."""
scanner = FakeScanner(
[
{"file_path": "/models/flux/detail-model.safetensors"},
{"file_path": "/models/flux/only-flux.safetensors"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("flux")
assert matching == [
f"flux{os.sep}only-flux.safetensors",
f"flux{os.sep}detail-model.safetensors",
]
+55
View File
@@ -288,3 +288,58 @@ class TestIsobmffBrotliExtraction:
# Direct extraction should return None because decompressed size exceeds limit # Direct extraction should return None because decompressed size exceeds limit
result = ExifUtils._extract_isobmff_brotli(str(path)) result = ExifUtils._extract_isobmff_brotli(str(path))
assert result is None assert result is None
# --- get_image_dimensions tests ---
def test_get_image_dimensions_returns_actual_size(tmp_path):
"""(a) A valid image returns its real (width, height)."""
image_path = tmp_path / "preview.png"
Image.new("RGB", (64, 32), color="red").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (64, 32)
def test_get_image_dimensions_missing_path_returns_none(tmp_path):
"""(b) A nonexistent path returns None without raising."""
assert ExifUtils.get_image_dimensions(str(tmp_path / "missing.png")) is None
def test_get_image_dimensions_skips_video_extension_without_pil(tmp_path, monkeypatch):
"""(c) A .mp4 path returns None and never invokes PIL."""
video_path = tmp_path / "preview.mp4"
video_path.write_bytes(b"not really a video")
def fail_if_called(*args, **kwargs):
raise AssertionError("PIL Image.open must not be called for video paths")
monkeypatch.setattr("py.utils.exif_utils.Image.open", fail_if_called)
assert ExifUtils.get_image_dimensions(str(video_path)) is None
def test_get_image_dimensions_corrupt_file_returns_none(tmp_path):
"""(d) A corrupt file returns None without raising."""
image_path = tmp_path / "corrupt.png"
image_path.write_bytes(b"\x00\x01\x02\x03 not a real image")
assert ExifUtils.get_image_dimensions(str(image_path)) is None
def test_get_image_dimensions_cache_key_includes_mtime(tmp_path):
"""(e) Replacing a path with a different-size image returns the new size."""
image_path = tmp_path / "replaced.png"
Image.new("RGB", (64, 32), color="red").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (64, 32)
Image.new("RGB", (100, 50), color="blue").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (100, 50)
def test_get_image_dimensions_skips_unreadable_formats(tmp_path):
"""(f) .avif/.jxl paths return None without raising."""
for ext in (".avif", ".jxl"):
image_path = tmp_path / f"preview{ext}"
image_path.write_bytes(b"fake container data")
assert ExifUtils.get_image_dimensions(str(image_path)) is None
+202 -16
View File
@@ -14,6 +14,7 @@ import {
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
getAutocompleteAutoFormatPreference, getAutocompleteAutoFormatPreference,
getAutocompleteAcceptKeyPreference, getAutocompleteAcceptKeyPreference,
getLoraActiveFiltersAutocompletePreference,
getPromptTagAutocompletePreference, getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference, getTagSpaceReplacementPreference,
} from "./settings.js"; } from "./settings.js";
@@ -48,6 +49,47 @@ const TAG_COMMANDS = {
}, },
}; };
// Command definitions for LoRA active-filters search
// Aliases (/activefilters, /noactivefilters) mirror /emb ↔ /embedding
const LORAS_COMMANDS = {
'/af': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
label: 'Active Filters: ON',
feedbackSummary: 'Active Filters Search: ON',
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
condition: () => !getLoraActiveFiltersAutocompletePreference()
},
'/noaf': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: false,
label: 'Active Filters: OFF',
feedbackSummary: 'Active Filters Search: OFF',
feedbackDetail: 'LoRA autocomplete searches the full library again.',
condition: () => getLoraActiveFiltersAutocompletePreference()
},
'/activefilters': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
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()
},
'/noactivefilters': {
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()
},
};
// Category display information // Category display information
const CATEGORY_INFO = { const CATEGORY_INFO = {
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' }, 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
@@ -719,6 +761,36 @@ class AutoComplete {
searchTerm = (match[1] || '').trim(); searchTerm = (match[1] || '').trim();
} }
// For loras model type, check if we're in command mode (/af, /noaf)
if (this.modelType === 'loras') {
const commandResult = this._parseCommandInput(rawSearchTerm);
if (commandResult.showCommands) {
// Show command list dropdown
this.showingCommands = true;
this.activeCommand = null;
this.searchType = 'commands';
this._showCommandList(commandResult.commandFilter);
return;
} else if (commandResult.command?.type === 'toggle_setting') {
// Handle toggle setting command (/af, /noaf)
this._handleToggleSettingCommand(commandResult.command);
return;
} else if (commandResult.command) {
// Command is active, use filtered search
this.showingCommands = false;
this.activeCommand = null;
this.searchType = null;
searchTerm = commandResult.searchTerm || rawSearchTerm;
} else {
// No command - regular lora search
this.showingCommands = false;
this.activeCommand = null;
this.searchType = null;
searchTerm = rawSearchTerm;
}
}
// For prompt model type, check if we're searching embeddings, commands, or tags // For prompt model type, check if we're searching embeddings, commands, or tags
if (this.modelType === 'prompt') { if (this.modelType === 'prompt') {
const match = rawSearchTerm.match(/^emb:(.*)$/i); const match = rawSearchTerm.match(/^emb:(.*)$/i);
@@ -1095,7 +1167,11 @@ class AutoComplete {
} }
_isSelectableInfoItem(item) { _isSelectableInfoItem(item) {
return isWildcardInfoItem(item); if (isWildcardInfoItem(item)) {
return true;
}
// Command items are not model paths — never show preview for them
return item && typeof item === 'object' && 'command' in item;
} }
/** /**
@@ -1159,6 +1235,11 @@ class AutoComplete {
return (match?.[1] || '').trim(); return (match?.[1] || '').trim();
} }
if (this.modelType === 'loras') {
const commandResult = this._parseCommandInput(rawSearchTerm);
return commandResult.searchTerm ?? rawSearchTerm;
}
if (this.modelType === 'prompt') { if (this.modelType === 'prompt') {
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i); const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
if (embeddingMatch) { if (embeddingMatch) {
@@ -1245,6 +1326,91 @@ class AutoComplete {
return this._getPreferredSelectedIndex(searchTerm); return this._getPreferredSelectedIndex(searchTerm);
} }
/**
* Build a URL-encoded query string from the LoRA Manager page's active
* filters in localStorage, or null when not applicable.
*/
_getActiveLoraFilters() {
if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
return null;
}
try {
const params = new URLSearchParams();
const folder = localStorage.getItem('lora_manager_loras_activeFolder');
const recursiveRaw = localStorage.getItem('lora_manager_loras_recursiveSearch');
const recursive = recursiveRaw === null ? true : recursiveRaw.toLowerCase() === 'true';
if (folder && folder !== 'null') {
params.append('folder', folder);
} else if (!recursive) {
// Root folder with recursion disabled mirrors the page list,
// which matches only root-level files via folder=''.
params.append('folder', '');
}
const raw = localStorage.getItem('lora_manager_loras_filters');
if (raw) {
const filters = JSON.parse(raw);
if (Array.isArray(filters.baseModel)) {
filters.baseModel.forEach((m) => m && params.append('base_model', m));
}
if (filters.tags && typeof filters.tags === 'object') {
Object.entries(filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('tag_include', tag);
} else if (state === 'exclude') {
params.append('tag_exclude', tag);
}
});
}
if (filters.autoTags && typeof filters.autoTags === 'object') {
Object.entries(filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (Array.isArray(filters.modelTypes)) {
filters.modelTypes.forEach((t) => t && params.append('model_type', t));
}
if (filters.tagLogic) {
params.append('tag_logic', filters.tagLogic);
}
if (filters.license) {
if (filters.license.noCredit === 'include') {
params.append('credit_required', 'false');
} else if (filters.license.noCredit === 'exclude') {
params.append('credit_required', 'true');
}
if (filters.license.allowSelling === 'include') {
params.append('allow_selling_generated_content', 'true');
} else if (filters.license.allowSelling === 'exclude') {
params.append('allow_selling_generated_content', 'false');
}
}
}
// Always send recursive in filter mode — its presence also signals
// the backend to run the filter pipeline (e.g. show_only_sfw) even
// when no concrete filter is set, matching the list endpoint.
params.append('recursive', String(recursive));
return params.toString();
} catch (error) {
console.warn('[Lora Manager] Failed to read active filters for autocomplete:', error);
return null;
}
}
async search(term = '', endpoint = null) { async search(term = '', endpoint = null) {
try { try {
this.currentSearchTerm = term; this.currentSearchTerm = term;
@@ -1262,6 +1428,10 @@ class AutoComplete {
endpoint = `/lm/${this.modelType}/relative-paths`; endpoint = `/lm/${this.modelType}/relative-paths`;
} }
// Active-filter query params for loras (null when setting off or
// model type is not loras, so appending is safe for all types)
const activeFiltersQuery = this._getActiveLoraFilters();
// Generate multiple query variations for better matching, but avoid // Generate multiple query variations for better matching, but avoid
// sending duplicate-equivalent requests that normalize to the same // sending duplicate-equivalent requests that normalize to the same
// backend search term. // backend search term.
@@ -1281,9 +1451,10 @@ class AutoComplete {
const url = endpoint.includes('?') const url = endpoint.includes('?')
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}` ? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`; : `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
try { try {
const response = await api.fetchApi(url); const response = await api.fetchApi(finalUrl);
const data = await response.json(); const data = await response.json();
return { return {
items: data.success ? (data.relative_paths || data.words || []) : [], items: data.success ? (data.relative_paths || data.words || []) : [],
@@ -1358,6 +1529,15 @@ class AutoComplete {
} }
} }
/**
* Return the command map for the current model type.
* Lora model types get the active-filters toggle commands, all others
* keep the prompt tag commands.
*/
_getCommands() {
return this.modelType === 'loras' ? LORAS_COMMANDS : TAG_COMMANDS;
}
/** /**
* Parse command input to detect command mode * Parse command input to detect command mode
* @param {string} rawInput - Raw input text * @param {string} rawInput - Raw input text
@@ -1379,8 +1559,8 @@ class AutoComplete {
const partialCommand = trimmed.toLowerCase(); const partialCommand = trimmed.toLowerCase();
// Check for exact command match // Check for exact command match
if (TAG_COMMANDS[partialCommand]) { if (this._getCommands()[partialCommand]) {
const cmd = TAG_COMMANDS[partialCommand]; const cmd = this._getCommands()[partialCommand];
// Filter out toggle commands that don't meet their condition // Filter out toggle commands that don't meet their condition
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) { if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
return { showCommands: false, command: null, searchTerm: '' }; return { showCommands: false, command: null, searchTerm: '' };
@@ -1405,8 +1585,8 @@ class AutoComplete {
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase(); const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
const searchPart = trimmed.slice(spaceIndex + 1).trim(); const searchPart = trimmed.slice(spaceIndex + 1).trim();
if (TAG_COMMANDS[commandPart]) { if (this._getCommands()[commandPart]) {
const cmd = TAG_COMMANDS[commandPart]; const cmd = this._getCommands()[commandPart];
// Filter out toggle commands that don't meet their condition // Filter out toggle commands that don't meet their condition
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) { if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
return { showCommands: false, command: null, searchTerm: trimmed }; return { showCommands: false, command: null, searchTerm: trimmed };
@@ -1437,7 +1617,7 @@ class AutoComplete {
const commands = []; const commands = [];
for (const [cmd, info] of Object.entries(TAG_COMMANDS)) { for (const [cmd, info] of Object.entries(this._getCommands())) {
// Filter out toggle commands that don't meet their condition // Filter out toggle commands that don't meet their condition
if (info.type === 'toggle_setting' && info.condition) { if (info.type === 'toggle_setting' && info.condition) {
if (!info.condition()) continue; if (!info.condition()) continue;
@@ -1902,7 +2082,8 @@ class AutoComplete {
showPreviewForItem(relativePath, itemElement) { showPreviewForItem(relativePath, itemElement) {
if (!this.options.showPreview || !this.previewTooltip) return; if (!this.options.showPreview || !this.previewTooltip) return;
if (typeof relativePath !== 'string' || !relativePath) return;
// Extract filename without extension for preview // Extract filename without extension for preview
const fileName = relativePath.split(/[/\\]/).pop(); const fileName = relativePath.split(/[/\\]/).pop();
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, ''); const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
@@ -1984,14 +2165,18 @@ class AutoComplete {
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm); const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
const offset = this.items.length; const offset = this.items.length;
// Active-filter query params for loras (null when setting off)
const activeFiltersQuery = this._getActiveLoraFilters();
// Execute all queries in parallel with offset // Execute all queries in parallel with offset
const searchPromises = queriesToExecute.map(async (query) => { const searchPromises = queriesToExecute.map(async (query) => {
const url = endpoint.includes('?') const url = endpoint.includes('?')
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}` ? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`; : `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
try { try {
const response = await api.fetchApi(url); const response = await api.fetchApi(finalUrl);
const data = await response.json(); const data = await response.json();
return data.success ? (data.relative_paths || data.words || []) : []; return data.success ? (data.relative_paths || data.words || []) : [];
} catch (error) { } catch (error) {
@@ -2692,14 +2877,14 @@ class AutoComplete {
const settingManager = app?.extensionManager?.setting; const settingManager = app?.extensionManager?.setting;
if (settingManager && typeof settingManager.set === 'function') { if (settingManager && typeof settingManager.set === 'function') {
await settingManager.set(settingId, value); await settingManager.set(settingId, value);
this._showToggleFeedback(value); this._showToggleFeedback(command, value);
this._clearCurrentToken(); this._clearCurrentToken();
} else { } else {
// Fallback: use legacy settings API // Fallback: use legacy settings API
const setting = app.ui.settings.settingsById?.[settingId]; const setting = app.ui.settings.settingsById?.[settingId];
if (setting) { if (setting) {
app.ui.settings.setSettingValue(settingId, value); app.ui.settings.setSettingValue(settingId, value);
this._showToggleFeedback(value); this._showToggleFeedback(command, value);
this._clearCurrentToken(); this._clearCurrentToken();
} }
} }
@@ -2718,15 +2903,16 @@ class AutoComplete {
/** /**
* Show visual feedback for toggle action using toast * Show visual feedback for toggle action using toast
* @param {Object} command - The toggle command that was executed
* @param {boolean} enabled - New autocomplete state * @param {boolean} enabled - New autocomplete state
*/ */
_showToggleFeedback(enabled) { _showToggleFeedback(command, enabled) {
showToast({ showToast({
severity: enabled ? 'success' : 'secondary', severity: enabled ? 'success' : 'secondary',
summary: enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled', summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
detail: enabled detail: command.feedbackDetail || (enabled
? 'Tag autocomplete is now ON. Type to see suggestions.' ? 'Tag autocomplete is now ON. Type to see suggestions.'
: 'Tag autocomplete is now OFF. Use /ac to re-enable.', : 'Tag autocomplete is now OFF. Use /ac to re-enable.'),
life: 3000 life: 3000
}); });
} }
+38
View File
@@ -39,6 +39,9 @@ const NEW_TAB_ZOOM_LEVEL = 0.8;
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step"; const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
const STRENGTH_STEP_DEFAULT = 0.05; const STRENGTH_STEP_DEFAULT = 0.05;
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID = "loramanager.lora_active_filters_autocomplete";
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT = false;
// ============================================================================ // ============================================================================
// Helper Functions // Helper Functions
// ============================================================================ // ============================================================================
@@ -360,6 +363,32 @@ const getStrengthStepPreference = (() => {
}; };
})(); })();
const getLoraActiveFiltersAutocompletePreference = (() => {
let settingsUnavailableLogged = false;
return () => {
const settingManager = app?.extensionManager?.setting;
if (!settingManager || typeof settingManager.get !== "function") {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: settings API unavailable, using default lora active filters autocomplete setting.");
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
try {
const value = settingManager.get(LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID);
return value ?? LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read lora active filters autocomplete setting, using default.", error);
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
};
})();
// ============================================================================ // ============================================================================
// Register Extension with All Settings // Register Extension with All Settings
// ============================================================================ // ============================================================================
@@ -396,6 +425,14 @@ app.registerExtension({
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.", tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
category: ["LoRA Manager", "Autocomplete", "Prompt"], category: ["LoRA Manager", "Autocomplete", "Prompt"],
}, },
{
id: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
name: "Search LoRA autocomplete within active filters",
type: "boolean",
defaultValue: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT,
tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. Commands /af and /noaf toggle this mode.",
category: ["LoRA Manager", "Autocomplete", "LoRA Active Filters"],
},
{ {
id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID, id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID,
name: "Append comma after autocomplete", name: "Append comma after autocomplete",
@@ -549,4 +586,5 @@ export {
getUsageStatisticsPreference, getUsageStatisticsPreference,
getNewTabTemplatePreference, getNewTabTemplatePreference,
getStrengthStepPreference, getStrengthStepPreference,
getLoraActiveFiltersAutocompletePreference,
}; };