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
Will Miao 24f5f7df5d feat(llm): add Gemini as a preset AI provider 2026-08-07 10:27:51 +08:00
Will Miao daf01fb1d6 feat(downloads): show batch download summary with failure details and retry 2026-08-07 10:23:17 +08:00
Will Miao 0f11b6def9 fix(recipes): allow recipes storage path on a different drive (Windows)
os.path.commonpath raises ValueError for paths on different Windows
drives. Treat that as no common root so cross-drive recipes migrations
succeed instead of failing with 'Invalid recipes path change'.
2026-08-06 22:18:24 +08:00
Will Miao 7df83f44b8 feat(SaveImageLM): add add_loras_to_prompt toggle to restore legacy lora syntax line in metadata 2026-08-06 15:58:18 +08:00
Will Miao 169fa7bed6 fix(vue-widgets): resolve pre-existing typecheck errors 2026-08-06 15:33:02 +08:00
Will Miao 027b504fe8 refactor(autocomplete): remove unused custom_words and embeddings modelTypes 2026-08-06 15:28:58 +08:00
Will Miao 186ef4da78 refactor(ui): group example image download actions into a submenu
Move the 'Download Missing' / 'Re-process All' example image actions
under a single 'Download Example Images' submenu item in the single-model
and bulk context menus, matching the existing send-to-workflow submenu
pattern. Shorten the submenu labels and update all locale translations.
2026-08-03 21:18:05 +08:00
pixelpaws dc674098e7 Merge pull request #1050 from willmiao/fix/recipes-bulk-content-rating
fix(recipes): enable bulk content rating for selected recipes
2026-08-03 20:58:24 +08:00
Will Miao 9087b4b07c feat(example-images): add missing-only download path and skip existing files
Split the single-model and bulk context menu actions into 'Download
Missing Example Images' (regular endpoint, skips already-processed
models) and 'Re-process Example Images' (force endpoint, retries
failed models).

- start_download accepts model_hashes so a selected subset can be
  processed with the progress-aware skip logic; explicitly targeted
  models bypass the failed/processed model-level guards so per-image
  gaps are filled
- pre-download existence check in the processor skips network requests
  for image files already on disk across all download paths
- force download retries previously failed models and clears their
  failed status on success
- add i18n keys for the new menu items across all locales
2026-08-03 20:52:46 +08:00
73 changed files with 26056 additions and 20227 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
+26 -1
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",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Benutzerdefiniert (OpenAI-kompatibel)" "custom": "Benutzerdefiniert (OpenAI-kompatibel)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "Ausgewählte löschen", "deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"downloadExamples": "Beispielbilder herunterladen", "downloadExamples": "Beispielbilder herunterladen",
"downloadMissingExamples": "Fehlende herunterladen",
"reprocessExamples": "Alle erneut verarbeiten",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle", "resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "An Workflow senden (Ersetzen)", "sendToWorkflowReplace": "An Workflow senden (Ersetzen)",
"openExamples": "Beispiele-Ordner öffnen", "openExamples": "Beispiele-Ordner öffnen",
"downloadExamples": "Beispielbilder herunterladen", "downloadExamples": "Beispielbilder herunterladen",
"downloadMissingExamples": "Fehlende herunterladen",
"reprocessExamples": "Alle erneut verarbeiten",
"replacePreview": "Vorschau ersetzen", "replacePreview": "Vorschau ersetzen",
"setContentRating": "Inhaltsbewertung festlegen", "setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
@@ -1577,6 +1588,21 @@
"downloadCsv": "CSV herunterladen", "downloadCsv": "CSV herunterladen",
"columnModelName": "Modellname", "columnModelName": "Modellname",
"columnError": "Fehler" "columnError": "Fehler"
},
"downloadBatchSummary": {
"title": "Zusammenfassung des Batch-Downloads",
"statSuccess": "Erfolgreich",
"statFailed": "Fehlgeschlagen",
"statTotal": "Gesamt",
"successMessage": "Alle {count} Modelle erfolgreich heruntergeladen",
"completedWithErrors": "Abgeschlossen, aber mit Fehlern",
"failed": "Download fehlgeschlagen",
"failedItems": "Fehlgeschlagene Elemente ({count})",
"columnName": "Modellname",
"columnError": "Fehler",
"close": "Schließen",
"copyReport": "Bericht kopieren",
"retryFailed": "Fehlgeschlagene erneut versuchen ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,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",
+26 -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",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Custom (OpenAI-compatible)" "custom": "Custom (OpenAI-compatible)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "Delete Selected", "deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"downloadExamples": "Download Example Images", "downloadExamples": "Download Example Images",
"downloadMissingExamples": "Download Missing",
"reprocessExamples": "Re-process All",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)", "resumeMetadataRefreshCount": "Resume ({count} models)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "Send to Workflow (Replace)", "sendToWorkflowReplace": "Send to Workflow (Replace)",
"openExamples": "Open Examples Folder", "openExamples": "Open Examples Folder",
"downloadExamples": "Download Example Images", "downloadExamples": "Download Example Images",
"downloadMissingExamples": "Download Missing",
"reprocessExamples": "Re-process All",
"replacePreview": "Replace Preview", "replacePreview": "Replace Preview",
"setContentRating": "Set Content Rating", "setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
@@ -1577,6 +1588,21 @@
"downloadCsv": "Download CSV", "downloadCsv": "Download CSV",
"columnModelName": "Model Name", "columnModelName": "Model Name",
"columnError": "Error" "columnError": "Error"
},
"downloadBatchSummary": {
"title": "Batch Download Summary",
"statSuccess": "Success",
"statFailed": "Failed",
"statTotal": "Total",
"successMessage": "All {count} models downloaded successfully",
"completedWithErrors": "Completed with errors",
"failed": "Download failed",
"failedItems": "Failed Items ({count})",
"columnName": "Model Name",
"columnError": "Error",
"close": "Close",
"copyReport": "Copy Report",
"retryFailed": "Retry Failed ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,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",
+26 -1
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",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Personalizado (compatible con OpenAI)" "custom": "Personalizado (compatible con OpenAI)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "Eliminar seleccionados", "deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"downloadExamples": "Descargar imágenes de ejemplo", "downloadExamples": "Descargar imágenes de ejemplo",
"downloadMissingExamples": "Descargar faltantes",
"reprocessExamples": "Reprocesar todo",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos", "resumeMetadataRefreshCount": "Reanudar{count} modelos",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "Enviar al flujo de trabajo (Reemplazar)", "sendToWorkflowReplace": "Enviar al flujo de trabajo (Reemplazar)",
"openExamples": "Abrir carpeta de ejemplos", "openExamples": "Abrir carpeta de ejemplos",
"downloadExamples": "Descargar imágenes de ejemplo", "downloadExamples": "Descargar imágenes de ejemplo",
"downloadMissingExamples": "Descargar faltantes",
"reprocessExamples": "Reprocesar todo",
"replacePreview": "Reemplazar vista previa", "replacePreview": "Reemplazar vista previa",
"setContentRating": "Establecer clasificación de contenido", "setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
@@ -1577,6 +1588,21 @@
"downloadCsv": "Descargar CSV", "downloadCsv": "Descargar CSV",
"columnModelName": "Nombre del modelo", "columnModelName": "Nombre del modelo",
"columnError": "Error" "columnError": "Error"
},
"downloadBatchSummary": {
"title": "Resumen de descarga por lotes",
"statSuccess": "Correctos",
"statFailed": "Fallidos",
"statTotal": "Total",
"successMessage": "Todos los {count} modelos se descargaron correctamente",
"completedWithErrors": "Completado con errores",
"failed": "Descarga fallida",
"failedItems": "Elementos fallidos ({count})",
"columnName": "Nombre del modelo",
"columnError": "Error",
"close": "Cerrar",
"copyReport": "Copiar informe",
"retryFailed": "Reintentar fallidos ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,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",
+26 -1
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",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Personnalisé (compatible OpenAI)" "custom": "Personnalisé (compatible OpenAI)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "Supprimer la sélection", "deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"downloadExamples": "Télécharger les images d'exemple", "downloadExamples": "Télécharger les images d'exemple",
"downloadMissingExamples": "Télécharger les manquantes",
"reprocessExamples": "Tout retraiter",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles", "resumeMetadataRefreshCount": "Reprendre{count} modèles",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "Envoyer vers le workflow (Remplacer)", "sendToWorkflowReplace": "Envoyer vers le workflow (Remplacer)",
"openExamples": "Ouvrir le dossier d'exemples", "openExamples": "Ouvrir le dossier d'exemples",
"downloadExamples": "Télécharger les images d'exemple", "downloadExamples": "Télécharger les images d'exemple",
"downloadMissingExamples": "Télécharger les manquantes",
"reprocessExamples": "Tout retraiter",
"replacePreview": "Remplacer l'aperçu", "replacePreview": "Remplacer l'aperçu",
"setContentRating": "Définir la classification du contenu", "setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
@@ -1577,6 +1588,21 @@
"downloadCsv": "Télécharger CSV", "downloadCsv": "Télécharger CSV",
"columnModelName": "Nom du modèle", "columnModelName": "Nom du modèle",
"columnError": "Erreur" "columnError": "Erreur"
},
"downloadBatchSummary": {
"title": "Résumé du téléchargement groupé",
"statSuccess": "Réussis",
"statFailed": "Échoués",
"statTotal": "Total",
"successMessage": "Les {count} modèles ont été téléchargés avec succès",
"completedWithErrors": "Terminé avec des erreurs",
"failed": "Échec du téléchargement",
"failedItems": "Éléments échoués ({count})",
"columnName": "Nom du modèle",
"columnError": "Erreur",
"close": "Fermer",
"copyReport": "Copier le rapport",
"retryFailed": "Réessayer les échecs ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,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",
+26 -1
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": "תצוגת מידע בכרטיס",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "מותאם אישית (תואם OpenAI)" "custom": "מותאם אישית (תואם OpenAI)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "מחק נבחרים", "deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים", "downloadMissingLoras": "הורדת LoRAs חסרים",
"downloadExamples": "הורד תמונות דוגמה", "downloadExamples": "הורד תמונות דוגמה",
"downloadMissingExamples": "הורדת חסרים",
"reprocessExamples": "עיבוד מחדש של הכול",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)", "resumeMetadataRefreshCount": "המשך({count} מודלים)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "שלח ל-Workflow (החלף)", "sendToWorkflowReplace": "שלח ל-Workflow (החלף)",
"openExamples": "פתח תיקיית דוגמאות", "openExamples": "פתח תיקיית דוגמאות",
"downloadExamples": "הורד תמונות דוגמה", "downloadExamples": "הורד תמונות דוגמה",
"downloadMissingExamples": "הורדת חסרים",
"reprocessExamples": "עיבוד מחדש של הכול",
"replacePreview": "החלף תצוגה מקדימה", "replacePreview": "החלף תצוגה מקדימה",
"setContentRating": "הגדר דירוג תוכן", "setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
@@ -1577,6 +1588,21 @@
"downloadCsv": "הורד CSV", "downloadCsv": "הורד CSV",
"columnModelName": "שם המודל", "columnModelName": "שם המודל",
"columnError": "שגיאה" "columnError": "שגיאה"
},
"downloadBatchSummary": {
"title": "סיכום הורדה בכמות",
"statSuccess": "הצליחו",
"statFailed": "נכשלו",
"statTotal": "סה\"כ",
"successMessage": "כל {count} הדגמים הורדו בהצלחה",
"completedWithErrors": "הושלם עם שגיאות",
"failed": "ההורדה נכשלה",
"failedItems": "פריטים שנכשלו ({count})",
"columnName": "שם הדגם",
"columnError": "שגיאה",
"close": "סגור",
"copyReport": "העתק דוח",
"retryFailed": "נסה שוב ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,7 +2059,6 @@
"presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות", "presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
"presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים", "presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
"presetNameExists": "קביעה מראש עם שם זה כבר קיימת", "presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
"maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
"presetNotFound": "קביעה מראש לא נמצאה", "presetNotFound": "קביעה מראש לא נמצאה",
"invalidPreset": "נתוני קביעה מראש לא חוקיים", "invalidPreset": "נתוני קביעה מראש לא חוקיים",
"deletePresetFailed": "מחיקת קביעה מראש נכשלה", "deletePresetFailed": "מחיקת קביעה מראש נכשלה",
+26 -1
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": "カード情報表示",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "カスタム(OpenAI 互換)" "custom": "カスタム(OpenAI 互換)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "選択したものを削除", "deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード", "downloadMissingLoras": "不足している LoRA をダウンロード",
"downloadExamples": "例画像をダウンロード", "downloadExamples": "例画像をダウンロード",
"downloadMissingExamples": "不足分をダウンロード",
"reprocessExamples": "すべて再処理",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)", "resumeMetadataRefreshCount": "再開({count}モデル)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "ワークフローに送信(置換)", "sendToWorkflowReplace": "ワークフローに送信(置換)",
"openExamples": "例画像フォルダを開く", "openExamples": "例画像フォルダを開く",
"downloadExamples": "例画像をダウンロード", "downloadExamples": "例画像をダウンロード",
"downloadMissingExamples": "不足分をダウンロード",
"reprocessExamples": "すべて再処理",
"replacePreview": "プレビューを置換", "replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定", "setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
@@ -1577,6 +1588,21 @@
"downloadCsv": "CSVをダウンロード", "downloadCsv": "CSVをダウンロード",
"columnModelName": "モデル名", "columnModelName": "モデル名",
"columnError": "エラー" "columnError": "エラー"
},
"downloadBatchSummary": {
"title": "バッチダウンロードの概要",
"statSuccess": "成功",
"statFailed": "失敗",
"statTotal": "合計",
"successMessage": "{count} 個のモデルがすべて正常にダウンロードされました",
"completedWithErrors": "エラーありで完了",
"failed": "ダウンロードに失敗しました",
"failedItems": "失敗した項目({count}",
"columnName": "モデル名",
"columnError": "エラー",
"close": "閉じる",
"copyReport": "レポートをコピー",
"retryFailed": "失敗した項目を再試行({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,7 +2059,6 @@
"presetNameTooLong": "プリセット名は{max}文字以内にしてください", "presetNameTooLong": "プリセット名は{max}文字以内にしてください",
"presetNameInvalidChars": "プリセット名に使用できない文字が含まれています", "presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
"presetNameExists": "同じ名前のプリセットが既に存在します", "presetNameExists": "同じ名前のプリセットが既に存在します",
"maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
"presetNotFound": "プリセットが見つかりません", "presetNotFound": "プリセットが見つかりません",
"invalidPreset": "無効なプリセットデータです", "invalidPreset": "無効なプリセットデータです",
"deletePresetFailed": "プリセットの削除に失敗しました", "deletePresetFailed": "プリセットの削除に失敗しました",
+26 -1
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": "카드 정보 표시",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "사용자 정의 (OpenAI 호환)" "custom": "사용자 정의 (OpenAI 호환)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "선택된 항목 삭제", "deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"downloadExamples": "예시 이미지 다운로드", "downloadExamples": "예시 이미지 다운로드",
"downloadMissingExamples": "누락된 것만 다운로드",
"reprocessExamples": "모두 다시 처리",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)", "resumeMetadataRefreshCount": "재개({count}개 모델)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "워크플로로 전송 (교체)", "sendToWorkflowReplace": "워크플로로 전송 (교체)",
"openExamples": "예시 폴더 열기", "openExamples": "예시 폴더 열기",
"downloadExamples": "예시 이미지 다운로드", "downloadExamples": "예시 이미지 다운로드",
"downloadMissingExamples": "누락된 것만 다운로드",
"reprocessExamples": "모두 다시 처리",
"replacePreview": "미리보기 교체", "replacePreview": "미리보기 교체",
"setContentRating": "콘텐츠 등급 설정", "setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
@@ -1577,6 +1588,21 @@
"downloadCsv": "CSV 다운로드", "downloadCsv": "CSV 다운로드",
"columnModelName": "모델 이름", "columnModelName": "모델 이름",
"columnError": "오류" "columnError": "오류"
},
"downloadBatchSummary": {
"title": "일괄 다운로드 요약",
"statSuccess": "성공",
"statFailed": "실패",
"statTotal": "전체",
"successMessage": "{count}개 모델이 모두 성공적으로 다운로드되었습니다",
"completedWithErrors": "오류와 함께 완료됨",
"failed": "다운로드 실패",
"failedItems": "실패한 항목 ({count})",
"columnName": "모델 이름",
"columnError": "오류",
"close": "닫기",
"copyReport": "보고서 복사",
"retryFailed": "실패 항목 재시도 ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,7 +2059,6 @@
"presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다", "presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
"presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다", "presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
"presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다", "presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
"maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
"presetNotFound": "프리셋을 찾을 수 없습니다", "presetNotFound": "프리셋을 찾을 수 없습니다",
"invalidPreset": "잘못된 프리셋 데이터입니다", "invalidPreset": "잘못된 프리셋 데이터입니다",
"deletePresetFailed": "프리셋 삭제에 실패했습니다", "deletePresetFailed": "프리셋 삭제에 실패했습니다",
+26 -1
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": "Отображение информации карточки",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "Пользовательский (совместимый с OpenAI)" "custom": "Пользовательский (совместимый с OpenAI)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "Удалить выбранные", "deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"downloadExamples": "Загрузить примеры изображений", "downloadExamples": "Загрузить примеры изображений",
"downloadMissingExamples": "Скачать недостающие",
"reprocessExamples": "Обработать всё заново",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)", "resumeMetadataRefreshCount": "Возобновить({count} моделей)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "Отправить в Workflow (Заменить)", "sendToWorkflowReplace": "Отправить в Workflow (Заменить)",
"openExamples": "Открыть папку примеров", "openExamples": "Открыть папку примеров",
"downloadExamples": "Загрузить примеры изображений", "downloadExamples": "Загрузить примеры изображений",
"downloadMissingExamples": "Скачать недостающие",
"reprocessExamples": "Обработать всё заново",
"replacePreview": "Заменить превью", "replacePreview": "Заменить превью",
"setContentRating": "Установить рейтинг контента", "setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
@@ -1577,6 +1588,21 @@
"downloadCsv": "Скачать CSV", "downloadCsv": "Скачать CSV",
"columnModelName": "Имя модели", "columnModelName": "Имя модели",
"columnError": "Ошибка" "columnError": "Ошибка"
},
"downloadBatchSummary": {
"title": "Сводка пакетной загрузки",
"statSuccess": "Успешно",
"statFailed": "Ошибки",
"statTotal": "Всего",
"successMessage": "Все {count} моделей успешно загружены",
"completedWithErrors": "Завершено с ошибками",
"failed": "Не удалось загрузить",
"failedItems": "Неудачные элементы ({count})",
"columnName": "Имя модели",
"columnError": "Ошибка",
"close": "Закрыть",
"copyReport": "Скопировать отчёт",
"retryFailed": "Повторить неудачные ({count})"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,7 +2059,6 @@
"presetNameTooLong": "Имя пресета должно содержать не более {max} символов", "presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
"presetNameInvalidChars": "Имя пресета содержит недопустимые символы", "presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
"presetNameExists": "Пресет с таким именем уже существует", "presetNameExists": "Пресет с таким именем уже существует",
"maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
"presetNotFound": "Пресет не найден", "presetNotFound": "Пресет не найден",
"invalidPreset": "Недопустимые данные пресета", "invalidPreset": "Недопустимые данные пресета",
"deletePresetFailed": "Не удалось удалить пресет", "deletePresetFailed": "Не удалось удалить пресет",
+26 -1
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": "卡片信息显示",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "自定义(OpenAI 兼容)" "custom": "自定义(OpenAI 兼容)"
}, },
@@ -773,6 +780,8 @@
"deleteAll": "删除已选", "deleteAll": "删除已选",
"downloadMissingLoras": "下载缺失的 LoRAs", "downloadMissingLoras": "下载缺失的 LoRAs",
"downloadExamples": "下载示例图片", "downloadExamples": "下载示例图片",
"downloadMissingExamples": "下载缺失的",
"reprocessExamples": "重新处理全部",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)", "resumeMetadataRefreshCount": "恢复({count} 个模型)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "发送到工作流(替换)", "sendToWorkflowReplace": "发送到工作流(替换)",
"openExamples": "打开示例文件夹", "openExamples": "打开示例文件夹",
"downloadExamples": "下载示例图片", "downloadExamples": "下载示例图片",
"downloadMissingExamples": "下载缺失的",
"reprocessExamples": "重新处理全部",
"replacePreview": "替换预览", "replacePreview": "替换预览",
"setContentRating": "设置内容评级", "setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
@@ -1577,6 +1588,21 @@
"downloadCsv": "下载 CSV", "downloadCsv": "下载 CSV",
"columnModelName": "模型名称", "columnModelName": "模型名称",
"columnError": "错误" "columnError": "错误"
},
"downloadBatchSummary": {
"title": "批量下载摘要",
"statSuccess": "成功",
"statFailed": "失败",
"statTotal": "总数",
"successMessage": "全部 {count} 个模型下载成功",
"completedWithErrors": "已完成,但有错误",
"failed": "下载失败",
"failedItems": "失败项({count}",
"columnName": "模型名称",
"columnError": "错误",
"close": "关闭",
"copyReport": "复制报告",
"retryFailed": "重试失败项({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,7 +2059,6 @@
"presetNameTooLong": "预设名称不能超过 {max} 个字符", "presetNameTooLong": "预设名称不能超过 {max} 个字符",
"presetNameInvalidChars": "预设名称包含无效字符", "presetNameInvalidChars": "预设名称包含无效字符",
"presetNameExists": "已存在同名预设", "presetNameExists": "已存在同名预设",
"maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
"presetNotFound": "预设未找到", "presetNotFound": "预设未找到",
"invalidPreset": "无效的预设数据", "invalidPreset": "无效的预设数据",
"deletePresetFailed": "删除预设失败", "deletePresetFailed": "删除预设失败",
+27 -2
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": "卡片資訊顯示",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek", "deepseek": "DeepSeek",
"groq": "Groq", "groq": "Groq",
"openrouter": "OpenRouter", "openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go", "opencode-go": "OpenCode Go",
"custom": "自訂(OpenAI 相容)" "custom": "自訂(OpenAI 相容)"
}, },
@@ -686,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": "設定",
@@ -773,6 +780,8 @@
"deleteAll": "刪除所選", "deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs", "downloadMissingLoras": "下載缺失的 LoRAs",
"downloadExamples": "下載範例圖片", "downloadExamples": "下載範例圖片",
"downloadMissingExamples": "下載缺少的",
"reprocessExamples": "重新處理全部",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)", "resumeMetadataRefreshCount": "恢復({count} 個模型)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "傳送到工作流(取代)", "sendToWorkflowReplace": "傳送到工作流(取代)",
"openExamples": "開啟範例資料夾", "openExamples": "開啟範例資料夾",
"downloadExamples": "下載範例圖片", "downloadExamples": "下載範例圖片",
"downloadMissingExamples": "下載缺少的",
"reprocessExamples": "重新處理全部",
"replacePreview": "更換預覽圖", "replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級", "setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
@@ -1577,6 +1588,21 @@
"downloadCsv": "下載 CSV", "downloadCsv": "下載 CSV",
"columnModelName": "模型名稱", "columnModelName": "模型名稱",
"columnError": "錯誤" "columnError": "錯誤"
},
"downloadBatchSummary": {
"title": "批次下載摘要",
"statSuccess": "成功",
"statFailed": "失敗",
"statTotal": "總數",
"successMessage": "全部 {count} 個模型下載成功",
"completedWithErrors": "已完成,但有錯誤",
"failed": "下載失敗",
"failedItems": "失敗項目({count}",
"columnName": "模型名稱",
"columnError": "錯誤",
"close": "關閉",
"copyReport": "複製報告",
"retryFailed": "重試失敗項目({count}"
} }
}, },
"modelTags": { "modelTags": {
@@ -2033,7 +2059,6 @@
"presetNameTooLong": "預設名稱不能超過 {max} 個字元", "presetNameTooLong": "預設名稱不能超過 {max} 個字元",
"presetNameInvalidChars": "預設名稱包含無效字元", "presetNameInvalidChars": "預設名稱包含無效字元",
"presetNameExists": "已存在同名預設", "presetNameExists": "已存在同名預設",
"maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
"presetNotFound": "預設未找到", "presetNotFound": "預設未找到",
"invalidPreset": "無效的預設資料", "invalidPreset": "無效的預設資料",
"deletePresetFailed": "刪除預設失敗", "deletePresetFailed": "刪除預設失敗",
+16 -3
View File
@@ -252,6 +252,13 @@ class SaveImageLM:
"tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.", "tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.",
}, },
), ),
"add_loras_to_prompt": (
"BOOLEAN",
{
"default": False,
"tooltip": "When enabled, appends the LoRA syntax line (e.g. <lora:name:strength>) after the positive prompt in the saved metadata.",
},
),
"add_counter_to_filename": ( "add_counter_to_filename": (
"BOOLEAN", "BOOLEAN",
{ {
@@ -348,7 +355,7 @@ class SaveImageLM:
type_lower = model_type.lower() if model_type else "other" type_lower = model_type.lower() if model_type else "other"
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}" return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
def format_metadata(self, metadata_dict: dict) -> str: def format_metadata(self, metadata_dict: dict, add_loras_to_prompt: bool = False) -> str:
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources.""" """Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
if not metadata_dict: return "" if not metadata_dict: return ""
@@ -458,7 +465,10 @@ class SaveImageLM:
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
# Build output lines # Build output lines
lines = [prompt] if prompt else [""] prompt_line = prompt if prompt else ""
if add_loras_to_prompt and loras_text:
prompt_line = f"{prompt_line}\n{loras_text}" if prompt_line else loras_text
lines = [prompt_line] if prompt_line else [""]
if negative_prompt: if negative_prompt:
lines.append(f"Negative prompt: {negative_prompt}") lines.append(f"Negative prompt: {negative_prompt}")
@@ -793,6 +803,7 @@ class SaveImageLM:
save_with_metadata=True, save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
save_as_recipe=False, save_as_recipe=False,
add_loras_to_prompt=False,
): ):
"""Save images with metadata""" """Save images with metadata"""
results = [] results = []
@@ -801,7 +812,7 @@ class SaveImageLM:
raw_metadata = get_metadata() raw_metadata = get_metadata()
metadata_dict = MetadataProcessor.to_dict(raw_metadata, id) metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
metadata = self.format_metadata(metadata_dict) metadata = self.format_metadata(metadata_dict, add_loras_to_prompt)
# Process filename_prefix with pattern substitution # Process filename_prefix with pattern substitution
filename_prefix = self.format_filename(filename_prefix, metadata_dict) filename_prefix = self.format_filename(filename_prefix, metadata_dict)
@@ -943,6 +954,7 @@ class SaveImageLM:
save_with_metadata=True, save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
save_as_recipe=False, save_as_recipe=False,
add_loras_to_prompt=False,
): ):
"""Process and save image with metadata""" """Process and save image with metadata"""
# Make sure the output directory exists # Make sure the output directory exists
@@ -974,6 +986,7 @@ class SaveImageLM:
save_with_metadata, save_with_metadata,
add_counter_to_filename, add_counter_to_filename,
save_as_recipe, save_as_recipe,
add_loras_to_prompt,
) )
return { return {
+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
+5
View File
@@ -201,6 +201,11 @@ PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
"api_base": "https://openrouter.ai/api/v1", "api_base": "https://openrouter.ai/api/v1",
"requires_key": True, "requires_key": True,
}, },
"google": {
"name": "Gemini",
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
"requires_key": True,
},
"opencode-go": { "opencode-go": {
"name": "OpenCode Go", "name": "OpenCode Go",
"api_base": "https://opencode.ai/zen/go/v1", "api_base": "https://opencode.ai/zen/go/v1",
+6 -3
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,
@@ -1473,10 +1474,12 @@ class SettingsManager:
try: try:
common_root = os.path.commonpath([source, target]) common_root = os.path.commonpath([source, target])
except ValueError as exc: except ValueError:
raise ValueError("Invalid recipes path change") from exc # Windows: paths on different drives share no common root.
# A cross-drive move is valid, so treat it as no common root.
common_root = None
if common_root == source: if common_root is not None and common_root == source:
raise ValueError("Recipes path cannot be moved into a nested directory") raise ValueError("Recipes path cannot be moved into a nested directory")
planned_recipe_updates: Dict[str, Dict[str, Any]] = {} planned_recipe_updates: Dict[str, Dict[str, Any]] = {}
+27 -5
View File
@@ -172,6 +172,7 @@ class DownloadManager:
model_types = data.get("model_types", ["lora", "checkpoint"]) model_types = data.get("model_types", ["lora", "checkpoint"])
delay = float(data.get("delay", 0.2)) delay = float(data.get("delay", 0.2))
force = data.get("force", False) force = data.get("force", False)
model_hashes = data.get("model_hashes", [])
# Step 2: Validate configuration (fast lookup) # Step 2: Validate configuration (fast lookup)
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
@@ -241,6 +242,7 @@ class DownloadManager:
delay, delay,
active_library, active_library,
force, force,
model_hashes,
) )
) )
@@ -577,8 +579,9 @@ class DownloadManager:
delay, delay,
library_name, library_name,
force: bool = False, force: bool = False,
model_hashes: list[str] | None = None,
): ):
"""Download example images for all models.""" """Download example images for all models (or only the given hashes)."""
downloader = await get_downloader() downloader = await get_downloader()
@@ -606,6 +609,18 @@ class DownloadManager:
if model.get("sha256"): if model.get("sha256"):
all_models.append((scanner_type, model, scanner)) all_models.append((scanner_type, model, scanner))
# Restrict to the requested hashes when provided (empty = all models).
# Explicit targets are a directed user request, so previously failed
# models are retried instead of skipped.
explicit_targets = bool(model_hashes)
if model_hashes:
hash_set = {h.lower() for h in model_hashes}
all_models = [
(scanner_type, model, scanner)
for scanner_type, model, scanner in all_models
if model.get("sha256", "").lower() in hash_set
]
# Update total count # Update total count
self._progress["total"] = len(all_models) self._progress["total"] = len(all_models)
logger.debug(f"Found {self._progress['total']} models to process") logger.debug(f"Found {self._progress['total']} models to process")
@@ -629,6 +644,7 @@ class DownloadManager:
downloader, downloader,
library_name, library_name,
force, force,
explicit_targets,
) )
# Update progress # Update progress
@@ -725,6 +741,7 @@ class DownloadManager:
downloader, downloader,
library_name, library_name,
force: bool = False, force: bool = False,
explicit_targets: bool = False,
): ):
"""Process a single model download.""" """Process a single model download."""
@@ -747,8 +764,9 @@ class DownloadManager:
self._progress["current_model"] = f"{model_name} ({model_hash[:8]})" self._progress["current_model"] = f"{model_name} ({model_hash[:8]})"
await self._broadcast_progress(status="running") await self._broadcast_progress(status="running")
# Skip if already in failed models (unless force mode is enabled) # Skip if already in failed models (unless force mode is enabled or
if not force and model_hash in self._progress["failed_models"]: # the model was explicitly targeted by hash)
if not force and not explicit_targets and model_hash in self._progress["failed_models"]:
logger.debug(f"Skipping known failed model: {model_name}") logger.debug(f"Skipping known failed model: {model_name}")
return False return False
@@ -757,6 +775,10 @@ class DownloadManager:
) )
existing_files = _model_directory_has_files(model_dir) existing_files = _model_directory_has_files(model_dir)
# Model-level guard: a populated folder counts as done. Explicitly
# targeted models bypass it so the per-image existence pre-check can
# fill individual gaps without re-fetching existing files.
if not explicit_targets:
# Skip if already processed AND directory exists with files # Skip if already processed AND directory exists with files
if model_hash in self._progress["processed_models"]: if model_hash in self._progress["processed_models"]:
if existing_files: if existing_files:
@@ -884,7 +906,7 @@ class DownloadManager:
model_name, model_name,
) )
# Clear failed_models so non-force runs can retry # Clear failed_models so non-force runs can retry
if force and model_hash in self._progress["failed_models"]: if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash) self._progress["failed_models"].discard(model_hash)
logger.info( logger.info(
f"Removed {model_name} from failed_models after force retry with rate-limited images" f"Removed {model_name} from failed_models after force retry with rate-limited images"
@@ -904,7 +926,7 @@ class DownloadManager:
) )
elif success: elif success:
self._progress["processed_models"].add(model_hash) self._progress["processed_models"].add(model_hash)
if force and model_hash in self._progress["failed_models"]: if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash) self._progress["failed_models"].discard(model_hash)
logger.info( logger.info(
f"Removed {model_name} from failed_models after successful force retry" f"Removed {model_name} from failed_models after successful force retry"
+30
View File
@@ -113,6 +113,26 @@ class ExampleImagesProcessor:
message = str(error).lower() message = str(error).lower()
return '404' in message or 'file not found' in message return '404' in message or 'file not found' in message
@staticmethod
def _example_image_file_exists(model_dir: str, index: int, media_type_hint: str | None = None) -> bool:
"""Return True when the file that would be written for a media index already exists.
The final filename (``image_{index}{extension}``) depends on the downloaded
content, so the extension cannot be known ahead of time. The post-download
check skips the write when the exact target file exists; this pre-check
approximates that with the candidate extensions for the media type (videos
only when the metadata hints at a video) so the network request is avoided
for files that already exist on disk.
"""
if media_type_hint == "video":
extensions = SUPPORTED_MEDIA_EXTENSIONS['videos']
else:
extensions = SUPPORTED_MEDIA_EXTENSIONS['images']
return any(
os.path.exists(os.path.join(model_dir, f"image_{index}{ext}"))
for ext in extensions
)
@staticmethod @staticmethod
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader): async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
"""Download images for a single model """Download images for a single model
@@ -140,6 +160,11 @@ class ExampleImagesProcessor:
if optimize and 'civitai.com' in image_url: if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url) image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
# Skip the download when the file already exists on disk
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
logger.debug("File already exists, skipping download for %s", image_url)
continue
# Download the file first to determine the actual file type # Download the file first to determine the actual file type
try: try:
logger.debug(f"Downloading media file {i} for {model_name}") logger.debug(f"Downloading media file {i} for {model_name}")
@@ -229,6 +254,11 @@ class ExampleImagesProcessor:
if optimize and 'civitai.com' in image_url: if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url) image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
# Skip the download when the file already exists on disk
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
logger.debug("File already exists, skipping download for %s", image_url)
continue
async def _attempt_download() -> tuple: async def _attempt_download() -> tuple:
logger.debug("Downloading media file %s for %s", i, model_name) logger.debug("Downloading media file %s for %s", i, model_name)
return await downloader.download_to_memory( return await downloader.download_to_memory(
+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);
@@ -0,0 +1,67 @@
/* Batch Download Summary Modal component styles only.
Stat cards and failure table styles are shared with the metadata refresh
result modal (metadata-refresh-result.css) and are not redefined here. */
.download-batch-summary-modal {
max-width: 700px;
}
.summary-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-2) 0;
}
.summary-header i {
font-size: 1.4em;
}
.summary-header.success i {
color: var(--color-success);
}
.summary-header.warning i {
color: var(--color-warning);
}
.summary-header.error i {
color: var(--color-error);
}
.summary-title {
font-weight: var(--weight-semibold);
color: var(--lora-text);
}
.summary-hint {
margin-left: auto;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.btn-retry {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--lora-accent, #4f46e5);
color: #fff;
border: none;
border-radius: var(--border-radius-sm);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-weight: var(--weight-semibold);
}
.btn-retry:hover {
background: var(--lora-accent-hover, #4338ca);
}
.failure-link {
color: var(--lora-accent, #4f46e5);
text-decoration: none;
}
.failure-link:hover {
text-decoration: underline;
}
+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;
} }
+1
View File
@@ -41,6 +41,7 @@
@import 'components/sidebar.css'; /* Add sidebar component */ @import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css'; @import 'components/media-viewer.css';
@import 'components/metadata-refresh-result.css'; @import 'components/metadata-refresh-result.css';
@import 'components/download-batch-summary.css';
.initialization-notice { .initialization-notice {
display: flex; display: flex;
+2 -1
View File
@@ -184,7 +184,8 @@ export const DOWNLOAD_ENDPOINTS = {
downloadGet: '/api/lm/download-model-get', downloadGet: '/api/lm/download-model-get',
cancelGet: '/api/lm/cancel-download-get', cancelGet: '/api/lm/cancel-download-get',
progress: '/api/lm/download-progress', progress: '/api/lm/download-progress',
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
}; };
// Hugging Face API endpoints // Hugging Face API endpoints
+8 -2
View File
@@ -1641,7 +1641,7 @@ export class BaseModelApiClient {
} }
} }
async downloadExampleImages(modelHashes, modelTypes = null) { async downloadExampleImages(modelHashes, modelTypes = null, { force = true } = {}) {
let ws = null; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
@@ -1700,8 +1700,13 @@ export class BaseModelApiClient {
// Determine optimize setting // Determine optimize setting
const optimize = state.global?.settings?.optimize_example_images ?? true; const optimize = state.global?.settings?.optimize_example_images ?? true;
// force=false routes to the regular endpoint, which skips already-processed models
const endpoint = force
? DOWNLOAD_ENDPOINTS.exampleImages
: DOWNLOAD_ENDPOINTS.exampleImagesMissing;
// Make the API request to start the download process // Make the API request to start the download process
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, { const response = await fetch(endpoint, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@@ -1710,6 +1715,7 @@ export class BaseModelApiClient {
model_hashes: modelHashes, model_hashes: modelHashes,
output_dir: outputDir, output_dir: outputDir,
optimize: optimize, optimize: optimize,
force: force,
model_types: modelTypes || [this.apiConfig.config.singularName] model_types: modelTypes || [this.apiConfig.config.singularName]
}) })
}); });
@@ -137,11 +137,10 @@ export class BulkContextMenu extends BaseContextMenu {
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none'; downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
} }
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]'); const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
if (downloadExampleImagesItem) { if (downloadExampleImagesSubmenu) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes // Show on model pages (loras, checkpoints, embeddings), hide on recipes
const modelPages = ['loras', 'checkpoints', 'embeddings']; downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
} }
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
@@ -294,8 +293,11 @@ export class BulkContextMenu extends BaseContextMenu {
case 'download-missing-loras': case 'download-missing-loras':
this.handleDownloadMissingLoras(); this.handleDownloadMissingLoras();
break; break;
case 'download-missing-example-images':
this.handleDownloadExampleImages({ force: false });
break;
case 'download-example-images': case 'download-example-images':
this.handleDownloadExampleImages(); this.handleDownloadExampleImages({ force: true });
break; break;
case 'clear': case 'clear':
bulkManager.clearSelection(); bulkManager.clearSelection();
@@ -340,7 +342,7 @@ export class BulkContextMenu extends BaseContextMenu {
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes); await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
} }
async handleDownloadExampleImages() { async handleDownloadExampleImages({ force = true } = {}) {
if (state.selectedModels.size === 0) { if (state.selectedModels.size === 0) {
return; return;
} }
@@ -361,7 +363,7 @@ export class BulkContextMenu extends BaseContextMenu {
try { try {
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([...hashes]); await apiClient.downloadExampleImages([...hashes], null, { force });
} catch (error) { } catch (error) {
console.error('Bulk download example images failed:', error); console.error('Bulk download example images failed:', error);
} }
@@ -347,7 +347,10 @@ export const ModelContextMenuMixin = {
openExampleImagesFolder(this.currentCard.dataset.sha256); openExampleImagesFolder(this.currentCard.dataset.sha256);
return true; return true;
case 'download-examples': case 'download-examples':
this.downloadExampleImages(); this.downloadExampleImages(false);
return true;
case 'download-examples-force':
this.downloadExampleImages(true);
return true; return true;
case 'civitai': case 'civitai':
if (this.currentCard.dataset.from_civitai === 'true') { if (this.currentCard.dataset.from_civitai === 'true') {
@@ -378,7 +381,7 @@ export const ModelContextMenuMixin = {
}, },
// Download example images method // Download example images method
async downloadExampleImages() { async downloadExampleImages(force = false) {
const modelHash = this.currentCard.dataset.sha256; const modelHash = this.currentCard.dataset.sha256;
if (!modelHash) { if (!modelHash) {
showToast('toast.contextMenu.missingHash', {}, 'error'); showToast('toast.contextMenu.missingHash', {}, 'error');
@@ -387,7 +390,7 @@ export const ModelContextMenuMixin = {
try { try {
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([modelHash]); await apiClient.downloadExampleImages([modelHash], null, { force });
} catch (error) { } catch (error) {
console.error('Error downloading example images:', error); console.error('Error downloading example images:', error);
} }
@@ -0,0 +1,340 @@
import { translate } from '../utils/i18nHelpers.js';
import { showToast, openHuggingFace } from '../utils/uiHelpers.js';
/**
* Escape HTML entities in a string to prevent injection when interpolating into innerHTML.
* Safe for both text content and attribute values (quotes are escaped too).
* @param {string} str - The string to escape
* @returns {string} - The escaped string
*/
function _escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Resolve the display name of a failed download entry.
* Prefers the resolved name carried on the entry, then known item fields,
* then derives a name from the item URL as a last resort.
* @param {Object} entry - The failed entry ({ item, error, name? })
* @returns {string} - The best available display name
*/
function _resolveItemName(entry) {
if (entry?.name) {
return entry.name;
}
const item = entry?.item ?? entry;
const direct = item?.displayName || item?.name || item?.file_name || item?.filename || item?.selectedVersion?.name;
if (direct) {
return direct;
}
if (item?.url) {
try {
const segments = new URL(item.url).pathname.split('/').filter(Boolean);
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1]);
}
} catch (e) {
// Unparseable URL — fall through to 'Unknown'
}
}
return 'Unknown';
}
/**
* Resolve the URL to open for a failed item always the original item URL.
* @param {Object} item - The failed item payload
* @returns {string|null} - A URL string, or null when nothing is available
*/
function _resolveItemUrl(item) {
return item?.url || null;
}
/**
* Format a raw failure error into a concise human-readable message.
* Unwraps JSON envelopes and extracts HTTP status/body details when present.
* @param {*} error - The raw error (usually a string)
* @returns {string} - The formatted error message
*/
function _formatError(error) {
if (!error) {
return 'Unknown error';
}
let base = typeof error === 'string' ? error : String(error);
// Unwrap JSON envelope: { "success": false, "error": "...", ... }
try {
const parsed = JSON.parse(base);
if (parsed && typeof parsed.error === 'string' && parsed.error) {
base = parsed.error;
}
} catch (e) {
// Not a JSON envelope — keep the raw string
}
// Extract HTTP status and JSON body details, e.g. "status=403 body={...}"
let result = base;
const statusMatch = base.match(/status=(\d{3})/);
const bodyMatch = base.match(/body=(\{.*\})/s);
if (bodyMatch) {
try {
const body = JSON.parse(bodyMatch[1]);
const detail = (typeof body?.message === 'string' && body.message)
|| (typeof body?.error === 'string' && body.error)
|| null;
if (detail) {
const status = statusMatch ? statusMatch[1] : null;
result = `${status ? `HTTP ${status}` : ''}${detail}`;
}
} catch (e) {
// Body is not valid JSON — keep the base string
}
}
// Truncate overly long messages
if (result.length > 220) {
result = result.slice(0, 220) + '…';
}
return result;
}
/**
* Build a plain-text report of the batch download results.
* @param {number} total - Total number of models attempted
* @param {number} completed - Number of models successfully downloaded
* @param {Array} failedItems - Array of failed items ({ item, error })
* @returns {string} - The report text
*/
function _buildReportText(total, completed, failedItems) {
const lines = [
'=== Batch Download Report ===',
`Date: ${new Date().toLocaleString()}`,
`Total: ${total}`,
`Successfully downloaded: ${completed}`,
`Failed: ${failedItems.length}`,
'',
];
if (failedItems.length > 0) {
lines.push('--- Failed Items ---');
failedItems.forEach((entry, i) => {
const name = _resolveItemName(entry);
const error = _formatError(entry?.error);
lines.push(`${i + 1}. ${name}${error}`);
const itemUrl = _resolveItemUrl(entry?.item ?? entry);
if (itemUrl) {
lines.push(` URL: ${itemUrl}`);
}
});
lines.push('');
}
lines.push('====================');
return lines.join('\n');
}
/**
* Handle a successful clipboard write: confirm via toast and briefly swap the
* trigger button to a "Copied!" state.
* @param {HTMLElement|null} btn - The button that triggered the copy action
*/
function _onCopyReportSuccess(btn) {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}
/**
* Fallback for environments without the async Clipboard API (e.g. insecure
* contexts over LAN http where `navigator.clipboard` is undefined): copy via a
* hidden textarea and `document.execCommand('copy')`.
* @param {string} text - The report text to copy
*/
function _copyReportWithExecCommand(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
}
/**
* Copy the batch download report to the clipboard.
* Uses the async Clipboard API when available, otherwise falls back to a hidden
* textarea + execCommand so the action still works in insecure contexts.
* @param {HTMLElement} btn - The button that triggered the copy action
* @param {number} total - Total number of models attempted
* @param {number} completed - Number of models successfully downloaded
* @param {Array} failedItems - Array of failed items
*/
function _copyReport(btn, total, completed, failedItems) {
const text = _buildReportText(total, completed, failedItems);
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text)
.then(() => _onCopyReportSuccess(btn))
.catch(() => _copyReportWithExecCommand(text));
} else {
_copyReportWithExecCommand(text);
}
}
/**
* Show the batch download summary modal after a batch download completes.
* Mirrors the Metadata Fetch Summary modal lifecycle: the modal element is
* appended directly to document.body and removed on close; it is not
* registered with ModalManager.
* @param {Object} options - Summary options
* @param {number} options.total - Total number of models attempted
* @param {number} options.completed - Number of models successfully downloaded
* @param {Array} options.failedItems - Array of failed items ({ item, error })
* @param {Function} options.onRetry - Callback invoked with failedItems to retry the failed subset
*/
export function showDownloadBatchSummary({ total, completed, failedItems, onRetry }) {
const failures = failedItems || [];
const failedCount = failures.length;
// 3-state summary header semantics (mirrors BatchImportManager results header)
let headerState;
let headerIcon;
let headerText;
if (completed === 0) {
headerState = 'error';
headerIcon = 'fa-times-circle';
headerText = translate('modals.downloadBatchSummary.failed', {}, 'Download failed');
} else if (failedCount > 0) {
headerState = 'warning';
headerIcon = 'fa-exclamation-circle';
headerText = translate('modals.downloadBatchSummary.completedWithErrors', {}, 'Completed with errors');
} else {
headerState = 'success';
headerIcon = 'fa-check-circle';
headerText = translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully');
}
// Build failure table rows
const failureRows = failures.map((entry, i) => {
const item = entry?.item ?? entry;
const name = _resolveItemName(entry);
const itemUrl = _resolveItemUrl(item);
const rawError = entry?.error ? String(entry.error) : '';
const error = _formatError(entry?.error);
const nameCell = itemUrl
? `<td class="failure-name"><a href="#" class="failure-link" data-action="open-model" data-index="${i}" title="${_escapeHtml(itemUrl)}">${_escapeHtml(name)}</a></td>`
: `<td class="failure-name" title="${_escapeHtml(name)}">${_escapeHtml(name)}</td>`;
return `<tr>
<td class="failure-index">${i + 1}</td>
${nameCell}
<td class="failure-error" title="${_escapeHtml(rawError)}">${_escapeHtml(error)}</td>
</tr>`;
}).join('');
const modalHtml = `
<div id="downloadBatchSummaryModal" class="modal" style="display: block;">
<div class="modal-content download-batch-summary-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.downloadBatchSummary.title', {}, 'Batch Download Summary')}</h2>
<div class="summary-header ${headerState}">
<i class="fas ${headerIcon}"></i>
<span class="summary-title">${headerText}</span>
<span class="summary-hint">${completed}/${total}</span>
</div>
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${completed}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failedCount}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statTotal', {}, 'Total')}</span>
<span class="stat-card-value">${total}</span>
</div>
</div>
</div>
${failedCount > 0 ? `
<div class="refresh-failures-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.downloadBatchSummary.failedItems', { count: failedCount }, 'Failed Items (' + failedCount + ')')}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.downloadBatchSummary.columnName', {}, 'Model Name')}</th>
<th>${translate('modals.downloadBatchSummary.columnError', {}, 'Error')}</th>
</tr>
</thead>
<tbody>${failureRows}</tbody>
</table>
</div>
</div>
` : `
<div class="refresh-success-message">
<i class="fas fa-check-circle"></i> ${translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully')}
</div>
`}
<div class="modal-actions">
${failedCount > 0 ? `
<button class="btn-retry" data-action="retry-failed"><i class="fas fa-redo"></i> ${translate('modals.downloadBatchSummary.retryFailed', { count: failedCount }, 'Retry Failed (' + failedCount + ')')}</button>
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.downloadBatchSummary.copyReport', {}, 'Copy Report')}</button>
` : ''}
<button class="cancel-btn" data-action="close-modal">${translate('modals.downloadBatchSummary.close', {}, 'Close')}</button>
</div>
</div>
</div>
`;
const existing = document.getElementById('downloadBatchSummaryModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
const actionEl = e.target.closest('[data-action]');
const action = actionEl?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'retry-failed':
modal.remove();
if (typeof onRetry === 'function') {
onRetry(failures);
}
break;
case 'copy-report':
_copyReport(actionEl, total, completed, failures);
break;
case 'open-model': {
// Keep the modal open; just open the item's original URL in a new tab
const entry = failures[Number(actionEl.dataset.index)];
const item = entry?.item;
if (!item?.url) break;
openHuggingFace(item.url);
break;
}
}
});
}
+11 -2
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,9 +95,17 @@ export class DuplicatesManager {
recipeGrid.innerHTML = ''; recipeGrid.innerHTML = '';
} }
// Re-enable virtual scrolling // Re-enable virtual scrolling, or apply a layout switch deferred
// 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(); state.virtualScroller.enable();
} }
}
renderDuplicateGroups() { renderDuplicateGroups() {
const recipeGrid = document.getElementById('recipeGrid'); const recipeGrid = document.getElementById('recipeGrid');
+128 -11
View File
@@ -6,8 +6,9 @@ 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';
export class DownloadManager { export class DownloadManager {
constructor() { constructor() {
@@ -234,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();
@@ -427,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) {
@@ -878,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,
@@ -896,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;
@@ -984,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) {
@@ -1018,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 {
@@ -1033,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;
@@ -1059,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}...`);
@@ -1105,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();
@@ -1114,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 {
showToast('toast.loras.downloadCompleted', {}, 'success');
}
await resetAndReload(true); await resetAndReload(true);
return true; 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);
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);
@@ -1548,6 +1653,10 @@ export class DownloadManager {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
return this.executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths });
}
async executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths }) {
const batchDownloadId = Date.now().toString(); const batchDownloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`); const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
@@ -1558,6 +1667,7 @@ export class DownloadManager {
let completedDownloads = 0; let completedDownloads = 0;
let failedDownloads = 0; let failedDownloads = 0;
let cancelled = false; let cancelled = false;
const failedItems = [];
loadingManager.showCancelButton(async () => { loadingManager.showCancelButton(async () => {
if (cancelled) return; if (cancelled) return;
@@ -1658,6 +1768,7 @@ export class DownloadManager {
if (!response.success) { if (!response.success) {
failedDownloads++; failedDownloads++;
failedItems.push({ item, error: response.error || 'Unknown error', name });
} else { } else {
completedDownloads++; completedDownloads++;
updateProgress(100, completedDownloads, ''); updateProgress(100, completedDownloads, '');
@@ -1666,6 +1777,7 @@ export class DownloadManager {
if (!cancelled) { if (!cancelled) {
console.error(`Failed to download ${name}:`, err); console.error(`Failed to download ${name}:`, err);
failedDownloads++; failedDownloads++;
failedItems.push({ item, error: err?.message || 'Unknown error', name });
} }
} }
} }
@@ -1679,10 +1791,15 @@ export class DownloadManager {
} else if (failedDownloads === 0) { } else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success'); showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else { } else {
showToast('toast.loras.downloadPartialSuccess', { showDownloadBatchSummary({
completed: completedDownloads,
total: downloadItems.length, total: downloadItems.length,
}, 'warning'); completed: completedDownloads,
failedItems,
onRetry: (failed) => this.executeBatchDownload(
failed.map((f) => f.item),
{ modelRoot, targetFolder, useDefaultPaths }
),
});
} }
await resetAndReload(true); await resetAndReload(true);
-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
+6 -1
View File
@@ -32,7 +32,12 @@
<div class="context-menu-separator menu-section-break"></div> <div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview --> <!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div> <div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div> <div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
</div>
</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div> <div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div> <div class="context-menu-separator menu-section-break"></div>
<!-- Attributes --> <!-- Attributes -->
+22 -2
View File
@@ -44,8 +44,18 @@
<div class="context-menu-item" data-action="preview"> <div class="context-menu-item" data-action="preview">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.contextMenu.openExamples') }}</span> <i class="fas fa-folder-open"></i> <span>{{ t('loras.contextMenu.openExamples') }}</span>
</div> </div>
<div class="context-menu-item has-submenu" data-has-submenu="download-examples">
<i class="fas fa-download"></i>
<span>{{ t('loras.contextMenu.downloadExamples') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"> <div class="context-menu-item" data-action="download-examples">
<i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadExamples') }}</span> <i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadMissingExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-examples-force">
<i class="fas fa-redo-alt"></i> <span>{{ t('loras.contextMenu.reprocessExamples') }}</span>
</div>
</div>
</div> </div>
<div class="context-menu-item" data-action="replace-preview"> <div class="context-menu-item" data-action="replace-preview">
<i class="fas fa-image"></i> <span>{{ t('loras.contextMenu.replacePreview') }}</span> <i class="fas fa-image"></i> <span>{{ t('loras.contextMenu.replacePreview') }}</span>
@@ -136,8 +146,18 @@
</div> </div>
<div class="context-menu-section" data-section="download"> <div class="context-menu-section" data-section="download">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div> <div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="download-example-images">
<i class="fas fa-download"></i>
<span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-missing-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-example-images"> <div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span> <i class="fas fa-redo-alt"></i> <span>{{ t('loras.bulkOperations.reprocessExamples') }}</span>
</div>
</div>
</div> </div>
<div class="context-menu-item" data-action="download-missing-loras"> <div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span> <i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
@@ -202,6 +202,7 @@
<option value="deepseek">{{ t('settings.aiProvider.providerOptions.deepseek') }}</option> <option value="deepseek">{{ t('settings.aiProvider.providerOptions.deepseek') }}</option>
<option value="groq">{{ t('settings.aiProvider.providerOptions.groq') }}</option> <option value="groq">{{ t('settings.aiProvider.providerOptions.groq') }}</option>
<option value="openrouter">{{ t('settings.aiProvider.providerOptions.openrouter') }}</option> <option value="openrouter">{{ t('settings.aiProvider.providerOptions.openrouter') }}</option>
<option value="google">{{ t('settings.aiProvider.providerOptions.google') }}</option>
<option value="opencode-go">{{ t('settings.aiProvider.providerOptions.opencode-go') }}</option> <option value="opencode-go">{{ t('settings.aiProvider.providerOptions.opencode-go') }}</option>
<option value="custom">{{ t('settings.aiProvider.providerOptions.custom') }}</option> <option value="custom">{{ t('settings.aiProvider.providerOptions.custom') }}</option>
</select> </select>
@@ -625,6 +626,23 @@
</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">
+6 -1
View File
@@ -32,7 +32,12 @@
<div class="context-menu-separator menu-section-break"></div> <div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview --> <!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div> <div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div> <div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
</div>
</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div> <div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div> <div class="context-menu-separator menu-section-break"></div>
<!-- Attributes --> <!-- Attributes -->
@@ -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');
});
}); });
@@ -2155,4 +2155,35 @@ describe('Interaction-level regression coverage', () => {
excludedItem.dispatchEvent(new Event('click', { bubbles: true })); excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1); expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
}); });
it('routes single-model example downloads to missing-only and force paths', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu">
<div class="context-menu-item has-submenu" data-has-submenu="download-examples">
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"></div>
<div class="context-menu-item" data-action="download-examples-force"></div>
</div>
</div>
</div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
card.dataset.sha256 = 'abc123hash';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="download-examples"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: false });
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
});
}); });
@@ -0,0 +1,438 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
SUMMARY_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
} = vi.hoisted(() => ({
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
}));
const showToastMock = vi.hoisted(() => vi.fn());
const openHuggingFaceMock = vi.hoisted(() => vi.fn());
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_key, _params, fallback) => fallback ?? ''),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
openHuggingFace: openHuggingFaceMock,
}));
// A realistic failure payload from the backend: a JSON envelope whose `error`
// field embeds an HTTP status and a nested JSON body (Civitai Early Access).
const REAL_ERROR = '{"success": false, "error": "Failed to resolve authenticated Civitai redirect: status=403 body={\\"error\\":\\"Early Access\\",\\"deadline\\":\\"2026-08-12T08:18:36.063Z\\",\\"message\\":\\"This asset is in Early Access. You can use Buzz access it now!\\"}", "download_id": "1786065633067"}';
// The human-readable error the component should derive from REAL_ERROR.
const FORMATTED_REAL_ERROR = 'HTTP 403 — This asset is in Early Access. You can use Buzz access it now!';
describe('DownloadBatchSummaryModal', () => {
let showDownloadBatchSummary;
beforeEach(async () => {
document.body.innerHTML = '';
showToastMock.mockClear();
openHuggingFaceMock.mockClear();
({ showDownloadBatchSummary } = await import(SUMMARY_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
delete navigator.clipboard;
delete document.execCommand;
vi.restoreAllMocks();
vi.useRealTimers();
});
it('renders a warning summary with stat cards and a failure table on partial success', () => {
showDownloadBatchSummary({
total: 3,
completed: 2,
failedItems: [
{ item: { displayName: 'LoraA' }, error: 'timeout' },
{ item: { name: 'LoraB' }, error: '404' },
],
onRetry: vi.fn(),
});
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal).not.toBeNull();
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
// Success / Failed / Total stat cards.
const statValues = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
expect(statValues).toEqual(['2', '2', '3']);
const rows = modal.querySelectorAll('.failure-table tbody tr');
expect(rows).toHaveLength(2);
expect(rows[0].querySelector('.failure-name').textContent).toBe('LoraA');
expect(rows[0].querySelector('.failure-error').textContent).toBe('timeout');
expect(rows[1].querySelector('.failure-name').textContent).toBe('LoraB');
expect(rows[1].querySelector('.failure-error').textContent).toBe('404');
expect(modal.querySelector('[data-action="retry-failed"]').textContent).toContain('Retry Failed (2)');
expect(modal.querySelector('[data-action="copy-report"]')).not.toBeNull();
});
it('renders an error header when every download failed', () => {
showDownloadBatchSummary({
total: 2,
completed: 0,
failedItems: [
{ item: { displayName: 'LoraA' }, error: 'timeout' },
{ item: { displayName: 'LoraB' }, error: '404' },
],
onRetry: vi.fn(),
});
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
expect(modal.querySelector('.summary-title').textContent).toBe('Download failed');
});
it('renders a success summary without a failure table or retry button', () => {
showDownloadBatchSummary({ total: 2, completed: 2, failedItems: [], onRetry: vi.fn() });
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
expect(modal.querySelector('.failure-table')).toBeNull();
expect(modal.querySelector('[data-action="retry-failed"]')).toBeNull();
expect(modal.querySelector('.refresh-success-message')).not.toBeNull();
});
it('escapes HTML in failed item names and errors', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [
{ item: { name: '<img src=x onerror=alert(1)>', url: 'https://example.com/xss-model' }, error: '<script>bad()</script>' },
],
onRetry: vi.fn(),
});
const nameCell = document.querySelector('.failure-name');
const errorCell = document.querySelector('.failure-error');
// The URL resolves, so the name renders inside the failure link; the
// escaped entities must render back to the literal payload as text...
expect(nameCell.querySelector('a.failure-link')).not.toBeNull();
expect(nameCell.textContent).toContain('<img src=x onerror=alert(1)>');
expect(errorCell.textContent).toContain('<script>bad()</script>');
// ...and never as live DOM nodes.
expect(document.querySelector('.failure-table img')).toBeNull();
expect(document.querySelector('.failure-table script')).toBeNull();
expect(nameCell.innerHTML).toContain('&lt;img');
});
it('removes the modal and invokes onRetry with the original failed items', () => {
const onRetry = vi.fn();
const failedItems = [{ item: { displayName: 'LoraA' }, error: 'timeout' }];
showDownloadBatchSummary({ total: 3, completed: 2, failedItems, onRetry });
document.querySelector('[data-action="retry-failed"]').click();
expect(document.getElementById('downloadBatchSummaryModal')).toBeNull();
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(failedItems);
// Same object references, not copies.
expect(onRetry.mock.calls[0][0][0]).toBe(failedItems[0]);
});
it('closes the modal via the close action without retrying', () => {
const onRetry = vi.fn();
showDownloadBatchSummary({
total: 2,
completed: 1,
failedItems: [{ item: { name: 'LoraA' }, error: 'timeout' }],
onRetry,
});
document.querySelector('.cancel-btn[data-action="close-modal"]').click();
expect(document.getElementById('downloadBatchSummaryModal')).toBeNull();
expect(onRetry).not.toHaveBeenCalled();
});
it('copies a plain-text batch report to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
showDownloadBatchSummary({
total: 3,
completed: 2,
failedItems: [
{ item: { displayName: 'LoraA', url: 'https://civitai.red/models/111/lora-a?modelVersionId=222' }, error: 'timeout' },
{ item: { name: 'LoraB', url: 'https://example.com/lora-b' }, error: '404' },
],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
// writeText is invoked synchronously by the click handler.
expect(writeText).toHaveBeenCalledTimes(1);
const text = writeText.mock.calls[0][0];
expect(text).toContain('Batch Download Report');
expect(text).toContain('Total: 3');
expect(text).toContain('LoraA — timeout');
expect(text).toContain('LoraB — 404');
// Each failed item with a URL gets an indented URL line right after it.
expect(text).toContain(' URL: https://civitai.red/models/111/lora-a?modelVersionId=222');
expect(text).toContain(' URL: https://example.com/lora-b');
// Exactly the two URLs from the failed items — nothing more, no undefined.
expect(text.match(/^\s+URL:/gm)).toHaveLength(2);
expect(text).not.toContain('URL: undefined');
// The toast fires after the mocked clipboard promise settles.
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success');
});
it('omits the URL line for failed items without a resolvable url', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
showDownloadBatchSummary({
total: 2,
completed: 0,
failedItems: [
{ item: { name: 'WithUrl', url: 'https://example.com/with-url' }, error: 'boom' },
{ item: { name: 'NoUrl' }, error: 'boom' },
],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
const text = writeText.mock.calls[0][0];
expect(text).toContain(' URL: https://example.com/with-url');
// Only the one URL line exists — the URL-less item contributes none.
expect(text.match(/^\s+URL:/gm)).toHaveLength(1);
expect(text).not.toContain(' URL: undefined');
expect(text).not.toContain(' URL: null');
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
});
it('falls back to execCommand when navigator.clipboard is unavailable', async () => {
// afterEach deletes navigator.clipboard, but be explicit so this test is
// robust even if a previous test failed before its cleanup ran.
delete navigator.clipboard;
// jsdom does not implement document.execCommand, so install a mock for the
// fallback path (removed by the afterEach cleanup above).
const execCommandMock = vi.fn(() => true);
document.execCommand = execCommandMock;
showDownloadBatchSummary({
total: 3,
completed: 2,
failedItems: [
{ item: { displayName: 'LoraA' }, error: 'timeout' },
{ item: { name: 'LoraB' }, error: '404' },
],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
// Without the async Clipboard API the fallback must run synchronously.
expect(execCommandMock).toHaveBeenCalledWith('copy');
await Promise.resolve();
await Promise.resolve();
expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success');
});
it('keeps only a single modal instance across repeated calls', () => {
showDownloadBatchSummary({
total: 2,
completed: 1,
failedItems: [{ item: { name: 'A' }, error: 'e' }],
onRetry: vi.fn(),
});
showDownloadBatchSummary({ total: 3, completed: 3, failedItems: [], onRetry: vi.fn() });
expect(document.querySelectorAll('#downloadBatchSummaryModal')).toHaveLength(1);
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
});
it('resolves failure names from entry.name, item fields, URL paths, or Unknown', () => {
showDownloadBatchSummary({
total: 4,
completed: 0,
failedItems: [
{ name: 'entryName', item: { displayName: 'ItemName' }, error: 'e1' },
{ item: { selectedVersion: { name: 'v1.0' } }, error: 'e2' },
{ item: { url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960' }, error: 'e3' },
{ item: {}, error: 'e4' },
],
onRetry: vi.fn(),
});
const names = Array.from(document.querySelectorAll('.failure-name')).map(el => el.textContent);
expect(names).toEqual(['entryName', 'v1.0', 'midjourney-artful-nsfw', 'Unknown']);
});
it('formats the real JSON failure payload into a concise HTTP error and truncates long ones', () => {
showDownloadBatchSummary({
total: 2,
completed: 0,
failedItems: [
{ item: { name: 'EarlyAccess' }, error: REAL_ERROR },
{ item: { name: 'LongError' }, error: 'x'.repeat(300) },
],
onRetry: vi.fn(),
});
const errorCells = document.querySelectorAll('.failure-error');
expect(errorCells[0].textContent).toBe(FORMATTED_REAL_ERROR);
expect(errorCells[1].textContent).toBe('x'.repeat(220) + '…');
});
it('keeps the raw error string in the error cell title for debugging', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{ item: { name: 'EarlyAccess' }, error: REAL_ERROR }],
onRetry: vi.fn(),
});
const errorCell = document.querySelector('.failure-error');
expect(errorCell.getAttribute('title')).toBe(REAL_ERROR);
expect(errorCell.getAttribute('title')).not.toBe(FORMATTED_REAL_ERROR);
});
it('opens the original item url in a new tab when a failure link is clicked', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960',
modelId: '837884',
selectedVersion: { id: '3153960' },
},
error: 'rate limited',
}],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960');
// The modal stays open so the user can keep inspecting the failures.
expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
});
it('opens the item url directly when selectedVersion is absent', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId: '837884',
modelVersionId: '3153960',
url: 'https://civitai.red/models/837884/midjourney-artful-nsfw',
},
error: 'rate limited',
}],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw');
expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
});
it('opens the original huggingface url directly when a huggingface failure link is clicked', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
url: 'https://huggingface.co/user/repo',
source: 'huggingface',
repo: 'user/repo',
filename: 'model.safetensors',
revision: 'main',
},
error: 'download failed',
}],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://huggingface.co/user/repo');
expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
});
it('opens an arbitrary URL via openHuggingFace for fallback items', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{ item: { url: 'https://example.com/model' }, error: 'boom' }],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://example.com/model');
});
it('renders the failure name as plain text when no URL can be resolved', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{ item: { modelId: null }, error: 'boom' }],
onRetry: vi.fn(),
});
expect(document.querySelector('a.failure-link')).toBeNull();
expect(document.querySelector('.failure-name').textContent).toBe('Unknown');
// Without a link there is nothing to open: clicking the cell is inert.
document.querySelector('.failure-name').click();
expect(openHuggingFaceMock).not.toHaveBeenCalled();
});
it('copies formatted errors (not raw JSON) into the report text', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
name: 'EarlyAccess',
url: 'https://civitai.red/models/123/early-access?modelVersionId=456',
},
error: REAL_ERROR,
}],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
expect(writeText).toHaveBeenCalledTimes(1);
const text = writeText.mock.calls[0][0];
expect(text).toContain(FORMATTED_REAL_ERROR);
expect(text).toContain(' URL: https://civitai.red/models/123/early-access?modelVersionId=456');
expect(text).not.toContain('download_id');
expect(text).not.toContain('Failed to resolve authenticated Civitai redirect');
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
});
});
@@ -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', () => ({
@@ -0,0 +1,434 @@
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',
},
},
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(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
setStatus: 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,
}));
/**
* Fake WebSocket used by executeBatchDownload. Resolves `onopen` on the
* microtask queue right after construction (which happens after the real
* code has assigned `onopen`), so the open promise resolves deterministically
* without real timers.
*/
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
queueMicrotask(() => {
if (this.onopen) this.onopen();
});
}
static get lastInstance() {
return FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
}
}
describe('DownloadManager batch download summary flow', () => {
let DownloadManager;
let manager;
const options = { modelRoot: '/models/loras', targetFolder: '', useDefaultPaths: true };
const makeItem = (modelId, versionId, name) => ({
modelId,
displayName: name,
selectedVersion: { id: versionId, name, existsLocally: false },
});
const item0 = makeItem('111', 'v1', 'Model A');
const item1 = makeItem('222', 'v2', 'Model B');
beforeEach(async () => {
document.body.innerHTML = '';
FakeWebSocket.instances = [];
// Reset the shared mocks so mockResolvedValueOnce queues and call
// history never leak between tests.
mockApiClient.downloadModel.mockReset();
mockApiClient.downloadHfModel.mockReset();
mockApiClient.cancelDownload.mockReset();
showToastMock.mockClear();
showDownloadBatchSummaryMock.mockClear();
resetAndReloadMock.mockClear();
mockLoadingManager.hide.mockClear();
mockLoadingManager.setStatus.mockClear();
mockLoadingManager.showCancelButton.mockClear();
mockLoadingManager.showDownloadProgress.mockClear();
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.resetModules();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
manager = new DownloadManager();
// The constructor leaves apiClient null; executeBatchDownload reads it
// directly, so point it at the shared mocked client.
manager.apiClient = mockApiClient;
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('shows the success toast when every item downloads successfully', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: true });
await manager.executeBatchDownload([item0, item1], options);
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
// Each item is downloaded with its own modelId + versionId.
expect(mockApiClient.downloadModel.mock.calls[0][0]).toBe('111');
expect(mockApiClient.downloadModel.mock.calls[0][1]).toBe('v1');
expect(mockApiClient.downloadModel.mock.calls[1][0]).toBe('222');
expect(mockApiClient.downloadModel.mock.calls[1][1]).toBe('v2');
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', { count: 2 }, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('shows a partial-failure summary when some items fail', async () => {
// The failing item has no displayName/filename, so the resolved entry
// name falls back to the selected version name.
const unnamedItem = { modelId: '333', selectedVersion: { id: 'v3', name: 'V3', existsLocally: false } };
mockApiClient.downloadModel
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
.mockResolvedValueOnce({ success: true });
await manager.executeBatchDownload([unnamedItem, item1], options);
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].item).toBe(unnamedItem);
expect(summary.failedItems[0].error).toBe('rate limited');
// The resolved display name is carried on the failed entry.
expect(summary.failedItems[0].name).toBe('V3');
expect(summary.onRetry).toEqual(expect.any(Function));
// No success toast and no downloadPartialSuccess toast for this path.
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), 'success');
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadPartialSuccess', expect.anything(), expect.anything());
});
it('shows an all-failed summary when every item fails', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'x' });
await manager.executeBatchDownload([item0, item1], options);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(2);
expect(summary.completed).toBe(0);
expect(summary.failedItems).toHaveLength(2);
expect(summary.failedItems[0].item).toBe(item0);
expect(summary.failedItems[1].item).toBe(item1);
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), expect.anything());
});
it('records the error message when downloadModel rejects', async () => {
// The item carries a filename but no displayName, so the resolved entry
// name comes from the filename.
const filenameItem = { modelId: '444', filename: 'model.safetensors', selectedVersion: { id: 'v4' } };
mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));
await manager.executeBatchDownload([filenameItem], options);
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).toBe(filenameItem);
expect(summary.failedItems[0].error).toBe('network down');
expect(summary.failedItems[0].name).toBe('model.safetensors');
});
it('retries the failed subset through onRetry with unwrapped items', async () => {
mockApiClient.downloadModel
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
.mockResolvedValueOnce({ success: true });
await manager.executeBatchDownload([item0, item1], options);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.failedItems).toHaveLength(1);
// Retry the exact failed subset returned by the summary. The onRetry
// callback unwraps the { item, error } entries back into raw model items
// before re-running executeBatchDownload. Make the retried item fail
// again so a second summary is produced.
mockApiClient.downloadModel.mockResolvedValueOnce({ success: false, error: 'still rate limited' });
await summary.onRetry(summary.failedItems);
// downloadModel is called a third time — only for the failed item (item0),
// NOT for the item that already succeeded (item1).
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(3);
const retryCall = mockApiClient.downloadModel.mock.calls[2];
expect(retryCall[0]).toBe(item0.modelId);
expect(retryCall[1]).toBe(item0.selectedVersion.id);
// A fresh summary is produced for the retry run (call count 1 -> 2).
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(2);
const retrySummary = showDownloadBatchSummaryMock.mock.calls[1][0];
expect(retrySummary.total).toBe(1);
expect(retrySummary.completed).toBe(0);
expect(retrySummary.failedItems).toHaveLength(1);
expect(retrySummary.failedItems[0].item).toBe(item0);
expect(retrySummary.failedItems[0].error).toBe('still rate limited');
});
it('stops the batch without showing a summary when cancelled before downloads start', async () => {
const downloadPromise = manager.executeBatchDownload([item0, item1], options);
// showCancelButton captured the cancel callback synchronously. Invoking it
// sets `cancelled = true` before the download loop runs (the loop only
// starts after the WebSocket open promise resolves on the microtask queue).
const cancelCallback = mockLoadingManager.showCancelButton.mock.calls[0][0];
const cancelPromise = cancelCallback();
await Promise.all([downloadPromise, cancelPromise]);
expect(mockApiClient.downloadModel).not.toHaveBeenCalled();
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.downloads.downloadStopped',
expect.anything(),
'info',
expect.stringContaining('Download cancelled')
);
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);
});
});
+43
View File
@@ -86,6 +86,41 @@ def test_save_image_skips_png_parameters_when_metadata_disabled_and_keeps_workfl
assert img.info["workflow"] == json.dumps(workflow) assert img.info["workflow"] == json.dumps(workflow)
def test_save_image_does_not_append_loras_to_prompt_by_default(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(
monkeypatch,
{"prompt": "prompt text", "seed": 123, "loras": "<lora:foo:0.7>"},
)
node = SaveImageLM()
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
image_path = tmp_path / "sample_00001_.png"
with Image.open(image_path) as img:
assert "<lora:" not in img.info["parameters"]
assert img.info["parameters"] == "prompt text\nSeed: 123, Version: ComfyUI"
def test_save_image_appends_loras_to_prompt_when_enabled(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(
monkeypatch,
{"prompt": "prompt text", "seed": 123, "loras": "<lora:foo:0.7>"},
)
node = SaveImageLM()
node.save_images(
[_make_image()], "ComfyUI", "png", id="node-1", add_loras_to_prompt=True
)
image_path = tmp_path / "sample_00001_.png"
with Image.open(image_path) as img:
assert img.info["parameters"] == (
"prompt text\n<lora:foo:0.7>\nSeed: 123, Version: ComfyUI"
)
def test_save_image_skips_jpeg_metadata_when_disabled(monkeypatch, tmp_path): def test_save_image_skips_jpeg_metadata_when_disabled(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path) _configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123}) _configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
@@ -451,6 +486,14 @@ class TestParameterDefaultConsistency:
assert SaveImageLM.save_images.__defaults__[5] == 0 assert SaveImageLM.save_images.__defaults__[5] == 0
assert SaveImageLM.process_image.__defaults__[7] == 0 assert SaveImageLM.process_image.__defaults__[7] == 0
def test_add_loras_to_prompt_defaults_are_consistent(self):
input_types = SaveImageLM.INPUT_TYPES()
optional = input_types["optional"]
assert optional["add_loras_to_prompt"][1]["default"] is False
assert SaveImageLM.save_images.__defaults__[-1] is False
assert SaveImageLM.process_image.__defaults__[-1] is False
def test_png_does_not_pass_webp_method_or_jpeg_subsampling(monkeypatch, tmp_path): def test_png_does_not_pass_webp_method_or_jpeg_subsampling(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path) _configure_save_paths(monkeypatch, tmp_path)
+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
@@ -529,7 +529,8 @@ async def test_not_found_example_images_are_cleaned(
model_dir = images_root / model_hash model_dir = images_root / model_hash
model_dir.mkdir(parents=True, exist_ok=True) model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / "image_0.png").write_bytes(b"first") # Pre-existing file collides with the valid image index (1) so the
# pre-download existence check must skip it without a network request
(model_dir / "image_1.png").write_bytes(b"second") (model_dir / "image_1.png").write_bytes(b"second")
async def fake_process_local_examples(*_args, **_kwargs): async def fake_process_local_examples(*_args, **_kwargs):
@@ -608,11 +609,188 @@ async def test_not_found_example_images_are_cleaned(
] ]
files = sorted(p.name for p in model_dir.iterdir()) files = sorted(p.name for p in model_dir.iterdir())
assert files == ["image_0.png", "image_1.png"] assert files == ["image_1.png"]
assert (model_dir / "image_0.png").read_bytes() == b"first"
assert (model_dir / "image_1.png").read_bytes() == b"second" assert (model_dir / "image_1.png").read_bytes() == b"second"
async def test_failed_models_retried_when_explicitly_targeted(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
images_root = tmp_path / "examples"
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
model_hash = "a" * 64
model_path = tmp_path / "model.safetensors"
model_path.write_text("data", encoding="utf-8")
model_metadata = {
"sha256": model_hash,
"model_name": "Failed Example",
"file_path": str(model_path),
"file_name": "model.safetensors",
"civitai": {"images": [{"url": "https://example.com/valid.png"}]},
}
scanner = StubScanner([model_metadata.copy()])
_patch_scanner(monkeypatch, scanner)
# Persist a previous failure so the skip path is exercised
images_root.mkdir(parents=True, exist_ok=True)
(images_root / ".download_progress.json").write_text(
json.dumps(
{
"failed_models": [model_hash],
"processed_models": [],
"rate_limited_models": [],
}
),
encoding="utf-8",
)
async def fake_process_local_examples(*_args, **_kwargs):
return False
async def fake_get_updated_model(model_hash_arg, _scanner):
return model_metadata
class DownloaderStub:
def __init__(self):
self.calls: list[str] = []
async def download_to_memory(self, url, *_args, **_kwargs):
self.calls.append(url)
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
downloader = DownloaderStub()
async def fake_get_downloader():
return downloader
monkeypatch.setattr(
download_module.ExampleImagesProcessor,
"process_local_examples",
staticmethod(fake_process_local_examples),
)
monkeypatch.setattr(
download_module.MetadataUpdater,
"get_updated_model",
staticmethod(fake_get_updated_model),
)
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
# Without explicit hashes the previously failed model is skipped
skipped_manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
result = await skipped_manager.start_download({"model_types": ["lora"], "delay": 0})
assert result["success"] is True
if skipped_manager._download_task is not None:
await asyncio.wait_for(skipped_manager._download_task, timeout=1)
assert downloader.calls == []
# With explicit hashes the previously failed model is retried and cleared
result = await manager.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
)
assert result["success"] is True
if manager._download_task is not None:
await asyncio.wait_for(manager._download_task, timeout=1)
assert downloader.calls == ["https://example.com/valid.png"]
assert manager._progress["failed_models"] == set()
assert model_hash in manager._progress["processed_models"]
async def test_explicit_targets_fill_partial_example_gaps(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
images_root = tmp_path / "examples"
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
model_hash = "b" * 64
model_path = tmp_path / "model.safetensors"
model_path.write_text("data", encoding="utf-8")
model_metadata = {
"sha256": model_hash,
"model_name": "Partial Example",
"file_path": str(model_path),
"file_name": "model.safetensors",
"civitai": {
"images": [
{"url": "https://example.com/first.png"},
{"url": "https://example.com/second.png"},
]
},
}
scanner = StubScanner([model_metadata.copy()])
_patch_scanner(monkeypatch, scanner)
# Simulate a partially populated folder: index 0 already downloaded
model_dir = images_root / model_hash
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / "image_0.png").write_bytes(b"existing")
async def fake_process_local_examples(*_args, **_kwargs):
return False
async def fake_get_updated_model(model_hash_arg, _scanner):
return model_metadata
class DownloaderStub:
def __init__(self):
self.calls: list[str] = []
async def download_to_memory(self, url, *_args, **_kwargs):
self.calls.append(url)
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
downloader = DownloaderStub()
async def fake_get_downloader():
return downloader
monkeypatch.setattr(
download_module.ExampleImagesProcessor,
"process_local_examples",
staticmethod(fake_process_local_examples),
)
monkeypatch.setattr(
download_module.MetadataUpdater,
"get_updated_model",
staticmethod(fake_get_updated_model),
)
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
# Untargeted run treats the populated folder as done
untargeted = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
result = await untargeted.start_download({"model_types": ["lora"], "delay": 0})
assert result["success"] is True
if untargeted._download_task is not None:
await asyncio.wait_for(untargeted._download_task, timeout=1)
assert downloader.calls == []
# Explicitly targeted run fills only the missing index, skipping the
# existing file without a network request
targeted = download_module.DownloadManager(ws_manager=ws_manager)
result = await targeted.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
)
assert result["success"] is True
if targeted._download_task is not None:
await asyncio.wait_for(targeted._download_task, timeout=1)
assert downloader.calls == ["https://example.com/second.png"]
assert (model_dir / "image_1.png").exists()
assert (model_dir / "image_0.png").read_bytes() == b"existing"
@pytest.fixture @pytest.fixture
def settings_manager(): def settings_manager():
return get_settings_manager() return get_settings_manager()
+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",
]
+53
View File
@@ -860,6 +860,59 @@ def test_set_recipes_path_rewrites_symlinked_recipe_metadata(manager, tmp_path):
assert not old_json_path.exists() assert not old_json_path.exists()
def test_set_recipes_path_allows_cross_drive_migration(manager, tmp_path, monkeypatch):
# Windows regression: os.path.commonpath raises ValueError for paths on
# different drives (ntpath semantics). Cross-drive moves must succeed.
lora_root = tmp_path / "loras"
old_recipes_dir = lora_root / "recipes" / "nested"
old_recipes_dir.mkdir(parents=True)
manager.set("folder_paths", {"loras": [str(lora_root)]})
recipe_id = "recipe-cross-drive"
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
old_image_path.write_bytes(b"image-bytes")
old_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": str(old_image_path),
"title": "Recipe Cross Drive",
}
),
encoding="utf-8",
)
new_recipes_dir = tmp_path / "N_drive" / "AI" / "Library" / "Recipes"
# The effective current recipes dir (source of the migration) is
# lora_root/recipes — the nested subdirectory holds the recipe files.
source = str(lora_root / "recipes")
target = str(new_recipes_dir)
real_commonpath = os.path.commonpath
def fake_commonpath(paths):
# Simulate ntpath on Windows: a source/target pair on different
# drives shares no common root and raises ValueError.
if {source, target} <= set(paths):
raise ValueError("Paths don't have the same drive")
return real_commonpath(paths)
monkeypatch.setattr(os.path, "commonpath", fake_commonpath)
manager.set("recipes_path", str(new_recipes_dir))
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
assert migrated_image_path.read_bytes() == b"image-bytes"
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
assert migrated_payload["file_path"] == str(migrated_image_path)
assert not old_image_path.exists()
assert not old_json_path.exists()
def test_set_recipes_path_rejects_file_target(manager, tmp_path): def test_set_recipes_path_rejects_file_target(manager, tmp_path):
lora_root = tmp_path / "loras" lora_root = tmp_path / "loras"
lora_root.mkdir() lora_root.mkdir()
@@ -63,7 +63,7 @@ async def test_start_download_bootstraps_progress_and_task(
release = asyncio.Event() release = asyncio.Event()
async def fake_download( async def fake_download(
self, output_dir, optimize, model_types, delay, library_name, force=False self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
): ):
started.set() started.set()
await release.wait() await release.wait()
@@ -93,6 +93,44 @@ async def test_start_download_bootstraps_progress_and_task(
assert manager._progress["status"] == "completed" assert manager._progress["status"] == "completed"
async def test_start_download_forwards_model_hashes(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["libraries"] = {"default": {}}
settings_manager.settings["active_library"] = "default"
manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
received: Dict[str, Any] = {}
async def fake_download(
self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
):
received["model_hashes"] = model_hashes
async with self._state_lock:
self._is_downloading = False
self._download_task = None
self._progress["status"] = "completed"
monkeypatch.setattr(
download_module.DownloadManager,
"_download_all_example_images",
fake_download,
)
result = await manager.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": ["abc123", "def456"]}
)
assert result["success"] is True
task = manager._download_task
assert task is not None
await asyncio.wait_for(task, timeout=1)
assert received["model_hashes"] == ["abc123", "def456"]
async def test_pause_and_resume_flow(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: async def test_pause_and_resume_flow(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path) settings_manager.settings["example_images_path"] = str(tmp_path)
@@ -100,6 +100,54 @@ def test_get_file_extension_media_type_hint_low_priority() -> None:
assert ext == ".mp4" assert ext == ".mp4"
def test_example_image_file_exists_checks_plausible_extensions(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
assert proc._example_image_file_exists(str(tmp_path), 0) is False
Path(tmp_path, "image_0.webp").write_bytes(b"x")
assert proc._example_image_file_exists(str(tmp_path), 0) is True
assert proc._example_image_file_exists(str(tmp_path), 1) is False
def test_example_image_file_exists_video_hint_only_checks_video_extensions(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
Path(tmp_path, "image_2.jpg").write_bytes(b"x")
# An existing image file must not satisfy a video-hinted lookup
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is False
Path(tmp_path, "image_2.mp4").write_bytes(b"x")
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is True
async def test_download_model_images_with_tracking_skips_existing_files(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
images = [
{"url": "https://image.civitai.com/a/b", "type": "image"},
{"url": "https://image.civitai.com/c/d", "type": "image"},
]
Path(tmp_path, "image_0.jpg").write_bytes(b"existing")
class RecordingDownloader:
def __init__(self) -> None:
self.calls: list[str] = []
async def download_to_memory(self, url, use_auth=False, return_headers=False):
self.calls.append(url)
return True, b"\xff\xd8\xff" + b"data", {}
downloader = RecordingDownloader()
success, is_stale, failed, rate_limited = await proc.download_model_images_with_tracking(
"hash", "model", images, str(tmp_path), False, downloader
)
assert success is True
assert is_stale is False
assert failed == []
assert rate_limited == []
# Only the missing image is requested; the existing one is skipped without a network call
assert len(downloader.calls) == 1
assert "c/d" in downloader.calls[0]
assert Path(tmp_path, "image_1.jpg").exists()
class StubScanner: class StubScanner:
def __init__(self, models: list[Dict[str, Any]]) -> None: def __init__(self, models: list[Dict[str, Any]]) -> None:
self._cache = SimpleNamespace(raw_data=models) self._cache = SimpleNamespace(raw_data=models)
+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
@@ -45,7 +45,7 @@ export interface AutocompleteTextWidgetInterface {
const props = defineProps<{ const props = defineProps<{
widget: AutocompleteTextWidgetInterface widget: AutocompleteTextWidgetInterface
node: { id: number } node: { id: number }
modelType?: 'loras' | 'embeddings' | 'custom_words' | 'prompt' modelType?: 'loras' | 'prompt'
placeholder?: string placeholder?: string
showPreview?: boolean showPreview?: boolean
spellcheck?: boolean spellcheck?: boolean
@@ -98,7 +98,7 @@ interface LoraInfoWidget {
onSetValue?: (v: unknown) => void onSetValue?: (v: unknown) => void
callback?: unknown callback?: unknown
options?: { options?: {
getValue?: () => LoraInfoWidgetValue getValue?: () => unknown
setValue?: (v: unknown) => void setValue?: (v: unknown) => void
} }
node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> } node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> }
@@ -299,8 +299,12 @@ onMounted(() => {
// ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue. // ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue.
// These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work. // These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work.
if (props.widget.options) {
props.widget.options.getValue = buildValue props.widget.options.getValue = buildValue
props.widget.options.setValue = applyValue props.widget.options.setValue = applyValue
} else {
console.warn('[LoraInfoWidget] widget.options missing, value persistence disabled')
}
// Also set serializeValue for prompt/API serialization path (executionUtil.ts) // Also set serializeValue for prompt/API serialization path (executionUtil.ts)
props.widget.serializeValue = async () => buildValue() props.widget.serializeValue = async () => buildValue()
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, type Ref } from 'vue'
// Dynamic import type for AutoComplete class // Dynamic import type for AutoComplete class
type AutoCompleteClass = new ( type AutoCompleteClass = new (
inputElement: HTMLTextAreaElement, inputElement: HTMLTextAreaElement,
modelType: 'loras' | 'embeddings' | 'custom_words' | 'prompt', modelType: 'loras' | 'prompt',
options?: AutocompleteOptions options?: AutocompleteOptions
) => AutoCompleteInstance ) => AutoCompleteInstance
@@ -29,7 +29,7 @@ export interface UseAutocompleteOptions {
export function useAutocomplete( export function useAutocomplete(
textareaRef: Ref<HTMLTextAreaElement | null>, textareaRef: Ref<HTMLTextAreaElement | null>,
modelType: 'loras' | 'embeddings' | 'custom_words' | 'prompt' = 'loras', modelType: 'loras' | 'prompt' = 'loras',
options: UseAutocompleteOptions = {} options: UseAutocompleteOptions = {}
) { ) {
const autocompleteInstance = ref<AutoCompleteInstance | null>(null) const autocompleteInstance = ref<AutoCompleteInstance | null>(null)
+6 -9
View File
@@ -36,6 +36,9 @@ const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300
const AUTOCOMPLETE_METADATA_VERSION = 1 const AUTOCOMPLETE_METADATA_VERSION = 1
const LORA_MANAGER_WIDGET_IDS_PROPERTY = '__lm_widget_ids' const LORA_MANAGER_WIDGET_IDS_PROPERTY = '__lm_widget_ids'
// Access LiteGraph global for Vue DOM mode detection (matches AutocompleteTextWidget.vue)
declare const LiteGraph: { vueNodesMode?: boolean } | undefined
// @ts-ignore - ComfyUI external module // @ts-ignore - ComfyUI external module
import { app } from '../../../scripts/app.js' import { app } from '../../../scripts/app.js'
// @ts-ignore - ComfyUI external module // @ts-ignore - ComfyUI external module
@@ -718,7 +721,7 @@ function createLoraInfoWidget(node: any) {
function createAutocompleteTextWidgetFactory( function createAutocompleteTextWidgetFactory(
node: any, node: any,
widgetName: string, widgetName: string,
modelType: 'loras' | 'embeddings' | 'prompt', modelType: 'loras' | 'prompt',
inputOptions: { placeholder?: string } = {} inputOptions: { placeholder?: string } = {}
) { ) {
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}` const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
@@ -835,7 +838,7 @@ function createAutocompleteTextWidgetFactory(
applyAutocompleteTextLayoutFix( applyAutocompleteTextLayoutFix(
widget, widget,
container, container,
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true
) )
} }
@@ -964,13 +967,7 @@ app.registerExtension({
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {} const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
return createAutocompleteTextWidgetFactory(node, 'text', 'loras', options) return createAutocompleteTextWidgetFactory(node, 'text', 'loras', options)
}, },
// Autocomplete text widget for embeddings (used by Prompt node) // Autocomplete text widget for prompt (used by Prompt and Text nodes)
// @ts-ignore
AUTOCOMPLETE_TEXT_EMBEDDINGS(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
return createAutocompleteTextWidgetFactory(node, 'text', 'embeddings', options)
},
// Autocomplete text widget for prompt (supports both embeddings and custom words)
// @ts-ignore // @ts-ignore
AUTOCOMPLETE_TEXT_PROMPT(node) { AUTOCOMPLETE_TEXT_PROMPT(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {} const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
+200 -14
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,6 +2082,7 @@ 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();
@@ -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,
}; };
+62 -64
View File
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0; padding: 20px 0;
} }
.autocomplete-text-widget[data-v-3f3d7a1a] { .autocomplete-text-widget[data-v-55e3316e] {
background: transparent; background: transparent;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
} }
.input-wrapper[data-v-3f3d7a1a] { .input-wrapper[data-v-55e3316e] {
position: relative; position: relative;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
} }
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */ /* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-3f3d7a1a] { .text-input[data-v-55e3316e] {
flex: 1; flex: 1;
width: 100%; width: 100%;
background-color: var(--comfy-input-bg, #222); background-color: var(--comfy-input-bg, #222);
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
} }
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */ /* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-3f3d7a1a] { .text-input.vue-dom-mode[data-v-55e3316e] {
background-color: var(--color-charcoal-400, #313235); background-color: var(--color-charcoal-400, #313235);
color: #fff; color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */ padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2161,12 +2161,12 @@ to { transform: rotate(360deg);
font-size: 12px; font-size: 12px;
font-family: inherit; font-family: inherit;
} }
.text-input[data-v-3f3d7a1a]:focus { .text-input[data-v-55e3316e]:focus {
outline: none; outline: none;
} }
/* Clear button styles */ /* Clear button styles */
.clear-button[data-v-3f3d7a1a] { .clear-button[data-v-55e3316e] {
position: absolute; position: absolute;
right: 6px; right: 6px;
bottom: 6px; /* Changed from top to bottom */ bottom: 6px; /* Changed from top to bottom */
@@ -2189,31 +2189,31 @@ to { transform: rotate(360deg);
} }
/* Show clear button when hovering over input wrapper */ /* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-3f3d7a1a] { .input-wrapper:hover .clear-button[data-v-55e3316e] {
opacity: 0.7; opacity: 0.7;
pointer-events: auto; pointer-events: auto;
} }
.clear-button[data-v-3f3d7a1a]:hover { .clear-button[data-v-55e3316e]:hover {
opacity: 1; opacity: 1;
background: rgba(255, 100, 100, 0.8); background: rgba(255, 100, 100, 0.8);
} }
.clear-button svg[data-v-3f3d7a1a] { .clear-button svg[data-v-55e3316e] {
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
/* Vue DOM mode adjustments for clear button */ /* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a] { .text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e] {
right: 8px; right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */ bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px; width: 20px;
height: 20px; height: 20px;
background: rgba(107, 114, 128, 0.6); background: rgba(107, 114, 128, 0.6);
} }
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a]:hover { .text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e]:hover {
background: oklch(62% 0.18 25); background: oklch(62% 0.18 25);
} }
.text-input.vue-dom-mode ~ .clear-button svg[data-v-3f3d7a1a] { .text-input.vue-dom-mode ~ .clear-button svg[data-v-55e3316e] {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
@@ -2224,7 +2224,7 @@ to { transform: rotate(360deg);
resize: vertical !important; resize: vertical !important;
} }
.lora-info-widget[data-v-a99cc1ab] { .lora-info-widget[data-v-d7692b6f] {
padding: 12px; padding: 12px;
background: rgba(40, 44, 52, 0.6); background: rgba(40, 44, 52, 0.6);
border-radius: 4px; border-radius: 4px;
@@ -2240,45 +2240,45 @@ to { transform: rotate(360deg);
determined solely by CSS not by descendant content. This breaks the determined solely by CSS not by descendant content. This breaks the
feedback loop where content grows ResizeObserver resizes content feedback loop where content grows ResizeObserver resizes content
reflows repeat. Same technique used by tags_widget.js + lm_styles.css. */ reflows repeat. Same technique used by tags_widget.js + lm_styles.css. */
.lora-info-widget.lm-vue-node[data-v-a99cc1ab] { .lora-info-widget.lm-vue-node[data-v-d7692b6f] {
contain: layout size; contain: layout size;
} }
/* ── Tab bar ── */ /* ── Tab bar ── */
.lora-info-tabs[data-v-a99cc1ab] { .lora-info-tabs[data-v-d7692b6f] {
display: flex; display: flex;
gap: 0; gap: 0;
margin-bottom: 10px; margin-bottom: 10px;
border-bottom: 1px solid var(--border-color, #444); border-bottom: 1px solid var(--border-color, #444);
flex-shrink: 0; flex-shrink: 0;
} }
.lora-info-tab[data-v-a99cc1ab] { .lora-info-tab[data-v-d7692b6f] {
flex: 1; flex: 1;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
padding: 6px 0; padding: 6px 0;
position: relative; position: relative;
} }
.lora-info-tab-input[data-v-a99cc1ab] { .lora-info-tab-input[data-v-d7692b6f] {
position: absolute; position: absolute;
opacity: 0; opacity: 0;
width: 0; width: 0;
height: 0; height: 0;
} }
.lora-info-tab-label[data-v-a99cc1ab] { .lora-info-tab-label[data-v-d7692b6f] {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
opacity: 0.5; opacity: 0.5;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.lora-info-tab:hover .lora-info-tab-label[data-v-a99cc1ab] { .lora-info-tab:hover .lora-info-tab-label[data-v-d7692b6f] {
opacity: 0.75; opacity: 0.75;
} }
.lora-info-tab.active .lora-info-tab-label[data-v-a99cc1ab] { .lora-info-tab.active .lora-info-tab-label[data-v-d7692b6f] {
opacity: 1; opacity: 1;
} }
.lora-info-tab.active[data-v-a99cc1ab]::after { .lora-info-tab.active[data-v-d7692b6f]::after {
content: ''; content: '';
position: absolute; position: absolute;
bottom: -1px; bottom: -1px;
@@ -2290,16 +2290,16 @@ to { transform: rotate(360deg);
} }
/* ── Tab content ── */ /* ── Tab content ── */
.tab-content[data-v-a99cc1ab] { .tab-content[data-v-d7692b6f] {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.notes-tab[data-v-a99cc1ab] { .notes-tab[data-v-d7692b6f] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.description-tab[data-v-a99cc1ab] { .description-tab[data-v-d7692b6f] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
@@ -2307,12 +2307,12 @@ to { transform: rotate(360deg);
} }
/* ── Info fields (shared) ── */ /* ── Info fields (shared) ── */
.info-field[data-v-a99cc1ab] { .info-field[data-v-d7692b6f] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.info-label[data-v-a99cc1ab] { .info-label[data-v-d7692b6f] {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
@@ -2320,7 +2320,7 @@ to { transform: rotate(360deg);
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
opacity: 0.6; opacity: 0.6;
} }
.lora-filename[data-v-a99cc1ab] { .lora-filename[data-v-d7692b6f] {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--fg-color, #fff); color: var(--fg-color, #fff);
@@ -2331,11 +2331,11 @@ to { transform: rotate(360deg);
user-select: text; user-select: text;
-webkit-user-select: text; -webkit-user-select: text;
} }
.notes-field[data-v-a99cc1ab] { .notes-field[data-v-d7692b6f] {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.lora-notes[data-v-a99cc1ab] { .lora-notes[data-v-d7692b6f] {
width: 100%; width: 100%;
flex: 1; flex: 1;
min-height: 60px; min-height: 60px;
@@ -2350,14 +2350,14 @@ to { transform: rotate(360deg);
font-family: inherit; font-family: inherit;
outline: none; outline: none;
} }
.lora-notes[data-v-a99cc1ab]:focus { .lora-notes[data-v-d7692b6f]:focus {
border-color: var(--comfy-input-border, #444); border-color: var(--comfy-input-border, #444);
} }
.lora-notes[data-v-a99cc1ab]:disabled { .lora-notes[data-v-d7692b6f]:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.save-btn[data-v-a99cc1ab] { .save-btn[data-v-d7692b6f] {
width: 100%; width: 100%;
margin-top: 8px; margin-top: 8px;
padding: 6px 12px; padding: 6px 12px;
@@ -2371,11 +2371,11 @@ to { transform: rotate(360deg);
box-sizing: border-box; box-sizing: border-box;
flex-shrink: 0; flex-shrink: 0;
} }
.save-btn[data-v-a99cc1ab]:hover:not(:disabled) { .save-btn[data-v-d7692b6f]:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.25); background: rgba(66, 153, 225, 0.25);
border-color: rgba(66, 153, 225, 0.6); border-color: rgba(66, 153, 225, 0.6);
} }
.save-btn[data-v-a99cc1ab]:disabled { .save-btn[data-v-d7692b6f]:disabled {
opacity: 0.4; opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
background: rgba(66, 153, 225, 0.05); background: rgba(66, 153, 225, 0.05);
@@ -2383,7 +2383,7 @@ to { transform: rotate(360deg);
} }
/* ── Description states ── */ /* ── Description states ── */
.description-state[data-v-a99cc1ab] { .description-state[data-v-d7692b6f] {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -2395,22 +2395,22 @@ to { transform: rotate(360deg);
min-height: 0; min-height: 0;
flex-shrink: 0; flex-shrink: 0;
} }
.description-state.error[data-v-a99cc1ab] { .description-state.error[data-v-d7692b6f] {
opacity: 0.7; opacity: 0.7;
color: #f87171; color: #f87171;
} }
/* ── Description content ── */ /* ── Description content ── */
.description-content[data-v-a99cc1ab] { .description-content[data-v-d7692b6f] {
min-height: 0; min-height: 0;
} }
.description-section[data-v-a99cc1ab] { .description-section[data-v-d7692b6f] {
margin-bottom: 14px; margin-bottom: 14px;
} }
.description-section[data-v-a99cc1ab]:last-child { .description-section[data-v-d7692b6f]:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.description-text[data-v-a99cc1ab] { .description-text[data-v-d7692b6f] {
padding: 8px 0; padding: 8px 0;
font-size: 12px; font-size: 12px;
line-height: 1.5; line-height: 1.5;
@@ -2422,41 +2422,41 @@ to { transform: rotate(360deg);
user-select: text; user-select: text;
-webkit-user-select: text; -webkit-user-select: text;
} }
.description-text[data-v-a99cc1ab] p { .description-text[data-v-d7692b6f] p {
margin: 0 0 8px 0; margin: 0 0 8px 0;
} }
.description-text[data-v-a99cc1ab] p:last-child { .description-text[data-v-d7692b6f] p:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.description-text[data-v-a99cc1ab] a { .description-text[data-v-d7692b6f] a {
color: rgba(66, 153, 225, 0.9); color: rgba(66, 153, 225, 0.9);
} }
.description-text[data-v-a99cc1ab] ul, .description-text[data-v-d7692b6f] ul,
.description-text[data-v-a99cc1ab] ol { .description-text[data-v-d7692b6f] ol {
padding-left: 20px; padding-left: 20px;
margin: 4px 0; margin: 4px 0;
} }
.description-text[data-v-a99cc1ab] h1, .description-text[data-v-d7692b6f] h1,
.description-text[data-v-a99cc1ab] h2, .description-text[data-v-d7692b6f] h2,
.description-text[data-v-a99cc1ab] h3 { .description-text[data-v-d7692b6f] h3 {
font-size: 13px; font-size: 13px;
margin: 10px 0 4px 0; margin: 10px 0 4px 0;
font-weight: 600; font-weight: 600;
opacity: 0.95; opacity: 0.95;
} }
.description-text[data-v-a99cc1ab] code { .description-text[data-v-d7692b6f] code {
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
padding: 1px 4px; padding: 1px 4px;
border-radius: 3px; border-radius: 3px;
font-size: 11px; font-size: 11px;
} }
.description-text[data-v-a99cc1ab] img { .description-text[data-v-d7692b6f] img {
max-width: 100%; max-width: 100%;
border-radius: 4px; border-radius: 4px;
} }
/* ── Placeholder (shared) ── */ /* ── Placeholder (shared) ── */
.placeholder[data-v-a99cc1ab] { .placeholder[data-v-d7692b6f] {
font-style: italic; font-style: italic;
color: rgba(226, 232, 240, 0.5); color: rgba(226, 232, 240, 0.5);
text-align: center; text-align: center;
@@ -2465,10 +2465,10 @@ to { transform: rotate(360deg);
} }
/* ── Spinner (Font Awesome) ── */ /* ── Spinner (Font Awesome) ── */
.fa-spinner[data-v-a99cc1ab] { .fa-spinner[data-v-d7692b6f] {
animation: fa-spin-a99cc1ab 1s linear infinite; animation: fa-spin-d7692b6f 1s linear infinite;
} }
@keyframes fa-spin-a99cc1ab { @keyframes fa-spin-d7692b6f {
0% { transform: rotate(0deg); 0% { transform: rotate(0deg);
} }
100% { transform: rotate(360deg); 100% { transform: rotate(360deg);
@@ -15316,7 +15316,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
}; };
} }
}); });
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-3f3d7a1a"]]); const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-55e3316e"]]);
const _hoisted_1 = { class: "lora-info-tabs" }; const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" }; const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" }; const _hoisted_3 = { class: "info-field" };
@@ -15511,8 +15511,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
if (data.filePath !== void 0) filePath.value = data.filePath; if (data.filePath !== void 0) filePath.value = data.filePath;
} }
}; };
if (props.widget.options) {
props.widget.options.getValue = buildValue; props.widget.options.getValue = buildValue;
props.widget.options.setValue = applyValue; props.widget.options.setValue = applyValue;
} else {
console.warn("[LoraInfoWidget] widget.options missing, value persistence disabled");
}
props.widget.serializeValue = async () => buildValue(); props.widget.serializeValue = async () => buildValue();
props.widget.onSetValue = applyValue; props.widget.onSetValue = applyValue;
const widgetIndex = (_b = (_a2 = props.widget.node) == null ? void 0 : _a2.widgets) == null ? void 0 : _b.findIndex( const widgetIndex = (_b = (_a2 = props.widget.node) == null ? void 0 : _a2.widgets) == null ? void 0 : _b.findIndex(
@@ -15641,7 +15645,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
}; };
} }
}); });
const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a99cc1ab"]]); const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d7692b6f"]]);
function createVueWidgetCleanup(vueApp, onCleanup) { function createVueWidgetCleanup(vueApp, onCleanup) {
let didUnmount = false; let didUnmount = false;
return () => { return () => {
@@ -16637,7 +16641,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
applyAutocompleteTextLayoutFix( applyAutocompleteTextLayoutFix(
widget, widget,
container, container,
typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode === true
); );
} }
const vueCleanup = createVueWidgetCleanup(vueApp, () => { const vueCleanup = createVueWidgetCleanup(vueApp, () => {
@@ -16747,13 +16751,7 @@ app$1.registerExtension({
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}; const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
return createAutocompleteTextWidgetFactory(node, "text", "loras", options); return createAutocompleteTextWidgetFactory(node, "text", "loras", options);
}, },
// Autocomplete text widget for embeddings (used by Prompt node) // Autocomplete text widget for prompt (used by Prompt and Text nodes)
// @ts-ignore
AUTOCOMPLETE_TEXT_EMBEDDINGS(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
return createAutocompleteTextWidgetFactory(node, "text", "embeddings", options);
},
// Autocomplete text widget for prompt (supports both embeddings and custom words)
// @ts-ignore // @ts-ignore
AUTOCOMPLETE_TEXT_PROMPT(node) { AUTOCOMPLETE_TEXT_PROMPT(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}; const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
File diff suppressed because one or more lines are too long