Compare commits

..

18 Commits

Author SHA1 Message Date
Will Miao
ceeab0c998 feat: add configurable mature blur threshold setting
Add new setting 'mature_blur_level' with options PG13/R/X/XXX to control
which NSFW rating level triggers blur filtering when NSFW blur is enabled.

- Backend: update preview selection logic to respect threshold
- Frontend: update UI components to use configurable threshold
- Settings: add validation and normalization for mature_blur_level
- Tests: add coverage for new threshold behavior
- Translations: add keys for all supported languages

Fixes #867
2026-03-26 18:24:47 +08:00
Will Miao
3b001a6cd8 fix(tests): update tests to match current download implementation
- Remove calculate_sha256 mocking from download_manager tests since
  SHA256 now comes from API metadata (not recalculated during download)
- Update chunk_size assertion from 4MB to 16MB in downloader config test
2026-03-26 18:00:04 +08:00
Will Miao
95e5bc26d1 feat: Add bulk download missing LoRAs feature for recipes
- Add BulkMissingLoraDownloadManager.js for handling bulk LoRA downloads
- Add context menu item to bulk mode for downloading missing LoRAs
- Add confirmation modal with deduplicated LoRA list preview
- Implement sequential downloading with WebSocket progress updates
- Fix CSS class naming conflicts to avoid import-modal.css collision
- Update translations for 9 languages (en, zh-CN, zh-TW, ja, ko, ru, de, fr, es, he)
- Style modal without internal scrolling for better UX
2026-03-26 17:46:53 +08:00
Will Miao
de3d0571f8 fix: verify returned image ID matches requested ID in CivitAI API
Fix issue #870 where importing recipes from CivitAI image URLs would
return the wrong image when the API response did not contain the
requested image ID.

The get_image_info() method now:
- Iterates through all returned items to find matching ID
- Returns None when no match is found and logs warning with returned IDs
- Handles invalid (non-numeric) ID formats

New test cases:
- test_get_image_info_returns_matching_item
- test_get_image_info_returns_none_when_id_mismatch
- test_get_image_info_handles_invalid_id

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 20:37:51 +08:00
Will Miao
6f2a01dc86 优化下载性能:移除 SHA256 计算并使用 16MB chunks
- 移除下载后的 SHA256 计算,直接使用 API 返回的 hash 值
- 将 chunk size 从 4MB 调整为 16MB,减少 75% 的 I/O 操作
- 这有助于缓解 ComfyUI 执行期间的卡顿问题
2026-03-25 19:29:48 +08:00
Will Miao
c5c1b8fd2a Fix: border corner clipping in duplicate recipe warning
Fix the bottom corners of duplicate warning border being clipped
due to parent container overflow:hidden and mismatched border-radius.

- Changed border-radius from top-only to all corners
- Ensures yellow border displays fully without being cut off
2026-03-25 13:57:38 +08:00
Will Miao
e97648c70b feat(import): add import-only option for recipes without downloading missing LoRAs
Add dual-button design in recipe import flow:
- Details step: [Import Recipe Only] [Import & Download]
- Location step: [Back] [Import & Download] (removed redundant Import Only)

Changes:
- templates/components/import_modal.html: Add secondary button for import-only
- static/js/managers/ImportManager.js: Add saveRecipeOnlyFromDetails() method
- static/js/managers/import/RecipeDataManager.js: Update button state management
- static/js/managers/import/DownloadManager.js: Support skipDownload flag
- locales/*.json: Complete all translation TODOs

Closes #868
2026-03-25 11:56:34 +08:00
Will Miao
8b85e083e2 feat(recipe-parser): add SuiImage metadata format support
- Add SuiImageParamsParser for sui_image_params JSON format
- Register new parser in RecipeParserFactory
- Fix metadata_provider auto-initialization when not ready
- Add 10 test cases for SuiImageParamsParser

Fixes batch import failure for images with sui_image_params metadata.
2026-03-25 08:43:33 +08:00
Will Miao
9112cd3b62 chore: Add .claude/ to gitignore
Exclude Claude Code personal configuration directory containing:
- settings.local.json (personal permissions and local paths)
- skills/ (personal skills)

These contain machine-specific paths and personal preferences
that should not be shared across the team.
2026-03-22 14:17:15 +08:00
Will Miao
7df4e8d037 fix(metadata_hook): correct function signature to fix bound method error
Fix issue #866 where the metadata hook's async wrapper used *args/**kwargs
which caused AttributeError when ComfyUI's make_locked_method_func tried
to access __func__ on the func parameter.

The async_map_node_over_list_with_metadata wrapper now uses the exact
same signature as ComfyUI's _async_map_node_over_list:
- Removed: *args, **kwargs
- Added: explicit v3_data=None parameter

This ensures the func parameter (always a string like obj.FUNCTION) is
passed correctly to make_locked_method_func without any type conversion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:25:04 +08:00
Will Miao
4000b7f7e7 feat: Add configurable LoRA strength adjustment step setting
Implements issue #808 - Allow users to customize the strength
variation range for LoRA widget arrow buttons.

Changes:
- Add 'Strength Adjustment Step' setting (0.01-0.1) in settings.js
- Replace hardcoded 0.05 increments with configurable step value
- Apply to both LoRA strength and CLIP strength controls

Fixes #808
2026-03-19 17:33:18 +08:00
Will Miao
76c15105e6 feat(lora-pool): add regex include/exclude name pattern filtering (#839)
Add name pattern filtering to LoRA Pool node allowing users to filter
LoRAs by filename or model name using either plain text or regex patterns.

Features:
- Include patterns: only show LoRAs matching at least one pattern
- Exclude patterns: exclude LoRAs matching any pattern
- Regex toggle: switch between substring and regex matching
- Case-insensitive matching for both modes
- Invalid regex automatically falls back to substring matching
- Filters apply to both file_name and model_name fields

Backend:
- Update LoraPoolLM._default_config() with namePatterns structure
- Add name pattern filtering to _apply_pool_filters() and _apply_specific_filters()
- Add API parameter parsing for name_pattern_include/exclude/use_regex
- Update LoraPoolConfig type with namePatterns field

Frontend:
- Add NamePatternsSection.vue component with pattern input UI
- Update useLoraPoolState to manage pattern state and API integration
- Update LoraPoolSummaryView to display NamePatternsSection
- Increase LORA_POOL_WIDGET_MIN_HEIGHT to accommodate new UI

Tests:
- Add 7 test cases covering text/regex include, exclude, combined
  filtering, model name fallback, and invalid regex handling

Closes #839
2026-03-19 17:15:05 +08:00
Will Miao
b11c90e19b feat: add type ignore comments and remove unused imports
- Add `# type: ignore` comments to comfy.sd and folder_paths imports
- Remove unused imports: os, random, and extract_lora_name
- Clean up import statements across checkpoint_loader, lora_randomizer, and unet_loader nodes
2026-03-19 15:54:49 +08:00
pixelpaws
9f5d2d0c18 Merge pull request #862 from EnragedAntelope/claude/add-webp-image-support-t8kG9
Improve webp image support
2026-03-19 15:35:16 +08:00
Will Miao
a0dc5229f4 feat(unet_loader): move torch import inside methods for lazy loading
- Delay torch import until needed in load_unet and load_unet_gguf methods
- This improves module loading performance by avoiding unnecessary imports
- Maintains functionality while reducing initial import overhead
2026-03-19 15:29:41 +08:00
EnragedAntelope
a32325402e Merge branch 'willmiao:main' into claude/add-webp-image-support-t8kG9 2026-03-17 08:37:46 -04:00
Claude
05ebd7493d chore: update package-lock.json after npm install
https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:33:34 +00:00
Claude
90986bd795 feat: add case-insensitive webp support for lora cover photos
Make preview file discovery case-insensitive so files with uppercase
extensions like .WEBP are found on case-sensitive filesystems. Also
explicitly list image/webp in the file picker accept attribute for
broader browser compatibility.

https://claude.ai/code/session_01SgT2pkisi27bEQELX5EeXZ
2026-03-17 01:32:48 +00:00
75 changed files with 3751 additions and 1048 deletions

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ model_cache/
# agent
.opencode/
.claude/
# Vue widgets development cache (but keep build output)
vue-widgets/node_modules/

View File

@@ -179,6 +179,8 @@ Insomnia Art Designs, megakirbs, Brennok, wackop, 2018cfh, Takkan, stone9k, $Met
- Context menu for quick actions
- Custom notes and usage tips
- Multi-folder support
- Configurable mature blur threshold (`PG13` / `R` / `X` / `XXX`, default `R+`)
- Example: setting threshold to `PG13` blurs `PG13`, `R`, `X`, and `XXX` previews when blur is enabled
- Visual progress indicators during initialization
---

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW-Inhalte unscharf stellen",
"blurNsfwContentHelp": "Nicht jugendfreie (NSFW) Vorschaubilder unscharf stellen",
"showOnlySfw": "Nur SFW-Ergebnisse anzeigen",
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern"
"showOnlySfwHelp": "Alle NSFW-Inhalte beim Durchsuchen und Suchen herausfiltern",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "Videos bei Hover automatisch abspielen",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"deleteAll": "Alle Modelle löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle",
"resumeMetadataRefreshCount": "Fortsetzen{count} Modelle",
@@ -645,6 +654,8 @@
"root": "Stammverzeichnis",
"browseFolders": "Ordner durchsuchen:",
"downloadAndSaveRecipe": "Herunterladen & Rezept speichern",
"importRecipeOnly": "Nur Rezept importieren",
"importAndDownload": "Importieren & Herunterladen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen",
"saveRecipe": "Rezept speichern",
"loraCountInfo": "({existing}/{total} in Bibliothek)",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "Basis-Modell aktualisieren",
"cancel": "Abbrechen"
},
"bulkDownloadMissingLoras": {
"title": "Fehlende LoRAs herunterladen",
"message": "{uniqueCount} einzigartige fehlende LoRAs gefunden (von insgesamt {totalCount} in ausgewählten Rezepten).",
"previewTitle": "Zu herunterladende LoRAs:",
"moreItems": "...und {count} weitere",
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"exampleAccess": {
"title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -1495,16 +1514,20 @@
"processingError": "Verarbeitungsfehler: {message}",
"folderBrowserError": "Fehler beim Laden des Ordner-Browsers: {message}",
"recipeSaveFailed": "Fehler beim Speichern des Rezepts: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Import fehlgeschlagen: {message}",
"folderTreeFailed": "Fehler beim Laden des Ordnerbaums",
"folderTreeError": "Fehler beim Laden des Ordnerbaums",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
},
"models": {
"noModelsSelected": "Keine Modelle ausgewählt",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Blur NSFW Content",
"blurNsfwContentHelp": "Blur mature (NSFW) content preview images",
"showOnlySfw": "Show Only SFW Results",
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching"
"showOnlySfwHelp": "Filter out all NSFW content when browsing and searching",
"matureBlurThreshold": "Mature Blur Threshold",
"matureBlurThresholdHelp": "Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "PG13 and above",
"r": "R and above (default)",
"x": "X and above",
"xxx": "XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "Autoplay Videos on Hover",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"deleteAll": "Delete Selected Models",
"downloadMissingLoras": "Download Missing LoRAs",
"clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)",
@@ -645,6 +654,8 @@
"root": "Root",
"browseFolders": "Browse Folders:",
"downloadAndSaveRecipe": "Download & Save Recipe",
"importRecipeOnly": "Import Recipe Only",
"importAndDownload": "Import & Download",
"downloadMissingLoras": "Download Missing LoRAs",
"saveRecipe": "Save Recipe",
"loraCountInfo": "({existing}/{total} in library)",
@@ -981,6 +992,14 @@
"save": "Update Base Model",
"cancel": "Cancel"
},
"bulkDownloadMissingLoras": {
"title": "Download Missing LoRAs",
"message": "Found {uniqueCount} unique missing LoRAs (from {totalCount} total across selected recipes).",
"previewTitle": "LoRAs to download:",
"moreItems": "...and {count} more",
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"exampleAccess": {
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
@@ -1495,6 +1514,7 @@
"processingError": "Processing error: {message}",
"folderBrowserError": "Error loading folder browser: {message}",
"recipeSaveFailed": "Failed to save recipe: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Import failed: {message}",
"folderTreeFailed": "Failed to load folder tree",
"folderTreeError": "Error loading folder tree",
@@ -1504,7 +1524,10 @@
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}"
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
},
"models": {
"noModelsSelected": "No models selected",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Difuminar contenido NSFW",
"blurNsfwContentHelp": "Difuminar imágenes de vista previa de contenido para adultos (NSFW)",
"showOnlySfw": "Mostrar solo resultados SFW",
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar"
"showOnlySfwHelp": "Filtrar todo el contenido NSFW al navegar y buscar",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "Reproducir videos automáticamente al pasar el ratón",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"deleteAll": "Eliminar todos los modelos",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos",
"resumeMetadataRefreshCount": "Reanudar{count} modelos",
@@ -645,6 +654,8 @@
"root": "Raíz",
"browseFolders": "Explorar carpetas:",
"downloadAndSaveRecipe": "Descargar y guardar receta",
"importRecipeOnly": "Importar solo la receta",
"importAndDownload": "Importar y descargar",
"downloadMissingLoras": "Descargar LoRAs faltantes",
"saveRecipe": "Guardar receta",
"loraCountInfo": "({existing}/{total} en la biblioteca)",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "Actualizar modelo base",
"cancel": "Cancelar"
},
"bulkDownloadMissingLoras": {
"title": "Descargar LoRAs faltantes",
"message": "Se encontraron {uniqueCount} LoRAs faltantes únicos (de {totalCount} en total entre las recetas seleccionadas).",
"previewTitle": "LoRAs para descargar:",
"moreItems": "...y {count} más",
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"exampleAccess": {
"title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -1495,16 +1514,20 @@
"processingError": "Error de procesamiento: {message}",
"folderBrowserError": "Error cargando explorador de carpetas: {message}",
"recipeSaveFailed": "Error al guardar receta: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Importación falló: {message}",
"folderTreeFailed": "Error al cargar árbol de carpetas",
"folderTreeError": "Error cargando árbol de carpetas",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
},
"models": {
"noModelsSelected": "No hay modelos seleccionados",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Flouter le contenu NSFW",
"blurNsfwContentHelp": "Flouter les images d'aperçu de contenu pour adultes (NSFW)",
"showOnlySfw": "Afficher uniquement les résultats SFW",
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche"
"showOnlySfwHelp": "Filtrer tout le contenu NSFW lors de la navigation et de la recherche",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "Lecture automatique vidéo au survol",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"deleteAll": "Supprimer tous les modèles",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles",
"resumeMetadataRefreshCount": "Reprendre{count} modèles",
@@ -645,6 +654,8 @@
"root": "Racine",
"browseFolders": "Parcourir les dossiers :",
"downloadAndSaveRecipe": "Télécharger et sauvegarder la recipe",
"importRecipeOnly": "Importer uniquement la recette",
"importAndDownload": "Importer et télécharger",
"downloadMissingLoras": "Télécharger les LoRAs manquants",
"saveRecipe": "Sauvegarder la recipe",
"loraCountInfo": "({existing}/{total} dans la bibliothèque)",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "Mettre à jour le modèle de base",
"cancel": "Annuler"
},
"bulkDownloadMissingLoras": {
"title": "Télécharger les LoRAs manquants",
"message": "{uniqueCount} LoRAs manquants uniques trouvés (sur un total de {totalCount} dans les recettes sélectionnées).",
"previewTitle": "LoRAs à télécharger :",
"moreItems": "...et {count} de plus",
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"exampleAccess": {
"title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -1495,16 +1514,20 @@
"processingError": "Erreur de traitement : {message}",
"folderBrowserError": "Erreur lors du chargement du navigateur de dossiers : {message}",
"recipeSaveFailed": "Échec de la sauvegarde de la recipe : {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Échec de l'importation : {message}",
"folderTreeFailed": "Échec du chargement de l'arborescence des dossiers",
"folderTreeError": "Erreur lors du chargement de l'arborescence des dossiers",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
},
"models": {
"noModelsSelected": "Aucun modèle sélectionné",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "טשטש תוכן NSFW",
"blurNsfwContentHelp": "טשטש תמונות תצוגה מקדימה של תוכן למבוגרים (NSFW)",
"showOnlySfw": "הצג רק תוצאות SFW",
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש"
"showOnlySfwHelp": "סנן את כל התוכן ה-NSFW בעת גלישה וחיפוש",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "נגן וידאו אוטומטית בריחוף",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"deleteAll": "מחק את כל המודלים",
"downloadMissingLoras": "הורדת LoRAs חסרים",
"clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)",
"resumeMetadataRefreshCount": "המשך({count} מודלים)",
@@ -645,6 +654,8 @@
"root": "שורש",
"browseFolders": "דפדף בתיקיות:",
"downloadAndSaveRecipe": "הורד ושמור מתכון",
"importRecipeOnly": "יבא רק מתכון",
"importAndDownload": "יבא והורד",
"downloadMissingLoras": "הורד LoRAs חסרים",
"saveRecipe": "שמור מתכון",
"loraCountInfo": "({existing}/{total} בספרייה)",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "עדכן מודל בסיס",
"cancel": "ביטול"
},
"bulkDownloadMissingLoras": {
"title": "הורדת LoRAs חסרים",
"message": "נמצאו {uniqueCount} LoRAs חסרים ייחודיים (מתוך {totalCount} בסך הכל במתכונים שנבחרו).",
"previewTitle": "LoRAs להורדה:",
"moreItems": "...ועוד {count}",
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"exampleAccess": {
"title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -1495,16 +1514,20 @@
"processingError": "שגיאת עיבוד: {message}",
"folderBrowserError": "שגיאה בטעינת דפדפן התיקיות: {message}",
"recipeSaveFailed": "שמירת המתכון נכשלה: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "הייבוא נכשל: {message}",
"folderTreeFailed": "טעינת עץ התיקיות נכשלה",
"folderTreeError": "שגיאה בטעינת עץ התיקיות",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
},
"models": {
"noModelsSelected": "לא נבחרו מודלים",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFWコンテンツをぼかす",
"blurNsfwContentHelp": "成人向けNSFWコンテンツのプレビュー画像をぼかします",
"showOnlySfw": "SFWコンテンツのみ表示",
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します"
"showOnlySfwHelp": "閲覧と検索時にすべてのNSFWコンテンツを除外します",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "ホバー時に動画を自動再生",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"deleteAll": "すべてのモデルを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード",
"clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)",
"resumeMetadataRefreshCount": "再開({count}モデル)",
@@ -645,6 +654,8 @@
"root": "ルート",
"browseFolders": "フォルダを参照:",
"downloadAndSaveRecipe": "ダウンロード & レシピ保存",
"importRecipeOnly": "レシピのみインポート",
"importAndDownload": "インポートとダウンロード",
"downloadMissingLoras": "不足しているLoRAをダウンロード",
"saveRecipe": "レシピを保存",
"loraCountInfo": "{existing}/{total} ライブラリ内)",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "ベースモデルを更新",
"cancel": "キャンセル"
},
"bulkDownloadMissingLoras": {
"title": "不足している LoRA をダウンロード",
"message": "選択したレシピから合計 {totalCount} 個中 {uniqueCount} 個のユニークな不足している LoRA が見つかりました。",
"previewTitle": "ダウンロードする LoRA:",
"moreItems": "...あと {count} 個",
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"exampleAccess": {
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -1495,16 +1514,20 @@
"processingError": "処理エラー:{message}",
"folderBrowserError": "フォルダブラウザの読み込みエラー:{message}",
"recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
},
"models": {
"noModelsSelected": "モデルが選択されていません",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "NSFW 콘텐츠 블러 처리",
"blurNsfwContentHelp": "성인(NSFW) 콘텐츠 미리보기 이미지를 블러 처리합니다",
"showOnlySfw": "SFW 결과만 표시",
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다"
"showOnlySfwHelp": "탐색 및 검색 시 모든 NSFW 콘텐츠를 필터링합니다",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "호버 시 비디오 자동 재생",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"deleteAll": "모든 모델 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
"resumeMetadataRefreshCount": "재개({count}개 모델)",
@@ -645,6 +654,8 @@
"root": "루트",
"browseFolders": "폴더 탐색:",
"downloadAndSaveRecipe": "다운로드 및 레시피 저장",
"importRecipeOnly": "레시피만 가져오기",
"importAndDownload": "가져오기 및 다운로드",
"downloadMissingLoras": "누락된 LoRA 다운로드",
"saveRecipe": "레시피 저장",
"loraCountInfo": "({existing}/{total} 라이브러리에 있음)",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "베이스 모델 업데이트",
"cancel": "취소"
},
"bulkDownloadMissingLoras": {
"title": "누락된 LoRA 다운로드",
"message": "선택한 레시피에서 총 {totalCount}개 중 {uniqueCount}개의 고유한 누락된 LoRA를 찾았습니다.",
"previewTitle": "다운로드할 LoRA:",
"moreItems": "...그리고 {count}개 더",
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"exampleAccess": {
"title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -1495,16 +1514,20 @@
"processingError": "처리 오류: {message}",
"folderBrowserError": "폴더 브라우저 로딩 오류: {message}",
"recipeSaveFailed": "레시피 저장 실패: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "가져오기 실패: {message}",
"folderTreeFailed": "폴더 트리 로딩 실패",
"folderTreeError": "폴더 트리 로딩 오류",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
},
"models": {
"noModelsSelected": "선택된 모델이 없습니다",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "Размывать NSFW контент",
"blurNsfwContentHelp": "Размывать превью изображений контента для взрослых (NSFW)",
"showOnlySfw": "Показывать только SFW результаты",
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске"
"showOnlySfwHelp": "Фильтровать весь NSFW контент при просмотре и поиске",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "Автовоспроизведение видео при наведении",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"deleteAll": "Удалить все модели",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)",
"resumeMetadataRefreshCount": "Возобновить({count} моделей)",
@@ -645,6 +654,8 @@
"root": "Корень",
"browseFolders": "Обзор папок:",
"downloadAndSaveRecipe": "Скачать и сохранить рецепт",
"importRecipeOnly": "Импортировать только рецепт",
"importAndDownload": "Импорт и скачивание",
"downloadMissingLoras": "Скачать отсутствующие LoRAs",
"saveRecipe": "Сохранить рецепт",
"loraCountInfo": "({existing}/{total} в библиотеке)",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "Обновить базовую модель",
"cancel": "Отмена"
},
"bulkDownloadMissingLoras": {
"title": "Скачать отсутствующие LoRAs",
"message": "Найдено {uniqueCount} уникальных отсутствующих LoRAs (из {totalCount} всего в выбранных рецептах).",
"previewTitle": "LoRAs для скачивания:",
"moreItems": "...и еще {count}",
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"exampleAccess": {
"title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -1495,16 +1514,20 @@
"processingError": "Ошибка обработки: {message}",
"folderBrowserError": "Ошибка загрузки браузера папок: {message}",
"recipeSaveFailed": "Не удалось сохранить рецепт: {error}",
"recipeSaved": "Recipe saved successfully",
"importFailed": "Импорт не удался: {message}",
"folderTreeFailed": "Не удалось загрузить дерево папок",
"folderTreeError": "Ошибка загрузки дерева папок",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
},
"models": {
"noModelsSelected": "Модели не выбраны",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 内容",
"blurNsfwContentHelp": "模糊成熟NSFW内容预览图片",
"showOnlySfw": "仅显示 SFW 结果",
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容"
"showOnlySfwHelp": "浏览和搜索时过滤所有 NSFW 内容",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "悬停时自动播放视频",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"deleteAll": "删除选中模型",
"downloadMissingLoras": "下载缺失的 LoRAs",
"clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)",
"resumeMetadataRefreshCount": "恢复({count} 个模型)",
@@ -645,6 +654,8 @@
"root": "根目录",
"browseFolders": "浏览文件夹:",
"downloadAndSaveRecipe": "下载并保存配方",
"importRecipeOnly": "仅导入配方",
"importAndDownload": "导入并下载",
"downloadMissingLoras": "下载缺失的 LoRA",
"saveRecipe": "保存配方",
"loraCountInfo": "({existing}/{total} in library)",
@@ -734,55 +745,55 @@
"batchImport": {
"title": "批量导入配方",
"action": "批量导入",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"urlList": "URL 列表",
"directory": "目录",
"urlDescription": "输入图像 URL 或本地文件路径(每行一个)。每个都将作为配方导入。",
"directoryDescription": "输入目录路径以导入该文件夹中的所有图片。",
"urlsLabel": "图片 URL 或本地路径",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"urlsHint": "每行输入一个 URL 或路径",
"directoryPath": "目录路径",
"directoryPlaceholder": "/图片/文件夹/路径",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"browse": "浏览",
"recursive": "包含子目录",
"tagsOptional": "标签(可选,应用于所有配方)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"tagsPlaceholder": "输入以逗号分隔的标签",
"tagsHint": "标签将被添加到所有导入的配方中",
"skipNoMetadata": "跳过无元数据的图片",
"skipNoMetadataHelp": "没有 LoRA 元数据的图片将自动跳过。",
"start": "[TODO: Translate] Start Import",
"start": "开始导入",
"startImport": "开始导入",
"importing": "正在导入配方...",
"progress": "进度",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"total": "总计",
"success": "成功",
"failed": "失败",
"skipped": "跳过",
"current": "当前",
"currentItem": "当前",
"preparing": "准备中...",
"cancel": "[TODO: Translate] Cancel",
"cancel": "取消",
"cancelImport": "取消",
"cancelled": "批量导入已取消",
"completed": "导入完成",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedWithErrors": "导入完成但有错误",
"completedSuccess": "成功导入 {count} 个配方",
"successCount": "成功",
"failedCount": "失败",
"skippedCount": "跳过",
"totalProcessed": "总计处理",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"viewDetails": "查看详情",
"newImport": "新建导入",
"manualPathEntry": "请手动输入目录路径。此浏览器中文件浏览器不可用。",
"batchImportDirectorySelected": "已选择目录:{path}",
"batchImportManualEntryRequired": "文件浏览器不可用。请手动输入目录路径。",
"backToParent": "返回上级目录",
"folders": "文件夹",
"folderCount": "{count} 个文件夹",
"imageFiles": "图像文件",
"images": "图像",
"imageCount": "{count} 个图像",
"selectFolder": "选择此文件夹",
"errors": {
"enterUrls": "请至少输入一个 URL 或路径",
"enterDirectory": "请输入目录路径",
@@ -981,6 +992,14 @@
"save": "更新基础模型",
"cancel": "取消"
},
"bulkDownloadMissingLoras": {
"title": "下载缺失的 LoRAs",
"message": "发现 {uniqueCount} 个独特的缺失 LoRAs从选定配方中的 {totalCount} 个总数)。",
"previewTitle": "要下载的 LoRAs",
"moreItems": "...还有 {count} 个",
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -1495,16 +1514,20 @@
"processingError": "处理出错:{message}",
"folderBrowserError": "加载文件夹浏览器出错:{message}",
"recipeSaveFailed": "保存配方失败:{error}",
"recipeSaved": "配方保存成功",
"importFailed": "导入失败:{message}",
"folderTreeFailed": "加载文件夹树失败",
"folderTreeError": "加载文件夹树出错",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "启动批量导入失败:{message}",
"batchImportCancelling": "正在取消批量导入...",
"batchImportCancelFailed": "取消批量导入失败:{message}",
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
"batchImportNoDirectory": "请输入目录路径",
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
},
"models": {
"noModelsSelected": "未选中模型",

View File

@@ -291,7 +291,15 @@
"blurNsfwContent": "模糊 NSFW 內容",
"blurNsfwContentHelp": "模糊成熟NSFW內容預覽圖片",
"showOnlySfw": "僅顯示 SFW 結果",
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容"
"showOnlySfwHelp": "瀏覽和搜尋時過濾所有 NSFW 內容",
"matureBlurThreshold": "[TODO: Translate] Mature Blur Threshold",
"matureBlurThresholdHelp": "[TODO: Translate] Set which rating level starts blur filtering when NSFW blur is enabled.",
"matureBlurThresholdOptions": {
"pg13": "[TODO: Translate] PG13 and above",
"r": "[TODO: Translate] R and above (default)",
"x": "[TODO: Translate] X and above",
"xxx": "[TODO: Translate] XXX only"
}
},
"videoSettings": {
"autoplayOnHover": "滑鼠懸停自動播放影片",
@@ -575,6 +583,7 @@
"skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新",
"deleteAll": "刪除全部模型",
"downloadMissingLoras": "下載缺失的 LoRAs",
"clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)",
"resumeMetadataRefreshCount": "恢復({count} 個模型)",
@@ -645,6 +654,8 @@
"root": "根目錄",
"browseFolders": "瀏覽資料夾:",
"downloadAndSaveRecipe": "下載並儲存配方",
"importRecipeOnly": "僅匯入配方",
"importAndDownload": "匯入並下載",
"downloadMissingLoras": "下載缺少的 LoRA",
"saveRecipe": "儲存配方",
"loraCountInfo": "(庫存 {existing}/{total}",
@@ -732,61 +743,61 @@
}
},
"batchImport": {
"title": "[TODO: Translate] Batch Import Recipes",
"action": "[TODO: Translate] Batch Import",
"urlList": "[TODO: Translate] URL List",
"directory": "[TODO: Translate] Directory",
"urlDescription": "[TODO: Translate] Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "[TODO: Translate] Enter a directory path to import all images from that folder.",
"urlsLabel": "[TODO: Translate] Image URLs or Local Paths",
"urlsPlaceholder": "[TODO: Translate] https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "[TODO: Translate] Enter one URL or path per line",
"directoryPath": "[TODO: Translate] Directory Path",
"directoryPlaceholder": "[TODO: Translate] /path/to/images/folder",
"browse": "[TODO: Translate] Browse",
"recursive": "[TODO: Translate] Include subdirectories",
"tagsOptional": "[TODO: Translate] Tags (optional, applied to all recipes)",
"tagsPlaceholder": "[TODO: Translate] Enter tags separated by commas",
"tagsHint": "[TODO: Translate] Tags will be added to all imported recipes",
"skipNoMetadata": "[TODO: Translate] Skip images without metadata",
"skipNoMetadataHelp": "[TODO: Translate] Images without LoRA metadata will be skipped automatically.",
"start": "[TODO: Translate] Start Import",
"startImport": "[TODO: Translate] Start Import",
"importing": "[TODO: Translate] Importing...",
"progress": "[TODO: Translate] Progress",
"total": "[TODO: Translate] Total",
"success": "[TODO: Translate] Success",
"failed": "[TODO: Translate] Failed",
"skipped": "[TODO: Translate] Skipped",
"current": "[TODO: Translate] Current",
"currentItem": "[TODO: Translate] Current",
"preparing": "[TODO: Translate] Preparing...",
"cancel": "[TODO: Translate] Cancel",
"cancelImport": "[TODO: Translate] Cancel",
"cancelled": "[TODO: Translate] Import cancelled",
"completed": "[TODO: Translate] Import completed",
"completedWithErrors": "[TODO: Translate] Completed with errors",
"completedSuccess": "[TODO: Translate] Successfully imported {count} recipe(s)",
"successCount": "[TODO: Translate] Successful",
"failedCount": "[TODO: Translate] Failed",
"skippedCount": "[TODO: Translate] Skipped",
"totalProcessed": "[TODO: Translate] Total processed",
"viewDetails": "[TODO: Translate] View Details",
"newImport": "[TODO: Translate] New Import",
"manualPathEntry": "[TODO: Translate] Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {name}. You may need to enter the full path manually.",
"batchImportManualEntryRequired": "[TODO: Translate] File browser not available. Please enter the directory path manually.",
"backToParent": "[TODO: Translate] Back to parent directory",
"folders": "[TODO: Translate] Folders",
"folderCount": "[TODO: Translate] {count} folders",
"imageFiles": "[TODO: Translate] Image Files",
"images": "[TODO: Translate] images",
"imageCount": "[TODO: Translate] {count} images",
"selectFolder": "[TODO: Translate] Select This Folder",
"title": "批量匯入配方",
"action": "批量匯入",
"urlList": "URL 列表",
"directory": "目錄",
"urlDescription": "輸入圖像 URL 或本地檔案路徑(每行一個)。每個都將作為配方匯入。",
"directoryDescription": "輸入目錄路徑以匯入該資料夾中的所有圖像。",
"urlsLabel": "圖像 URL 或本地路徑",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "每行輸入一個 URL 或路徑",
"directoryPath": "目錄路徑",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "瀏覽",
"recursive": "包含子目錄",
"tagsOptional": "標籤(可選,應用於所有配方)",
"tagsPlaceholder": "輸入以逗號分隔的標籤",
"tagsHint": "標籤將被添加到所有匯入的配方中",
"skipNoMetadata": "跳過無元資料的圖像",
"skipNoMetadataHelp": "沒有 LoRA 元資料的圖像將被自動跳過。",
"start": "開始匯入",
"startImport": "開始匯入",
"importing": "匯入中...",
"progress": "進度",
"total": "總計",
"success": "成功",
"failed": "失敗",
"skipped": "跳過",
"current": "當前",
"currentItem": "當前項目",
"preparing": "準備中...",
"cancel": "取消",
"cancelImport": "取消匯入",
"cancelled": "匯入已取消",
"completed": "匯入完成",
"completedWithErrors": "匯入完成但有錯誤",
"completedSuccess": "成功匯入 {count} 個配方",
"successCount": "成功",
"failedCount": "失敗",
"skippedCount": "跳過",
"totalProcessed": "總計處理",
"viewDetails": "查看詳情",
"newImport": "新建匯入",
"manualPathEntry": "請手動輸入目錄路徑。此瀏覽器中檔案瀏覽器不可用。",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"batchImportManualEntryRequired": "檔案瀏覽器不可用。請手動輸入目錄路徑。",
"backToParent": "返回上級目錄",
"folders": "資料夾",
"folderCount": "{count} 個資料夾",
"imageFiles": "圖像檔案",
"images": "圖像",
"imageCount": "{count} 個圖像",
"selectFolder": "選擇此資料夾",
"errors": {
"enterUrls": "[TODO: Translate] Please enter at least one URL or path",
"enterDirectory": "[TODO: Translate] Please enter a directory path",
"startFailed": "[TODO: Translate] Failed to start import: {message}"
"enterUrls": "請輸入至少一個 URL 或路徑",
"enterDirectory": "請輸入目錄路徑",
"startFailed": "啟動匯入失敗:{message}"
}
}
},
@@ -981,6 +992,14 @@
"save": "更新基礎模型",
"cancel": "取消"
},
"bulkDownloadMissingLoras": {
"title": "下載缺失的 LoRAs",
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs從選取食譜中的 {totalCount} 個總數)。",
"previewTitle": "要下載的 LoRAs",
"moreItems": "...還有 {count} 個",
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"exampleAccess": {
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
@@ -1495,16 +1514,20 @@
"processingError": "處理錯誤:{message}",
"folderBrowserError": "載入資料夾瀏覽器錯誤:{message}",
"recipeSaveFailed": "儲存配方失敗:{error}",
"recipeSaved": "配方儲存成功",
"importFailed": "匯入失敗:{message}",
"folderTreeFailed": "載入資料夾樹狀結構失敗",
"folderTreeError": "載入資料夾樹狀結構錯誤",
"batchImportFailed": "[TODO: Translate] Failed to start batch import: {message}",
"batchImportCancelling": "[TODO: Translate] Cancelling batch import...",
"batchImportCancelFailed": "[TODO: Translate] Failed to cancel batch import: {message}",
"batchImportNoUrls": "[TODO: Translate] Please enter at least one URL or file path",
"batchImportNoDirectory": "[TODO: Translate] Please enter a directory path",
"batchImportBrowseFailed": "[TODO: Translate] Failed to browse directory: {message}",
"batchImportDirectorySelected": "[TODO: Translate] Directory selected: {path}"
"batchImportFailed": "啟動批量匯入失敗:{message}",
"batchImportCancelling": "正在取消批量匯入...",
"batchImportCancelFailed": "取消批量匯入失敗:{message}",
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
"batchImportNoDirectory": "請輸入目錄路徑",
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
},
"models": {
"noModelsSelected": "未選擇模型",

3
package-lock.json generated
View File

@@ -114,7 +114,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -138,7 +137,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1613,7 +1611,6 @@
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.0.1",
"data-urls": "^5.0.0",

View File

@@ -149,9 +149,12 @@ class MetadataHook:
# Store the original _async_map_node_over_list function
original_map_node_over_list = getattr(execution, map_node_func_name)
# Wrapped async function, compatible with both stable and nightly
async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None, *args, **kwargs):
hidden_inputs = kwargs.get('hidden_inputs', None)
# Wrapped async function - signature must exactly match _async_map_node_over_list
async def async_map_node_over_list_with_metadata(
prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt=False, execution_block_cb=None,
pre_execute_cb=None, v3_data=None
):
# Only collect metadata when calling the main function of nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'):
try:
@@ -164,10 +167,10 @@ class MetadataHook:
except Exception as e:
logger.error(f"Error collecting metadata (pre-execution): {str(e)}")
# Call original function with all args/kwargs
# Call original function with exact parameters
results = await original_map_node_over_list(
prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt, execution_block_cb, pre_execute_cb, *args, **kwargs
allow_interrupt, execution_block_cb, pre_execute_cb, v3_data=v3_data
)
if func == obj.FUNCTION and hasattr(obj, '__class__'):

View File

@@ -1,8 +1,7 @@
import logging
import os
from typing import List, Tuple
import comfy.sd
import folder_paths
import comfy.sd # type: ignore
import folder_paths # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)

View File

@@ -82,6 +82,7 @@ class LoraPoolLM:
"folders": {"include": [], "exclude": []},
"favoritesOnly": False,
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
},
"preview": {"matchCount": 0, "lastUpdated": 0},
}

View File

@@ -7,10 +7,8 @@ and tracks the last used combination for reuse.
"""
import logging
import random
import os
from ..utils.utils import get_lora_info
from .utils import extract_lora_name
logger = logging.getLogger(__name__)

View File

@@ -1,8 +1,7 @@
import logging
import os
from typing import List, Tuple
import torch
import comfy.sd
import comfy.sd # type: ignore
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
@@ -101,6 +100,8 @@ class UNETLoaderLM:
Returns:
Tuple of (MODEL,)
"""
import torch
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
@@ -143,6 +144,7 @@ class UNETLoaderLM:
Returns:
Tuple of (MODEL,)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)

View File

@@ -7,6 +7,7 @@ from .parsers import (
MetaFormatParser,
AutomaticMetadataParser,
CivitaiApiMetadataParser,
SuiImageParamsParser,
)
from .base import RecipeMetadataParser
@@ -55,6 +56,13 @@ class RecipeParserFactory:
# If JSON parsing fails, move on to other parsers
pass
# Try SuiImageParamsParser for SuiImage metadata format
try:
if SuiImageParamsParser().is_metadata_matching(metadata_str):
return SuiImageParamsParser()
except Exception:
pass
# Check other parsers that expect string input
if RecipeFormatParser().is_metadata_matching(metadata_str):
return RecipeFormatParser()

View File

@@ -5,6 +5,7 @@ from .comfy import ComfyMetadataParser
from .meta_format import MetaFormatParser
from .automatic import AutomaticMetadataParser
from .civitai_image import CivitaiApiMetadataParser
from .sui_image_params import SuiImageParamsParser
__all__ = [
'RecipeFormatParser',
@@ -12,4 +13,5 @@ __all__ = [
'MetaFormatParser',
'AutomaticMetadataParser',
'CivitaiApiMetadataParser',
'SuiImageParamsParser',
]

View File

@@ -0,0 +1,188 @@
"""Parser for SuiImage (Stable Diffusion WebUI) metadata format."""
import json
import logging
from typing import Dict, Any, Optional, List
from ..base import RecipeMetadataParser
from ...services.metadata_service import get_default_metadata_provider
logger = logging.getLogger(__name__)
class SuiImageParamsParser(RecipeMetadataParser):
"""Parser for SuiImage metadata JSON format.
This format is used by some Stable Diffusion WebUI variants.
Structure:
{
"sui_image_params": {
"prompt": "...",
"negativeprompt": "...",
"model": "...",
"seed": ...,
"steps": ...,
...
},
"sui_models": [
{"name": "...", "param": "model", "hash": "..."},
...
],
"sui_extra_data": {...}
}
"""
def is_metadata_matching(self, user_comment: str) -> bool:
"""Check if the user comment matches the SuiImage metadata format"""
try:
data = json.loads(user_comment)
return isinstance(data, dict) and 'sui_image_params' in data
except (json.JSONDecodeError, TypeError):
return False
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
"""Parse metadata from SuiImage metadata format"""
try:
metadata_provider = await get_default_metadata_provider()
data = json.loads(user_comment)
params = data.get('sui_image_params', {})
models = data.get('sui_models', [])
# Extract prompt and negative prompt
prompt = params.get('prompt', '')
negative_prompt = params.get('negativeprompt', '') or params.get('negative_prompt', '')
# Extract generation parameters
gen_params = {}
if prompt:
gen_params['prompt'] = prompt
if negative_prompt:
gen_params['negative_prompt'] = negative_prompt
# Map standard parameters
param_mapping = {
'steps': 'steps',
'seed': 'seed',
'cfgscale': 'cfg_scale',
'cfg_scale': 'cfg_scale',
'width': 'width',
'height': 'height',
'sampler': 'sampler',
'scheduler': 'scheduler',
'model': 'model',
'vae': 'vae',
}
for src_key, dest_key in param_mapping.items():
if src_key in params and params[src_key] is not None:
gen_params[dest_key] = params[src_key]
# Add size info if available
if 'width' in gen_params and 'height' in gen_params:
gen_params['size'] = f"{gen_params['width']}x{gen_params['height']}"
# Process models - extract checkpoint and loras
loras: List[Dict[str, Any]] = []
checkpoint: Optional[Dict[str, Any]] = None
for model in models:
model_name = model.get('name', '')
param_type = model.get('param', '')
model_hash = model.get('hash', '')
# Remove .safetensors extension for cleaner name
clean_name = model_name.replace('.safetensors', '') if model_name else ''
# Check if this is a LoRA by looking at the name or param type
is_lora = 'lora' in model_name.lower() or param_type.lower().startswith('lora')
if is_lora:
lora_entry = {
'id': 0,
'modelId': 0,
'name': clean_name,
'version': '',
'type': 'lora',
'weight': 1.0,
'existsLocally': False,
'localPath': None,
'file_name': model_name,
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get additional info from metadata provider
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
)
if civitai_info:
lora_entry = await self.populate_lora_from_civitai(
lora_entry, civitai_info, recipe_scanner
)
except Exception as e:
logger.debug(f"Error fetching info for LoRA {clean_name}: {e}")
if lora_entry:
loras.append(lora_entry)
elif param_type == 'model' or 'lora' not in model_name.lower():
# This is likely a checkpoint
checkpoint_entry = {
'id': 0,
'modelId': 0,
'name': clean_name,
'version': '',
'type': 'checkpoint',
'hash': model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash,
'existsLocally': False,
'localPath': None,
'file_name': model_name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get additional info from metadata provider
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(
model_hash.replace('0x', '') if model_hash.startswith('0x') else model_hash
)
if civitai_info:
checkpoint_entry = await self.populate_checkpoint_from_civitai(
checkpoint_entry, civitai_info
)
except Exception as e:
logger.debug(f"Error fetching info for checkpoint {clean_name}: {e}")
checkpoint = checkpoint_entry
# Determine base model from loras or checkpoint
base_model = None
if loras:
base_models = [lora.get('baseModel') for lora in loras if lora.get('baseModel')]
if base_models:
from collections import Counter
base_model_counts = Counter(base_models)
base_model = base_model_counts.most_common(1)[0][0]
elif checkpoint and checkpoint.get('baseModel'):
base_model = checkpoint['baseModel']
return {
'base_model': base_model,
'loras': loras,
'checkpoint': checkpoint,
'gen_params': gen_params,
'from_sui_image_params': True
}
except Exception as e:
logger.error(f"Error parsing SuiImage metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []}

View File

@@ -309,6 +309,13 @@ class ModelListingHandler:
else:
allow_selling_generated_content = None # None means no filter applied
# Name pattern filters for LoRA Pool
name_pattern_include = request.query.getall("name_pattern_include", [])
name_pattern_exclude = request.query.getall("name_pattern_exclude", [])
name_pattern_use_regex = (
request.query.get("name_pattern_use_regex", "false").lower() == "true"
)
return {
"page": page,
"page_size": page_size,
@@ -328,6 +335,9 @@ class ModelListingHandler:
"credit_required": credit_required,
"allow_selling_generated_content": allow_selling_generated_content,
"model_types": model_types,
"name_pattern_include": name_pattern_include,
"name_pattern_exclude": name_pattern_exclude,
"name_pattern_use_regex": name_pattern_use_regex,
**self._parse_specific_params(request),
}

View File

@@ -490,14 +490,33 @@ class CivitaiClient:
"""
try:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
requested_id = int(image_id)
logger.debug(f"Fetching image info for ID: {image_id}")
success, result = await self._make_request("GET", url, use_auth=True)
if success:
if result and "items" in result and len(result["items"]) > 0:
logger.debug(f"Successfully fetched image info for ID: {image_id}")
return result["items"][0]
if result and "items" in result and isinstance(result["items"], list):
items = result["items"]
# First, try to find the item with matching ID
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(f"Successfully fetched image info for ID: {image_id}")
return item
# No matching ID found - log warning with details about returned items
returned_ids = [
item.get("id") for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
f"CivitAI API returned no matching image for requested ID {image_id}. "
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
f"This may indicate the image was deleted, hidden, or there is a database lag."
)
return None
logger.warning(f"No image found with ID: {image_id}")
return None
@@ -505,6 +524,10 @@ class CivitaiClient:
return None
except RateLimitError:
raise
except ValueError as e:
error_msg = f"Invalid image ID format: {image_id}"
logger.error(error_msg)
return None
except Exception as e:
error_msg = f"Error fetching image info: {e}"
logger.error(error_msg)

View File

@@ -16,10 +16,9 @@ from ..utils.constants import (
VALID_LORA_TYPES,
)
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.file_utils import calculate_sha256
from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry
from .settings_manager import get_settings_manager
@@ -847,9 +846,13 @@ class DownloadManager:
blur_mature_content = bool(
settings_manager.get("blur_mature_content", True)
)
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
selected_image, nsfw_level = select_preview_media(
images,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
preview_url = selected_image.get("url") if selected_image else None
@@ -965,11 +968,12 @@ class DownloadManager:
for download_url in download_urls:
use_auth = download_url.startswith("https://civitai.com/api/download/")
download_kwargs = {
"progress_callback": lambda progress,
snapshot=None: self._handle_download_progress(
progress,
progress_callback,
snapshot,
"progress_callback": lambda progress, snapshot=None: (
self._handle_download_progress(
progress,
progress_callback,
snapshot,
)
),
"use_auth": use_auth, # Only use authentication for Civitai downloads
}
@@ -1238,7 +1242,8 @@ class DownloadManager:
entry.file_name = os.path.splitext(os.path.basename(file_path))[0]
# Update size to actual downloaded file size
entry.size = os.path.getsize(file_path)
entry.sha256 = await calculate_sha256(file_path)
# Use SHA256 from API metadata (already set in from_civitai_info)
# Do not recalculate to avoid blocking during ComfyUI execution
entries.append(entry)
return entries

View File

@@ -44,7 +44,9 @@ class DownloadStreamControl:
self._event.set()
self._reconnect_requested = False
self.last_progress_timestamp: Optional[float] = None
self.stall_timeout: float = float(stall_timeout) if stall_timeout is not None else 120.0
self.stall_timeout: float = (
float(stall_timeout) if stall_timeout is not None else 120.0
)
def is_set(self) -> bool:
return self._event.is_set()
@@ -85,7 +87,9 @@ class DownloadStreamControl:
self.last_progress_timestamp = timestamp or datetime.now().timestamp()
self._reconnect_requested = False
def time_since_last_progress(self, *, now: Optional[float] = None) -> Optional[float]:
def time_since_last_progress(
self, *, now: Optional[float] = None
) -> Optional[float]:
if self.last_progress_timestamp is None:
return None
reference = now if now is not None else datetime.now().timestamp()
@@ -120,7 +124,7 @@ class Downloader:
def __init__(self):
"""Initialize the downloader with optimal settings"""
# Check if already initialized for singleton pattern
if hasattr(self, '_initialized'):
if hasattr(self, "_initialized"):
return
self._initialized = True
@@ -131,7 +135,9 @@ class Downloader:
self._session_lock = asyncio.Lock()
# Configuration
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
self.chunk_size = (
16 * 1024 * 1024
) # 16MB chunks to balance I/O reduction and memory usage
self.max_retries = 5
self.base_delay = 2.0 # Base delay for exponential backoff
self.session_timeout = 300 # 5 minutes
@@ -139,10 +145,10 @@ class Downloader:
# Default headers
self.default_headers = {
'User-Agent': 'ComfyUI-LoRA-Manager/1.0',
"User-Agent": "ComfyUI-LoRA-Manager/1.0",
# Explicitly request uncompressed payloads so aiohttp doesn't need optional
# decoders (e.g. zstandard) that may be missing in runtime environments.
'Accept-Encoding': 'identity',
"Accept-Encoding": "identity",
}
@property
@@ -158,7 +164,7 @@ class Downloader:
@property
def proxy_url(self) -> Optional[str]:
"""Get the current proxy URL (initialize if needed)"""
if not hasattr(self, '_proxy_url'):
if not hasattr(self, "_proxy_url"):
self._proxy_url = None
return self._proxy_url
@@ -169,14 +175,14 @@ class Downloader:
try:
settings_manager = get_settings_manager()
settings_timeout = settings_manager.get('download_stall_timeout_seconds')
settings_timeout = settings_manager.get("download_stall_timeout_seconds")
except Exception as exc: # pragma: no cover - defensive guard
logger.debug("Failed to read stall timeout from settings: %s", exc)
raw_value = (
settings_timeout
if settings_timeout not in (None, "")
else os.environ.get('COMFYUI_DOWNLOAD_STALL_TIMEOUT')
else os.environ.get("COMFYUI_DOWNLOAD_STALL_TIMEOUT")
)
try:
@@ -191,11 +197,13 @@ class Downloader:
if self._session is None:
return True
if not hasattr(self, '_session_created_at') or self._session_created_at is None:
if not hasattr(self, "_session_created_at") or self._session_created_at is None:
return True
# Refresh if session is older than timeout
if (datetime.now() - self._session_created_at).total_seconds() > self.session_timeout:
if (
datetime.now() - self._session_created_at
).total_seconds() > self.session_timeout:
return True
return False
@@ -209,7 +217,7 @@ class Downloader:
if self._session is not None:
try:
await self._session.close()
except Exception as e: # pragma: no cover
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
finally:
self._session = None
@@ -217,12 +225,12 @@ class Downloader:
# Check for app-level proxy settings
proxy_url = None
settings_manager = get_settings_manager()
if settings_manager.get('proxy_enabled', False):
proxy_host = settings_manager.get('proxy_host', '').strip()
proxy_port = settings_manager.get('proxy_port', '').strip()
proxy_type = settings_manager.get('proxy_type', 'http').lower()
proxy_username = settings_manager.get('proxy_username', '').strip()
proxy_password = settings_manager.get('proxy_password', '').strip()
if settings_manager.get("proxy_enabled", False):
proxy_host = settings_manager.get("proxy_host", "").strip()
proxy_port = settings_manager.get("proxy_port", "").strip()
proxy_type = settings_manager.get("proxy_type", "http").lower()
proxy_username = settings_manager.get("proxy_username", "").strip()
proxy_password = settings_manager.get("proxy_password", "").strip()
if proxy_host and proxy_port:
# Build proxy URL
@@ -231,37 +239,46 @@ class Downloader:
else:
proxy_url = f"{proxy_type}://{proxy_host}:{proxy_port}"
logger.debug(f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}")
logger.debug(
f"Using app-level proxy: {proxy_type}://{proxy_host}:{proxy_port}"
)
logger.debug("Proxy mode: app-level proxy is active.")
else:
logger.debug("Proxy mode: system-level proxy (trust_env) will be used if configured in environment.")
logger.debug(
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
)
# Optimize TCP connection parameters
connector = aiohttp.TCPConnector(
ssl=True,
limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse
enable_cleanup_closed=True
enable_cleanup_closed=True,
)
# Configure timeout parameters
timeout = aiohttp.ClientTimeout(
total=None, # No total timeout for large downloads
connect=60, # Connection timeout
sock_read=300 # 5 minute socket read timeout
sock_read=300, # 5 minute socket read timeout
)
self._session = aiohttp.ClientSession(
connector=connector,
trust_env=proxy_url is None, # Only use system proxy if no app-level proxy is set
timeout=timeout
trust_env=proxy_url
is None, # Only use system proxy if no app-level proxy is set
timeout=timeout,
)
# Store proxy URL for use in requests
self._proxy_url = proxy_url
self._session_created_at = datetime.now()
logger.debug("Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s", bool(proxy_url), proxy_url is None)
logger.debug(
"Created new HTTP session with proxy settings. App-level proxy: %s, System-level proxy (trust_env): %s",
bool(proxy_url),
proxy_url is None,
)
def _get_auth_headers(self, use_auth: bool = False) -> Dict[str, str]:
"""Get headers with optional authentication"""
@@ -270,10 +287,10 @@ class Downloader:
if use_auth:
# Add CivitAI API key if available
settings_manager = get_settings_manager()
api_key = settings_manager.get('civitai_api_key')
api_key = settings_manager.get("civitai_api_key")
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
headers['Content-Type'] = 'application/json'
headers["Authorization"] = f"Bearer {api_key}"
headers["Content-Type"] = "application/json"
return headers
@@ -303,7 +320,7 @@ class Downloader:
Tuple[bool, str]: (success, save_path or error message)
"""
retry_count = 0
part_path = save_path + '.part' if allow_resume else save_path
part_path = save_path + ".part" if allow_resume else save_path
# Prepare headers
headers = self._get_auth_headers(use_auth)
@@ -323,50 +340,71 @@ class Downloader:
session = await self.session
# Debug log for proxy mode at request time
if self.proxy_url:
logger.debug(f"[download_file] Using app-level proxy: {self.proxy_url}")
logger.debug(
f"[download_file] Using app-level proxy: {self.proxy_url}"
)
else:
logger.debug("[download_file] Using system-level proxy (trust_env) if configured.")
logger.debug(
"[download_file] Using system-level proxy (trust_env) if configured."
)
# Add Range header for resume if we have partial data
request_headers = headers.copy()
if allow_resume and resume_offset > 0:
request_headers['Range'] = f'bytes={resume_offset}-'
request_headers["Range"] = f"bytes={resume_offset}-"
# Disable compression for better chunked downloads
request_headers['Accept-Encoding'] = 'identity'
request_headers["Accept-Encoding"] = "identity"
logger.debug(f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}")
logger.debug(
f"Download attempt {retry_count + 1}/{self.max_retries + 1} from: {url}"
)
if resume_offset > 0:
logger.debug(f"Requesting range from byte {resume_offset}")
async with session.get(url, headers=request_headers, allow_redirects=True, proxy=self.proxy_url) as response:
async with session.get(
url,
headers=request_headers,
allow_redirects=True,
proxy=self.proxy_url,
) as response:
# Handle different response codes
if response.status == 200:
# Full content response
if resume_offset > 0:
# Server doesn't support ranges, restart from beginning
logger.warning("Server doesn't support range requests, restarting download")
logger.warning(
"Server doesn't support range requests, restarting download"
)
resume_offset = 0
if os.path.exists(part_path):
os.remove(part_path)
elif response.status == 206:
# Partial content response (resume successful)
content_range = response.headers.get('Content-Range')
content_range = response.headers.get("Content-Range")
if content_range:
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
range_parts = content_range.split('/')
range_parts = content_range.split("/")
if len(range_parts) == 2:
total_size = int(range_parts[1])
logger.info(f"Successfully resumed download from byte {resume_offset}")
logger.info(
f"Successfully resumed download from byte {resume_offset}"
)
elif response.status == 416:
# Range not satisfiable - file might be complete or corrupted
if allow_resume and os.path.exists(part_path):
part_size = os.path.getsize(part_path)
logger.warning(f"Range not satisfiable. Part file size: {part_size}")
logger.warning(
f"Range not satisfiable. Part file size: {part_size}"
)
# Try to get actual file size
head_response = await session.head(url, headers=headers, proxy=self.proxy_url)
head_response = await session.head(
url, headers=headers, proxy=self.proxy_url
)
if head_response.status == 200:
actual_size = int(head_response.headers.get('content-length', 0))
actual_size = int(
head_response.headers.get("content-length", 0)
)
if part_size == actual_size:
# File is complete, just rename it
if allow_resume:
@@ -388,21 +426,36 @@ class Downloader:
resume_offset = 0
continue
elif response.status == 401:
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
return False, "Invalid or missing API key, or early access restriction."
logger.warning(
f"Unauthorized access to resource: {url} (Status 401)"
)
return (
False,
"Invalid or missing API key, or early access restriction.",
)
elif response.status == 403:
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
return False, "Access forbidden: You don't have permission to download this file."
logger.warning(
f"Forbidden access to resource: {url} (Status 403)"
)
return (
False,
"Access forbidden: You don't have permission to download this file.",
)
elif response.status == 404:
logger.warning(f"Resource not found: {url} (Status 404)")
return False, "File not found - the download link may be invalid or expired."
return (
False,
"File not found - the download link may be invalid or expired.",
)
else:
logger.error(f"Download failed for {url} with status {response.status}")
logger.error(
f"Download failed for {url} with status {response.status}"
)
return False, f"Download failed with status {response.status}"
# Get total file size for progress calculation (if not set from Content-Range)
if total_size == 0:
total_size = int(response.headers.get('content-length', 0))
total_size = int(response.headers.get("content-length", 0))
if response.status == 206:
# For partial content, add the offset to get total file size
total_size += resume_offset
@@ -417,7 +470,7 @@ class Downloader:
# Stream download to file with progress updates
loop = asyncio.get_running_loop()
mode = 'ab' if (allow_resume and resume_offset > 0) else 'wb'
mode = "ab" if (allow_resume and resume_offset > 0) else "wb"
control = pause_event
if control is not None:
@@ -425,7 +478,9 @@ class Downloader:
with open(part_path, mode) as f:
while True:
active_stall_timeout = control.stall_timeout if control else self.stall_timeout
active_stall_timeout = (
control.stall_timeout if control else self.stall_timeout
)
if control is not None:
if control.is_paused():
@@ -437,7 +492,9 @@ class Downloader:
"Reconnect requested after resume"
)
elif control.consume_reconnect_request():
raise DownloadRestartRequested("Reconnect requested")
raise DownloadRestartRequested(
"Reconnect requested"
)
try:
chunk = await asyncio.wait_for(
@@ -466,22 +523,32 @@ class Downloader:
control.mark_progress(timestamp=now.timestamp())
# Limit progress update frequency to reduce overhead
time_diff = (now - last_progress_report_time).total_seconds()
time_diff = (
now - last_progress_report_time
).total_seconds()
if progress_callback and time_diff >= 1.0:
progress_samples.append((now, current_size))
cutoff = now - timedelta(seconds=5)
while progress_samples and progress_samples[0][0] < cutoff:
while (
progress_samples and progress_samples[0][0] < cutoff
):
progress_samples.popleft()
percent = (current_size / total_size) * 100 if total_size else 0.0
percent = (
(current_size / total_size) * 100
if total_size
else 0.0
)
bytes_per_second = 0.0
if len(progress_samples) >= 2:
first_time, first_bytes = progress_samples[0]
last_time, last_bytes = progress_samples[-1]
elapsed = (last_time - first_time).total_seconds()
if elapsed > 0:
bytes_per_second = (last_bytes - first_bytes) / elapsed
bytes_per_second = (
last_bytes - first_bytes
) / elapsed
progress_snapshot = DownloadProgress(
percent_complete=percent,
@@ -491,21 +558,23 @@ class Downloader:
timestamp=now.timestamp(),
)
await self._dispatch_progress_callback(progress_callback, progress_snapshot)
await self._dispatch_progress_callback(
progress_callback, progress_snapshot
)
last_progress_report_time = now
# Download completed successfully
# Verify file size integrity before finalizing
final_size = os.path.getsize(part_path) if os.path.exists(part_path) else 0
final_size = (
os.path.getsize(part_path) if os.path.exists(part_path) else 0
)
expected_size = total_size if total_size > 0 else None
integrity_error: Optional[str] = None
if final_size <= 0:
integrity_error = "Downloaded file is empty"
elif expected_size is not None and final_size != expected_size:
integrity_error = (
f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
)
integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
if integrity_error is not None:
logger.error(
@@ -555,7 +624,9 @@ class Downloader:
rename_attempt = 0
rename_success = False
while rename_attempt < max_rename_attempts and not rename_success:
while (
rename_attempt < max_rename_attempts and not rename_success
):
try:
# If the destination file exists, remove it first (Windows safe)
if os.path.exists(save_path):
@@ -566,11 +637,18 @@ class Downloader:
except PermissionError as e:
rename_attempt += 1
if rename_attempt < max_rename_attempts:
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})")
logger.info(
f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})"
)
await asyncio.sleep(2)
else:
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
return False, f"Failed to finalize download: {str(e)}"
logger.error(
f"Failed to rename file after {max_rename_attempts} attempts: {e}"
)
return (
False,
f"Failed to finalize download: {str(e)}",
)
final_size = os.path.getsize(save_path)
@@ -583,8 +661,9 @@ class Downloader:
bytes_per_second=0.0,
timestamp=datetime.now().timestamp(),
)
await self._dispatch_progress_callback(progress_callback, final_snapshot)
await self._dispatch_progress_callback(
progress_callback, final_snapshot
)
return True, save_path
@@ -597,7 +676,9 @@ class Downloader:
DownloadRestartRequested,
) as e:
retry_count += 1
logger.warning(f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}")
logger.warning(
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
)
if retry_count <= self.max_retries:
# Calculate delay with exponential backoff
@@ -615,7 +696,10 @@ class Downloader:
continue
else:
logger.error(f"Max retries exceeded for download: {e}")
return False, f"Network error after {self.max_retries + 1} attempts: {str(e)}"
return (
False,
f"Network error after {self.max_retries + 1} attempts: {str(e)}",
)
except Exception as e:
logger.error(f"Unexpected download error: {e}")
@@ -645,7 +729,7 @@ class Downloader:
url: str,
use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None,
return_headers: bool = False
return_headers: bool = False,
) -> Tuple[bool, Union[bytes, str], Optional[Dict]]:
"""
Download a file to memory (for small files like preview images)
@@ -663,16 +747,22 @@ class Downloader:
session = await self.session
# Debug log for proxy mode at request time
if self.proxy_url:
logger.debug(f"[download_to_memory] Using app-level proxy: {self.proxy_url}")
logger.debug(
f"[download_to_memory] Using app-level proxy: {self.proxy_url}"
)
else:
logger.debug("[download_to_memory] Using system-level proxy (trust_env) if configured.")
logger.debug(
"[download_to_memory] Using system-level proxy (trust_env) if configured."
)
# Prepare headers
headers = self._get_auth_headers(use_auth)
if custom_headers:
headers.update(custom_headers)
async with session.get(url, headers=headers, proxy=self.proxy_url) as response:
async with session.get(
url, headers=headers, proxy=self.proxy_url
) as response:
if response.status == 200:
content = await response.read()
if return_headers:
@@ -700,7 +790,7 @@ class Downloader:
self,
url: str,
use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None
custom_headers: Optional[Dict[str, str]] = None,
) -> Tuple[bool, Union[Dict, str]]:
"""
Get response headers without downloading the full content
@@ -717,16 +807,22 @@ class Downloader:
session = await self.session
# Debug log for proxy mode at request time
if self.proxy_url:
logger.debug(f"[get_response_headers] Using app-level proxy: {self.proxy_url}")
logger.debug(
f"[get_response_headers] Using app-level proxy: {self.proxy_url}"
)
else:
logger.debug("[get_response_headers] Using system-level proxy (trust_env) if configured.")
logger.debug(
"[get_response_headers] Using system-level proxy (trust_env) if configured."
)
# Prepare headers
headers = self._get_auth_headers(use_auth)
if custom_headers:
headers.update(custom_headers)
async with session.head(url, headers=headers, proxy=self.proxy_url) as response:
async with session.head(
url, headers=headers, proxy=self.proxy_url
) as response:
if response.status == 200:
return True, dict(response.headers)
else:
@@ -742,7 +838,7 @@ class Downloader:
url: str,
use_auth: bool = False,
custom_headers: Optional[Dict[str, str]] = None,
**kwargs
**kwargs,
) -> Tuple[bool, Union[Dict, str]]:
"""
Make a generic HTTP request and return JSON response
@@ -763,7 +859,9 @@ class Downloader:
if self.proxy_url:
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
else:
logger.debug("[make_request] Using system-level proxy (trust_env) if configured.")
logger.debug(
"[make_request] Using system-level proxy (trust_env) if configured."
)
# Prepare headers
headers = self._get_auth_headers(use_auth)
@@ -771,10 +869,12 @@ class Downloader:
headers.update(custom_headers)
# Add proxy to kwargs if not already present
if 'proxy' not in kwargs:
kwargs['proxy'] = self.proxy_url
if "proxy" not in kwargs:
kwargs["proxy"] = self.proxy_url
async with session.request(method, url, headers=headers, **kwargs) as response:
async with session.request(
method, url, headers=headers, **kwargs
) as response:
if response.status == 200:
# Try to parse as JSON, fall back to text
try:

View File

@@ -48,7 +48,9 @@ class LoraService(BaseModelService):
"notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False),
"update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool(lora_data.get("skip_metadata_refresh", False)),
"skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False)
),
"sub_type": sub_type,
"civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True
@@ -62,6 +64,68 @@ class LoraService(BaseModelService):
if first_letter:
data = self._filter_by_first_letter(data, first_letter)
# Handle name pattern filters
name_pattern_include = kwargs.get("name_pattern_include", [])
name_pattern_exclude = kwargs.get("name_pattern_exclude", [])
name_pattern_use_regex = kwargs.get("name_pattern_use_regex", False)
if name_pattern_include or name_pattern_exclude:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in data:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if name_pattern_exclude:
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_exclude, name_pattern_use_regex
):
excluded = True
break
if excluded:
continue
# Check include patterns
if name_pattern_include:
included = False
for name in names_to_check:
if matches_any_pattern(
name, name_pattern_include, name_pattern_use_regex
):
included = True
break
if not included:
continue
filtered.append(lora)
data = filtered
return data
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
@@ -368,9 +432,7 @@ class LoraService(BaseModelService):
rng.uniform(clip_strength_min, clip_strength_max), 2
)
else:
clip_str = round(
rng.uniform(clip_strength_min, clip_strength_max), 2
)
clip_str = round(rng.uniform(clip_strength_min, clip_strength_max), 2)
result_loras.append(
{
@@ -485,12 +547,69 @@ class LoraService(BaseModelService):
if bool(lora.get("license_flags", 127) & (1 << 1))
]
# Apply name pattern filters
name_patterns = filter_section.get("namePatterns", {})
include_patterns = name_patterns.get("include", [])
exclude_patterns = name_patterns.get("exclude", [])
use_regex = name_patterns.get("useRegex", False)
if include_patterns or exclude_patterns:
import re
def matches_pattern(name, pattern, use_regex):
"""Check if name matches pattern (regex or substring)"""
if not name:
return False
if use_regex:
try:
return bool(re.search(pattern, name, re.IGNORECASE))
except re.error:
# Invalid regex, fall back to substring match
return pattern.lower() in name.lower()
else:
return pattern.lower() in name.lower()
def matches_any_pattern(name, patterns, use_regex):
"""Check if name matches any of the patterns"""
if not patterns:
return True
return any(matches_pattern(name, p, use_regex) for p in patterns)
filtered = []
for lora in available_loras:
model_name = lora.get("model_name", "")
file_name = lora.get("file_name", "")
names_to_check = [n for n in [model_name, file_name] if n]
# Check exclude patterns first
excluded = False
if exclude_patterns:
for name in names_to_check:
if matches_any_pattern(name, exclude_patterns, use_regex):
excluded = True
break
if excluded:
continue
# Check include patterns
if include_patterns:
included = False
for name in names_to_check:
if matches_any_pattern(name, include_patterns, use_regex):
included = True
break
if not included:
continue
filtered.append(lora)
available_loras = filtered
return available_loras
async def get_cycler_list(
self,
pool_config: Optional[Dict] = None,
sort_by: str = "filename"
self, pool_config: Optional[Dict] = None, sort_by: str = "filename"
) -> List[Dict]:
"""
Get filtered and sorted LoRA list for cycling.
@@ -518,16 +637,16 @@ class LoraService(BaseModelService):
available_loras,
key=lambda x: (
(x.get("model_name") or x.get("file_name", "")).lower(),
x.get("file_path", "").lower()
)
x.get("file_path", "").lower(),
),
)
else: # Default to filename
available_loras = sorted(
available_loras,
key=lambda x: (
x.get("file_name", "").lower(),
x.get("file_path", "").lower()
)
x.get("file_path", "").lower(),
),
)
# Return minimal data needed for cycling

View File

@@ -122,11 +122,25 @@ async def get_metadata_provider(provider_name: str = None):
provider_manager = await ModelMetadataProviderManager.get_instance()
provider = (
provider_manager._get_provider(provider_name)
if provider_name
else provider_manager._get_provider()
)
try:
provider = (
provider_manager._get_provider(provider_name)
if provider_name
else provider_manager._get_provider()
)
except ValueError as e:
# Provider not initialized, attempt to initialize
if "No default provider set" in str(e) or "not registered" in str(e):
logger.warning(f"Metadata provider not initialized ({e}), initializing now...")
await initialize_metadata_providers()
provider_manager = await ModelMetadataProviderManager.get_instance()
provider = (
provider_manager._get_provider(provider_name)
if provider_name
else provider_manager._get_provider()
)
else:
raise
return _wrap_provider_with_rate_limit(provider_name, provider)

View File

@@ -14,7 +14,6 @@ from ..utils.metadata_manager import MetadataManager
from ..utils.civitai_utils import resolve_license_info
from .model_cache import ModelCache
from .model_hash_index import ModelHashIndex
from ..utils.constants import PREVIEW_EXTENSIONS
from .model_lifecycle_service import delete_model_artifacts
from .service_registry import ServiceRegistry
from .websocket_manager import ws_manager
@@ -1443,12 +1442,11 @@ class ModelScanner:
if not file_path:
return None
base_name = os.path.splitext(file_path)[0]
for ext in PREVIEW_EXTENSIONS:
preview_path = f"{base_name}{ext}"
if os.path.exists(preview_path):
return config.get_preview_static_url(preview_path)
dir_path = os.path.dirname(file_path)
base_name = os.path.splitext(os.path.basename(file_path))[0]
preview_path = find_preview_file(base_name, dir_path)
if preview_path:
return config.get_preview_static_url(preview_path)
return None

View File

@@ -13,7 +13,7 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
logger = logging.getLogger(__name__)
@@ -1252,14 +1252,23 @@ class ModelUpdateService:
return None
blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
settings = getattr(self, "_settings", None)
if settings is not None and hasattr(settings, "get"):
try:
blur_mature_content = bool(settings.get("blur_mature_content", True))
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings.get("mature_blur_level", "R")}
)
except Exception: # pragma: no cover - defensive guard
blur_mature_content = True
mature_threshold = resolve_mature_threshold({"mature_blur_level": "R"})
selected, _ = select_preview_media(candidates, blur_mature_content=blur_mature_content)
selected, _ = select_preview_media(
candidates,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
if not selected:
return None

View File

@@ -9,7 +9,7 @@ from urllib.parse import urlparse
from ..utils.constants import CARD_PREVIEW_WIDTH, PREVIEW_EXTENSIONS
from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import select_preview_media
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
@@ -49,9 +49,13 @@ class PreviewAssetService:
blur_mature_content = bool(
settings_manager.get("blur_mature_content", True)
)
mature_threshold = resolve_mature_threshold(
{"mature_blur_level": settings_manager.get("mature_blur_level", "R")}
)
first_preview, nsfw_level = select_preview_media(
images,
blur_mature_content=blur_mature_content,
mature_threshold=mature_threshold,
)
if not first_preview:
@@ -216,4 +220,3 @@ class PreviewAssetService:
if "webm" in content_type:
return ".webm"
return ".mp4"

View File

@@ -12,6 +12,7 @@ from typing import Any, Awaitable, Dict, Iterable, List, Mapping, Optional, Sequ
from platformdirs import user_config_dir
from ..utils.constants import DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import APP_NAME, ensure_settings_file, get_legacy_settings_path
from ..utils.tag_priorities import (
PriorityTagEntry,
@@ -59,6 +60,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"optimize_example_images": True,
"auto_download_example_images": False,
"blur_mature_content": True,
"mature_blur_level": "R",
"autoplay_on_hover": False,
"display_density": "default",
"card_info_display": "always",
@@ -274,6 +276,16 @@ class SettingsManager:
self.settings["metadata_refresh_skip_paths"] = []
inserted_defaults = True
had_mature_level = "mature_blur_level" in self.settings
raw_mature_level = self.settings.get("mature_blur_level")
normalized_mature_level = self.normalize_mature_blur_level(raw_mature_level)
if normalized_mature_level != raw_mature_level:
self.settings["mature_blur_level"] = normalized_mature_level
if had_mature_level:
updated_existing = True
else:
inserted_defaults = True
for key, value in defaults.items():
if key == "priority_tags":
continue
@@ -608,6 +620,7 @@ class SettingsManager:
'optimizeExampleImages': 'optimize_example_images',
'autoDownloadExampleImages': 'auto_download_example_images',
'blurMatureContent': 'blur_mature_content',
'matureBlurLevel': 'mature_blur_level',
'autoplayOnHover': 'autoplay_on_hover',
'displayDensity': 'display_density',
'cardInfoDisplay': 'card_info_display',
@@ -860,6 +873,13 @@ class SettingsManager:
return normalized
def normalize_mature_blur_level(self, value: Any) -> str:
if isinstance(value, str):
normalized = value.strip().upper()
if normalized in VALID_MATURE_BLUR_LEVELS:
return normalized
return "R"
def normalize_auto_organize_exclusions(self, value: Any) -> List[str]:
if value is None:
return []
@@ -1012,6 +1032,8 @@ class SettingsManager:
value = self.normalize_auto_organize_exclusions(value)
elif key == "metadata_refresh_skip_paths":
value = self.normalize_metadata_refresh_skip_paths(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
self.settings[key] = value
portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool):

View File

@@ -40,49 +40,39 @@ async def calculate_sha256(file_path: str) -> str:
return sha256_hash.hexdigest()
def find_preview_file(base_name: str, dir_path: str) -> str:
"""Find preview file for given base name in directory"""
"""Find preview file for given base name in directory.
Performs an exact-case check first (fast path), then falls back to a
case-insensitive scan so that files like ``model.WEBP`` or ``model.Png``
are discovered on case-sensitive filesystems.
"""
temp_extensions = PREVIEW_EXTENSIONS.copy()
# Add example extension for compatibility
# https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225
# The preview image will be optimized to lora-name.webp, so it won't affect other logic
temp_extensions.append(".example.0.jpeg")
# Fast path: exact-case match
for ext in temp_extensions:
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
if os.path.exists(full_pattern):
# Check if this is an image and not already webp
# TODO: disable the optimization for now, maybe add a config option later
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
# try:
# # Optimize the image to webp format
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
# # Use ExifUtils to optimize the image
# with open(full_pattern, 'rb') as f:
# image_data = f.read()
# optimized_data, _ = ExifUtils.optimize_image(
# image_data=image_data,
# target_width=CARD_PREVIEW_WIDTH,
# format='webp',
# quality=85,
# preserve_metadata=False
# )
# # Save the optimized webp file
# with open(webp_path, 'wb') as f:
# f.write(optimized_data)
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
# return webp_path.replace(os.sep, "/")
# except Exception as e:
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
# # Fall back to original file if optimization fails
# return full_pattern.replace(os.sep, "/")
# Return the original path for webp images or non-image files
return full_pattern.replace(os.sep, "/")
# Slow path: case-insensitive match for systems with mixed-case extensions
# (e.g. .WEBP, .Png, .JPG placed manually or by external tools)
try:
dir_entries = os.listdir(dir_path)
except OSError:
return ""
base_lower = base_name.lower()
for ext in temp_extensions:
target = f"{base_lower}{ext}" # ext is already lowercase
for entry in dir_entries:
if entry.lower() == target:
return os.path.join(dir_path, entry).replace(os.sep, "/")
return ""
def get_preview_extension(preview_path: str) -> str:

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
from typing import Mapping, Optional, Sequence, Tuple
from typing import Any, Mapping, Optional, Sequence, Tuple
from .constants import NSFW_LEVELS
PreviewMedia = Mapping[str, object]
VALID_MATURE_BLUR_LEVELS = ("PG13", "R", "X", "XXX")
def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
@@ -19,17 +20,36 @@ def _extract_nsfw_level(entry: Mapping[str, object]) -> int:
return 0
def resolve_mature_threshold(settings: Mapping[str, Any] | None) -> int:
"""Resolve the configured mature blur threshold from settings.
Allowed values are ``PG13``, ``R``, ``X``, and ``XXX``. Any invalid or
missing value falls back to ``R``.
"""
if not isinstance(settings, Mapping):
return NSFW_LEVELS.get("R", 4)
raw_level = settings.get("mature_blur_level", "R")
normalized = str(raw_level).strip().upper()
if normalized not in VALID_MATURE_BLUR_LEVELS:
normalized = "R"
return NSFW_LEVELS.get(normalized, NSFW_LEVELS.get("R", 4))
def select_preview_media(
images: Sequence[Mapping[str, object]] | None,
*,
blur_mature_content: bool,
mature_threshold: int | None = None,
) -> Tuple[Optional[PreviewMedia], int]:
"""Select the most appropriate preview media entry.
When ``blur_mature_content`` is enabled we first try to return the first media
item with an ``nsfwLevel`` lower than :pydata:`NSFW_LEVELS["R"]`. If none are
available we return the media entry with the lowest NSFW level. When the
setting is disabled we simply return the first entry.
item with an ``nsfwLevel`` lower than the configured mature threshold
(defaults to :pydata:`NSFW_LEVELS["R"]`). If none are available we return
the media entry with the lowest NSFW level. When the setting is disabled we
simply return the first entry.
"""
if not images:
@@ -45,7 +65,9 @@ def select_preview_media(
if not blur_mature_content:
return selected, selected_level
safe_threshold = NSFW_LEVELS.get("R", 4)
safe_threshold = (
mature_threshold if isinstance(mature_threshold, int) else NSFW_LEVELS.get("R", 4)
)
for candidate in candidates:
level = _extract_nsfw_level(candidate)
if level < safe_threshold:
@@ -60,4 +82,4 @@ def select_preview_media(
return selected, selected_level
__all__ = ["select_preview_media"]
__all__ = ["resolve_mature_threshold", "select_preview_media", "VALID_MATURE_BLUR_LEVELS"]

View File

@@ -687,7 +687,7 @@
padding: 12px 16px;
background: oklch(var(--lora-warning) / 0.1);
border: 1px solid var(--lora-warning);
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
border-radius: var(--border-radius-sm);
color: var(--text-color);
}

View File

@@ -151,7 +151,8 @@ body.modal-open {
[data-theme="dark"] .changelog-section,
[data-theme="dark"] .update-info,
[data-theme="dark"] .info-item,
[data-theme="dark"] .path-preview {
[data-theme="dark"] .path-preview,
[data-theme="dark"] #bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
@@ -349,3 +350,87 @@ button:disabled,
margin-top: var(--space-1);
text-align: center;
}
/* Bulk Download Missing LoRAs Modal */
#bulkDownloadMissingLorasModal .modal-body {
padding: var(--space-3);
}
#bulkDownloadMissingLorasModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-preview {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
#bulkDownloadMissingLorasModal .preview-title {
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--text-color);
font-size: 0.95em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list {
list-style: none;
padding: 0;
margin: 0;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-1) 0;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li:last-child {
border-bottom: none;
}
#bulkDownloadMissingLorasModal .bulk-download-loras-list li.more-items {
font-style: italic;
opacity: 0.7;
text-align: center;
justify-content: center;
padding: var(--space-2) 0;
}
#bulkDownloadMissingLorasModal .lora-name {
font-weight: 500;
color: var(--text-color);
flex: 1;
}
#bulkDownloadMissingLorasModal .lora-version {
font-size: 0.85em;
opacity: 0.7;
margin-left: var(--space-1);
color: var(--text-muted);
}
#bulkDownloadMissingLorasModal .confirmation-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-2);
background: rgba(59, 130, 246, 0.1);
border-radius: var(--border-radius-sm);
font-size: 0.9em;
color: var(--text-color);
}
#bulkDownloadMissingLorasModal .confirmation-note i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}

View File

@@ -251,7 +251,7 @@ export class BaseModelApiClient {
replaceModelPreview(filePath) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*,video/mp4';
input.accept = 'image/*,image/webp,video/mp4';
input.onchange = async () => {
if (!input.files || !input.files[0]) return;

View File

@@ -2,6 +2,8 @@ import { BaseContextMenu } from './BaseContextMenu.js';
import { state } from '../../state/index.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { updateElementText, translate } from '../../utils/i18nHelpers.js';
import { bulkMissingLoraDownloadManager } from '../../managers/BulkMissingLoraDownloadManager.js';
import { showToast } from '../../utils/uiHelpers.js';
export class BulkContextMenu extends BaseContextMenu {
constructor() {
@@ -37,6 +39,7 @@ export class BulkContextMenu extends BaseContextMenu {
const moveAllItem = this.menu.querySelector('[data-action="move-all"]');
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
if (sendToWorkflowAppendItem) {
sendToWorkflowAppendItem.style.display = config.sendToWorkflow ? 'flex' : 'none';
@@ -71,6 +74,10 @@ export class BulkContextMenu extends BaseContextMenu {
if (setContentRatingItem) {
setContentRatingItem.style.display = config.setContentRating ? 'flex' : 'none';
}
if (downloadMissingLorasItem) {
// Only show for recipes page
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
const resumeMetadataRefreshItem = this.menu.querySelector('[data-action="resume-metadata-refresh"]');
@@ -178,6 +185,9 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'download-missing-loras':
this.handleDownloadMissingLoras();
break;
case 'clear':
bulkManager.clearSelection();
break;
@@ -185,4 +195,39 @@ export class BulkContextMenu extends BaseContextMenu {
console.warn(`Unknown bulk action: ${action}`);
}
}
/**
* Handle downloading missing LoRAs for selected recipes
*/
async handleDownloadMissingLoras() {
if (state.selectedModels.size === 0) {
return;
}
// Get selected recipes from the virtual scroller
const selectedRecipes = [];
state.selectedModels.forEach(filePath => {
const card = document.querySelector(`.model-card[data-filepath="${CSS.escape(filePath)}"]`);
if (card && card.recipeData) {
selectedRecipes.push(card.recipeData);
}
});
if (selectedRecipes.length === 0) {
// Try to get recipes from virtual scroller state
const items = state.virtualScroller?.items || [];
items.forEach(recipe => {
if (recipe.file_path && state.selectedModels.has(recipe.file_path)) {
selectedRecipes.push(recipe);
}
});
}
if (selectedRecipes.length === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
}
}

View File

@@ -6,7 +6,7 @@ import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
class RecipeCard {
constructor(recipe, clickHandler) {
@@ -74,7 +74,8 @@ class RecipeCard {
// NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) {
card.classList.add('nsfw-content');

View File

@@ -1299,7 +1299,6 @@ class RecipeModal {
// New method to navigate to the LoRAs page
navigateToLorasPage(specificLoraIndex = null) {
debugger;
// Close the current modal
modalManager.closeModal('recipeModal');

View File

@@ -4,7 +4,7 @@ import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
@@ -478,7 +478,8 @@ export function createModelCard(model, modelType) {
card.dataset.nsfwLevel = nsfwLevel;
// Determine if the preview should be blurred based on NSFW level and user settings
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
if (shouldBlur) {
card.classList.add('nsfw-content');
}

View File

@@ -6,7 +6,7 @@
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { getModelApiClient } from '../../../api/modelApiFactory.js';
import { NSFW_LEVELS } from '../../../utils/constants.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
/**
@@ -607,7 +607,8 @@ function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
}
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
let overlay = mediaWrapper.querySelector('.nsfw-overlay');
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');

View File

@@ -6,7 +6,7 @@ import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { modalManager } from '../../../managers/ModalManager.js';
import { translate } from '../../../utils/i18nHelpers.js';
import { NSFW_LEVELS } from '../../../utils/constants.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import {
initLazyLoading,
initNsfwBlurHandlers,
@@ -184,7 +184,8 @@ function renderMediaItem(img, index, exampleFiles) {
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";

View File

@@ -0,0 +1,357 @@
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js';
/**
* Manager for downloading missing LoRAs for selected recipes in bulk
*/
export class BulkMissingLoraDownloadManager {
constructor() {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = [];
this.pendingRecipes = [];
}
/**
* Collect missing LoRAs from selected recipes with deduplication
* @param {Array} selectedRecipes - Array of selected recipe objects
* @returns {Object} - Object containing unique missing LoRAs and statistics
*/
collectMissingLoras(selectedRecipes) {
const uniqueLoras = new Map(); // key: hash or modelVersionId, value: lora object
const missingLorasByRecipe = new Map();
let totalMissingCount = 0;
selectedRecipes.forEach(recipe => {
const missingLoras = [];
if (recipe.loras && Array.isArray(recipe.loras)) {
recipe.loras.forEach(lora => {
// Only include LoRAs not in library and not deleted
if (!lora.inLibrary && !lora.isDeleted) {
const uniqueKey = lora.hash || lora.id || lora.modelVersionId;
if (uniqueKey && !uniqueLoras.has(uniqueKey)) {
// Store the LoRA info
uniqueLoras.set(uniqueKey, {
...lora,
modelId: lora.modelId || lora.model_id,
id: lora.id || lora.modelVersionId,
});
}
missingLoras.push(lora);
totalMissingCount++;
}
});
}
if (missingLoras.length > 0) {
missingLorasByRecipe.set(recipe.id || recipe.file_path, {
recipe,
missingLoras
});
}
});
return {
uniqueLoras: Array.from(uniqueLoras.values()),
uniqueCount: uniqueLoras.size,
totalMissingCount,
missingLorasByRecipe
};
}
/**
* Show confirmation modal for downloading missing LoRAs
* @param {Object} stats - Statistics about missing LoRAs
* @returns {Promise<boolean>} - Whether user confirmed
*/
async showConfirmationModal(stats) {
const { uniqueCount, totalMissingCount, uniqueLoras } = stats;
if (uniqueCount === 0) {
showToast('toast.recipes.noMissingLoras', {}, 'info');
return false;
}
// Store pending data for confirmation
this.pendingLoras = uniqueLoras;
// Update modal content
const messageEl = document.getElementById('bulkDownloadMissingLorasMessage');
const listEl = document.getElementById('bulkDownloadMissingLorasList');
const confirmBtn = document.getElementById('bulkDownloadMissingLorasConfirmBtn');
if (messageEl) {
messageEl.textContent = translate('modals.bulkDownloadMissingLoras.message', {
uniqueCount,
totalCount: totalMissingCount
}, `Found ${uniqueCount} unique missing LoRAs (from ${totalMissingCount} total across selected recipes).`);
}
if (listEl) {
listEl.innerHTML = uniqueLoras.slice(0, 10).map(lora => `
<li>
<span class="lora-name">${lora.name || lora.file_name || 'Unknown'}</span>
${lora.version ? `<span class="lora-version">${lora.version}</span>` : ''}
</li>
`).join('') +
(uniqueLoras.length > 10 ? `
<li class="more-items">${translate('modals.bulkDownloadMissingLoras.moreItems', { count: uniqueLoras.length - 10 }, `...and ${uniqueLoras.length - 10} more`)}</li>
` : '');
}
if (confirmBtn) {
confirmBtn.innerHTML = `
<i class="fas fa-download"></i>
${translate('modals.bulkDownloadMissingLoras.downloadButton', { count: uniqueCount }, `Download ${uniqueCount} LoRA(s)`)}
`;
}
// Show modal
modalManager.showModal('bulkDownloadMissingLorasModal');
// Return a promise that will be resolved when user confirms or cancels
return new Promise((resolve) => {
this.confirmResolve = resolve;
});
}
/**
* Called when user confirms download in modal
*/
async confirmDownload() {
modalManager.closeModal('bulkDownloadMissingLorasModal');
if (this.confirmResolve) {
this.confirmResolve(true);
this.confirmResolve = null;
}
// Execute download
await this.executeDownload(this.pendingLoras);
this.pendingLoras = [];
}
/**
* Download missing LoRAs for selected recipes
* @param {Array} selectedRecipes - Array of selected recipe objects
*/
async downloadMissingLoras(selectedRecipes) {
if (!selectedRecipes || selectedRecipes.length === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
// Store selected recipes
this.pendingRecipes = selectedRecipes;
// Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes);
if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
return;
}
// Show confirmation modal
const confirmed = await this.showConfirmationModal(stats);
if (!confirmed) {
return;
}
}
/**
* Execute the download process
* @param {Array} lorasToDownload - Array of unique LoRAs to download
*/
async executeDownload(lorasToDownload) {
const totalLoras = lorasToDownload.length;
// Get LoRA root directory
const loraRoot = await this.getLoraRoot();
if (!loraRoot) {
showToast('toast.recipes.noLoraRootConfigured', {}, 'error');
return;
}
// Generate batch download ID
const batchDownloadId = Date.now().toString();
// Use default paths
const useDefaultPaths = true;
// Set up WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
// Show download progress UI
const loadingManager = state.loadingManager;
const updateProgress = loadingManager.showDownloadProgress(totalLoras);
let completedDownloads = 0;
let failedDownloads = 0;
let currentLoraProgress = 0;
// Set up WebSocket message handler
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Handle download ID confirmation
if (data.type === 'download_id') {
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
return;
}
// Process progress updates
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
currentLoraProgress = data.progress;
const currentLora = lorasToDownload[completedDownloads + failedDownloads];
const loraName = currentLora ? (currentLora.name || currentLora.file_name || 'Unknown') : '';
const metrics = {
bytesDownloaded: data.bytes_downloaded,
totalBytes: data.total_bytes,
bytesPerSecond: data.bytes_per_second
};
updateProgress(currentLoraProgress, completedDownloads, loraName, metrics);
// Update status message
if (currentLoraProgress < 3) {
loadingManager.setStatus(
translate('recipes.controls.import.startingDownload',
{ current: completedDownloads + failedDownloads + 1, total: totalLoras },
`Starting download for LoRA ${completedDownloads + failedDownloads + 1}/${totalLoras}`
)
);
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
loadingManager.setStatus(
translate('recipes.controls.import.downloadingLoras', {}, `Downloading LoRAs...`)
);
}
}
};
// Wait for WebSocket to connect
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(error);
};
});
// Download each LoRA sequentially
for (let i = 0; i < lorasToDownload.length; i++) {
const lora = lorasToDownload[i];
currentLoraProgress = 0;
loadingManager.setStatus(
translate('recipes.controls.import.startingDownload',
{ current: i + 1, total: totalLoras },
`Starting download for LoRA ${i + 1}/${totalLoras}`
)
);
updateProgress(0, completedDownloads, lora.name || lora.file_name || 'Unknown');
try {
const modelId = lora.modelId || lora.model_id;
const versionId = lora.id || lora.modelVersionId;
if (!modelId && !versionId) {
console.warn(`Skipping LoRA without model/version ID:`, lora);
failedDownloads++;
continue;
}
const response = await this.loraApiClient.downloadModel(
modelId,
versionId,
loraRoot,
'', // Empty relative path, use default paths
useDefaultPaths,
batchDownloadId
);
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
}
} catch (error) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
}
}
// Close WebSocket
ws.close();
// Hide loading UI
loadingManager.hide();
// Show completion message
if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
total: totalLoras
}, 'warning');
}
// Refresh the recipes list to update LoRA status
if (window.recipeManager) {
window.recipeManager.loadRecipes();
}
}
/**
* Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null
*/
async getLoraRoot() {
try {
// Fetch available LoRA roots from API
const rootsData = await this.loraApiClient.fetchModelRoots();
if (!rootsData || !rootsData.roots || rootsData.roots.length === 0) {
console.error('No LoRA roots available');
return null;
}
// Try to get default root from settings
const defaultRootKey = 'default_lora_root';
const defaultRoot = state.global?.settings?.[defaultRootKey];
// If default root is set and exists in available roots, use it
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
return defaultRoot;
}
// Otherwise, return the first available root
return rootsData.roots[0];
} catch (error) {
console.error('Error getting LoRA root:', error);
return null;
}
}
}
// Export singleton instance
export const bulkMissingLoraDownloadManager = new BulkMissingLoraDownloadManager();
// Make available globally for HTML onclick handlers
if (typeof window !== 'undefined') {
window.bulkMissingLoraDownloadManager = bulkMissingLoraDownloadManager;
}

View File

@@ -142,6 +142,28 @@ export class ImportManager {
// Reset duplicate related properties
this.duplicateRecipes = [];
// Reset button visibility in location step
this.resetLocationStepButtons();
}
resetLocationStepButtons() {
// Reset buttons to default state
const locationStep = document.getElementById('locationStep');
if (!locationStep) return;
const backBtn = locationStep.querySelector('.secondary-btn');
const primaryBtn = locationStep.querySelector('.primary-btn');
// Back button - show
if (backBtn) {
backBtn.style.display = 'inline-block';
}
// Primary button - reset text
if (primaryBtn) {
primaryBtn.textContent = translate('recipes.controls.import.downloadAndSaveRecipe', {}, 'Download & Save Recipe');
}
}
toggleImportMode(mode) {
@@ -261,11 +283,57 @@ export class ImportManager {
this.loadDefaultPathSetting();
this.updateTargetPath();
// Update download button with missing LoRA count (if any)
if (this.missingLoras && this.missingLoras.length > 0) {
this.updateDownloadButtonCount();
this.updateImportButtonsVisibility(true);
} else {
this.updateImportButtonsVisibility(false);
}
} catch (error) {
showToast('toast.recipes.importFailed', { message: error.message }, 'error');
}
}
updateImportButtonsVisibility(hasMissingLoras) {
// Update primary button text based on whether there are missing LoRAs
const locationStep = document.getElementById('locationStep');
if (!locationStep) return;
const backBtn = locationStep.querySelector('.secondary-btn');
const primaryBtn = locationStep.querySelector('.primary-btn');
// Back button - always show
if (backBtn) {
backBtn.style.display = 'inline-block';
}
// Update primary button text
if (primaryBtn) {
const downloadCountSpan = locationStep.querySelector('#downloadLoraCount');
if (hasMissingLoras) {
// Rebuild button content to ensure proper structure
const buttonText = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download');
primaryBtn.innerHTML = `${buttonText} <span id="downloadLoraCount"></span>`;
} else {
primaryBtn.textContent = translate('recipes.controls.import.downloadAndSaveRecipe', {}, 'Download & Save Recipe');
}
}
}
updateDownloadButtonCount() {
// Update the download count badge on the primary button
const locationStep = document.getElementById('locationStep');
if (!locationStep) return;
const downloadCountSpan = locationStep.querySelector('#downloadLoraCount');
if (downloadCountSpan) {
const missingCount = this.missingLoras?.length || 0;
downloadCountSpan.textContent = missingCount > 0 ? `(${missingCount})` : '';
}
}
backToUpload() {
this.stepManager.showStep('uploadStep');
@@ -426,12 +494,54 @@ export class ImportManager {
const modalTitle = document.querySelector('#importModal h2');
if (modalTitle) modalTitle.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
// Update the save button text
const saveButton = document.querySelector('#locationStep .primary-btn');
if (saveButton) saveButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
// Update button texts and show download count
const locationStep = document.getElementById('locationStep');
if (!locationStep) return;
// Hide the back button
const backButton = document.querySelector('#locationStep .secondary-btn');
if (backButton) backButton.style.display = 'none';
const primaryBtn = locationStep.querySelector('.primary-btn');
const backBtn = locationStep.querySelector('.secondary-btn');
// primaryBtn should be the "Import & Download" button
if (primaryBtn) {
const buttonText = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download');
primaryBtn.innerHTML = `${buttonText} <span id="downloadLoraCount">(${recipeData.loras?.length || 0})</span>`;
}
// Hide the "Back" button in download-only mode
if (backBtn) {
backBtn.style.display = 'none';
}
}
saveRecipeWithoutDownload() {
// Call save recipe with skip download flag
return this.downloadManager.saveRecipe(true);
}
async saveRecipeOnlyFromDetails() {
// Validate recipe name first
if (!this.recipeName) {
showToast('toast.recipes.enterRecipeName', {}, 'error');
return;
}
// Mark deleted LoRAs as excluded
if (this.recipeData && this.recipeData.loras) {
this.recipeData.loras.forEach(lora => {
if (lora.isDeleted) {
lora.exclude = true;
}
});
}
// Update missing LoRAs list
this.missingLoras = this.recipeData.loras.filter(lora =>
!lora.existsLocally && !lora.isDeleted);
// For import only, we don't need downloadableLoRAs
this.downloadableLoRAs = [];
// Save recipe without downloading
await this.downloadManager.saveRecipe(true);
}
}

View File

@@ -291,6 +291,19 @@ export class ModalManager {
});
}
// Register bulkDownloadMissingLorasModal
const bulkDownloadMissingLorasModal = document.getElementById('bulkDownloadMissingLorasModal');
if (bulkDownloadMissingLorasModal) {
this.registerModal('bulkDownloadMissingLorasModal', {
element: bulkDownloadMissingLorasModal,
onClose: () => {
this.getModal('bulkDownloadMissingLorasModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}

View File

@@ -10,6 +10,8 @@ import { validatePriorityTagString, getPriorityTagSuggestionsMap, invalidatePrio
import { bannerService } from './BannerService.js';
import { sidebarManager } from '../components/SidebarManager.js';
const VALID_MATURE_BLUR_LEVELS = new Set(['PG13', 'R', 'X', 'XXX']);
export class SettingsManager {
constructor() {
this.initialized = false;
@@ -137,11 +139,25 @@ export class SettingsManager {
backendSettings?.metadata_refresh_skip_paths ?? defaults.metadata_refresh_skip_paths
);
merged.mature_blur_level = this.normalizeMatureBlurLevel(
backendSettings?.mature_blur_level ?? defaults.mature_blur_level
);
Object.keys(merged).forEach(key => this.backendSettingKeys.add(key));
return merged;
}
normalizeMatureBlurLevel(value) {
if (typeof value === 'string') {
const normalized = value.trim().toUpperCase();
if (VALID_MATURE_BLUR_LEVELS.has(normalized)) {
return normalized;
}
}
return 'R';
}
normalizePatternList(value) {
if (Array.isArray(value)) {
const sanitized = value
@@ -682,6 +698,13 @@ export class SettingsManager {
showOnlySFWCheckbox.checked = state.global.settings.show_only_sfw ?? false;
}
const matureBlurLevelSelect = document.getElementById('matureBlurLevel');
if (matureBlurLevelSelect) {
matureBlurLevelSelect.value = this.normalizeMatureBlurLevel(
state.global.settings.mature_blur_level
);
}
const usePortableCheckbox = document.getElementById('usePortableSettings');
if (usePortableCheckbox) {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
@@ -1811,7 +1834,9 @@ export class SettingsManager {
const element = document.getElementById(elementId);
if (!element) return;
const value = element.value;
const value = settingKey === 'mature_blur_level'
? this.normalizeMatureBlurLevel(element.value)
: element.value;
try {
// Update frontend state with mapped keys
@@ -1834,7 +1859,12 @@ export class SettingsManager {
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
if (settingKey === 'model_name_display' || settingKey === 'model_card_footer_action' || settingKey === 'update_flag_strategy') {
if (
settingKey === 'model_name_display'
|| settingKey === 'model_card_footer_action'
|| settingKey === 'update_flag_strategy'
|| settingKey === 'mature_blur_level'
) {
this.reloadContent();
}
} catch (error) {

View File

@@ -9,7 +9,7 @@ export class DownloadManager {
this.importManager = importManager;
}
async saveRecipe() {
async saveRecipe(skipDownload = false) {
// Check if we're in download-only mode (for existing recipe)
const isDownloadOnly = !!this.importManager.recipeId;
@@ -20,7 +20,10 @@ export class DownloadManager {
try {
// Show progress indicator
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
const loadingMessage = skipDownload
? translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...')
: (isDownloadOnly ? translate('recipes.controls.import.downloadingLoras', {}, 'Downloading LoRAs...') : translate('recipes.controls.import.savingRecipe', {}, 'Saving recipe...'));
this.importManager.loadingManager.showSimpleLoading(loadingMessage);
// Only send the complete recipe to save if not in download-only mode
if (!isDownloadOnly) {
@@ -98,15 +101,17 @@ export class DownloadManager {
}
}
// Check if we need to download LoRAs
// Check if we need to download LoRAs (skip if skipDownload is true)
let failedDownloads = 0;
if (this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
await this.downloadMissingLoras();
}
// Show success message
if (isDownloadOnly) {
if (failedDownloads === 0) {
if (skipDownload) {
showToast('toast.recipes.recipeSaved', {}, 'success');
} else if (failedDownloads === 0) {
showToast('toast.loras.downloadSuccessful', {}, 'success');
}
} else {

View File

@@ -325,7 +325,8 @@ export class RecipeDataManager {
}
updateNextButtonState() {
const nextButton = document.querySelector('#detailsStep .primary-btn');
const nextButton = document.getElementById('nextBtn');
const importOnlyBtn = document.getElementById('importOnlyBtn');
const actionsContainer = document.querySelector('#detailsStep .modal-actions');
if (!nextButton || !actionsContainer) return;
@@ -365,7 +366,7 @@ export class RecipeDataManager {
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
}
// Check for duplicates but don't change button actions
// Check for downloadable missing LoRAs
const missingNotDeleted = this.importManager.recipeData.loras.filter(
lora => !lora.existsLocally && !lora.isDeleted
).length;
@@ -374,8 +375,16 @@ export class RecipeDataManager {
nextButton.classList.remove('warning-btn');
if (missingNotDeleted > 0) {
nextButton.textContent = translate('recipes.controls.import.downloadMissingLoras', {}, 'Download Missing LoRAs');
// Show import only button and update primary button
if (importOnlyBtn) {
importOnlyBtn.style.display = 'inline-block';
}
nextButton.textContent = translate('recipes.controls.import.importAndDownload', {}, 'Import & Download') + ` (${missingNotDeleted})`;
} else {
// Hide import only button and show save recipe
if (importOnlyBtn) {
importOnlyBtn.style.display = 'none';
}
nextButton.textContent = translate('recipes.controls.import.saveRecipe', {}, 'Save Recipe');
}
}
@@ -440,8 +449,11 @@ export class RecipeDataManager {
// Store only downloadable LoRAs for the download step
this.importManager.downloadableLoRAs = this.importManager.missingLoras;
this.importManager.proceedToLocation();
} else if (this.importManager.missingLoras.length === 0 && this.importManager.recipeData.loras.some(l => !l.existsLocally)) {
// All missing LoRAs are deleted, save recipe without download
this.importManager.saveRecipe();
} else {
// Otherwise, save the recipe directly
// No missing LoRAs at all, save the recipe directly
this.importManager.saveRecipe();
}
}

View File

@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
optimize_example_images: true,
auto_download_example_images: false,
blur_mature_content: true,
mature_blur_level: 'R',
autoplay_on_hover: false,
display_density: 'default',
card_info_display: 'always',

View File

@@ -309,6 +309,15 @@ export const NSFW_LEVELS = {
BLOCKED: 32
};
export const VALID_MATURE_BLUR_LEVELS = ['PG13', 'R', 'X', 'XXX'];
export function getMatureBlurThreshold(settings = {}) {
const rawValue = settings?.mature_blur_level;
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim().toUpperCase() : '';
const levelName = VALID_MATURE_BLUR_LEVELS.includes(normalizedValue) ? normalizedValue : 'R';
return NSFW_LEVELS[levelName] ?? NSFW_LEVELS.R;
}
// Node type constants
export const NODE_TYPES = {
LORA_LOADER: 1,

View File

@@ -87,6 +87,9 @@
<i class="fas fa-redo"></i> <span>{{ t('loras.bulkOperations.resumeMetadataRefresh') }}</span>
</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
</div>
<div class="context-menu-item" data-action="move-all">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.bulkOperations.moveAll') }}</span>
</div>

View File

@@ -92,9 +92,10 @@
<!-- Duplicate recipes will be populated here -->
</div>
<div class="modal-actions">
<div class="modal-actions" id="detailsStepActions">
<button class="secondary-btn" onclick="importManager.backToUpload()">{{ t('common.actions.back') }}</button>
<button class="primary-btn" onclick="importManager.proceedFromDetails()">{{ t('common.actions.next') }}</button>
<button class="secondary-btn" id="importOnlyBtn" onclick="importManager.saveRecipeOnlyFromDetails()" style="display: none;">{{ t('recipes.controls.import.importRecipeOnly') }}</button>
<button class="primary-btn" id="nextBtn" onclick="importManager.proceedFromDetails()">{{ t('common.actions.next') }}</button>
</div>
</div>
@@ -159,7 +160,7 @@
<div class="modal-actions">
<button class="secondary-btn" onclick="importManager.backToDetails()">{{ t('common.actions.back') }}</button>
<button class="primary-btn" onclick="importManager.saveRecipe()">{{ t('recipes.controls.import.downloadAndSaveRecipe') }}</button>
<button class="primary-btn" onclick="importManager.saveRecipe()">{{ t('recipes.controls.import.importAndDownload') }} <span id="downloadLoraCount"></span></button>
</div>
</div>
</div>

View File

@@ -81,3 +81,31 @@
</div>
</div>
</div>
<!-- Bulk Download Missing LoRAs Confirmation Modal -->
<div id="bulkDownloadMissingLorasModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>{{ t('modals.bulkDownloadMissingLoras.title') }}</h2>
<span class="close" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">&times;</span>
</div>
<div class="modal-body">
<p class="confirmation-message" id="bulkDownloadMissingLorasMessage"></p>
<div class="bulk-download-loras-preview" id="bulkDownloadMissingLorasPreview">
<p class="preview-title">{{ t('modals.bulkDownloadMissingLoras.previewTitle') }}</p>
<ul class="bulk-download-loras-list" id="bulkDownloadMissingLorasList"></ul>
</div>
<p class="confirmation-note">
<i class="fas fa-info-circle"></i>
{{ t('modals.bulkDownloadMissingLoras.note') }}
</p>
</div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('bulkDownloadMissingLorasModal')">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" id="bulkDownloadMissingLorasConfirmBtn" onclick="bulkMissingLoraDownloadManager.confirmDownload()">
<i class="fas fa-download"></i>
{{ t('modals.bulkDownloadMissingLoras.downloadButton') }}
</button>
</div>
</div>
</div>

View File

@@ -281,6 +281,26 @@
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="matureBlurLevel">
{{ t('settings.contentFiltering.matureBlurThreshold') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.contentFiltering.matureBlurThresholdHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="matureBlurLevel"
onchange="settingsManager.saveSelectSetting('matureBlurLevel', 'mature_blur_level')">
<option value="PG13">{{ t('settings.contentFiltering.matureBlurThresholdOptions.pg13') }}</option>
<option value="R">{{ t('settings.contentFiltering.matureBlurThresholdOptions.r') }}</option>
<option value="X">{{ t('settings.contentFiltering.matureBlurThresholdOptions.x') }}</option>
<option value="XXX">{{ t('settings.contentFiltering.matureBlurThresholdOptions.xxx') }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Video Settings -->

View File

@@ -15,7 +15,8 @@ describe('state module', () => {
expect(defaultSettings).toMatchObject({
civitai_api_key: '',
language: 'en',
blur_mature_content: true
blur_mature_content: true,
mature_blur_level: 'R'
});
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../static/js/utils/constants.js';
describe('getMatureBlurThreshold', () => {
it('returns configured PG13 threshold', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'PG13' })).toBe(NSFW_LEVELS.PG13);
});
it('normalizes lowercase values', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'x' })).toBe(NSFW_LEVELS.X);
});
it('falls back to R when value is invalid or missing', () => {
expect(getMatureBlurThreshold({ mature_blur_level: 'invalid' })).toBe(NSFW_LEVELS.R);
expect(getMatureBlurThreshold({})).toBe(NSFW_LEVELS.R);
});
});

View File

@@ -484,9 +484,11 @@ async def test_get_model_version_info_success(monkeypatch, downloader):
assert result["images"][0]["meta"]["other"] == "keep"
async def test_get_image_info_returns_first_item(monkeypatch, downloader):
async def test_get_image_info_returns_matching_item(monkeypatch, downloader):
"""When API returns multiple items, return the one matching the requested ID."""
async def fake_make_request(method, url, use_auth=True, **kwargs):
return True, {"items": [{"id": 1}, {"id": 2}]}
# Requested ID is 42, but it's the second item in the response
return True, {"items": [{"id": 41}, {"id": 42, "name": "target"}, {"id": 43}]}
downloader.make_request = fake_make_request
@@ -494,7 +496,25 @@ async def test_get_image_info_returns_first_item(monkeypatch, downloader):
result = await client.get_image_info("42")
assert result == {"id": 1}
assert result == {"id": 42, "name": "target"}
async def test_get_image_info_returns_none_when_id_mismatch(monkeypatch, downloader, caplog):
"""When API returns items but none match the requested ID, return None and log warning."""
async def fake_make_request(method, url, use_auth=True, **kwargs):
# Requested ID is 999, but API returns different IDs (simulating deleted/hidden image)
return True, {"items": [{"id": 1}, {"id": 2}, {"id": 3}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info("999")
assert result is None
# Verify warning was logged
assert "CivitAI API returned no matching image for requested ID 999" in caplog.text
assert "Returned 3 item(s) with IDs: [1, 2, 3]" in caplog.text
async def test_get_image_info_handles_missing(monkeypatch, downloader):
@@ -508,3 +528,13 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
result = await client.get_image_info("42")
assert result is None
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
"""When given a non-numeric image ID, return None and log error."""
client = await CivitaiClient.get_instance()
result = await client.get_image_info("not-a-number")
assert result is None
assert "Invalid image ID format" in caplog.text

View File

@@ -281,8 +281,6 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
)
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
hash_calculator = AsyncMock(return_value="hash-single")
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
result = await manager._execute_download(
download_urls=download_urls,
@@ -299,10 +297,10 @@ async def test_execute_download_extracts_zip_single_model(monkeypatch, tmp_path)
assert not zip_path.exists()
extracted = save_dir / "model.safetensors"
assert extracted.exists()
assert hash_calculator.await_args.args[0] == str(extracted)
saved_call = MetadataManager.save_metadata.await_args
assert saved_call.args[0] == str(extracted)
assert saved_call.args[1].sha256 == "hash-single"
# SHA256 comes from metadata (API value), not recalculated
assert saved_call.args[1].sha256 == "sha256"
assert dummy_scanner.add_model_to_cache.await_count == 1
@@ -351,8 +349,6 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
DownloadManager, "_get_lora_scanner", AsyncMock(return_value=dummy_scanner)
)
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
hash_calculator = AsyncMock(side_effect=["hash-one", "hash-two"])
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
result = await manager._execute_download(
download_urls=download_urls,
@@ -372,15 +368,15 @@ async def test_execute_download_extracts_zip_multiple_models(monkeypatch, tmp_pa
assert extracted_one.exists()
assert extracted_two.exists()
assert hash_calculator.await_count == 2
assert MetadataManager.save_metadata.await_count == 2
assert dummy_scanner.add_model_to_cache.await_count == 2
metadata_calls = MetadataManager.save_metadata.await_args_list
assert metadata_calls[0].args[0] == str(extracted_one)
assert metadata_calls[0].args[1].sha256 == "hash-one"
# SHA256 comes from metadata (API value), not recalculated
assert metadata_calls[0].args[1].sha256 == "sha256"
assert metadata_calls[1].args[0] == str(extracted_two)
assert metadata_calls[1].args[1].sha256 == "hash-two"
assert metadata_calls[1].args[1].sha256 == "sha256"
@pytest.mark.asyncio
@@ -427,8 +423,6 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
ServiceRegistry, "get_embedding_scanner", AsyncMock(return_value=dummy_scanner)
)
monkeypatch.setattr(MetadataManager, "save_metadata", AsyncMock(return_value=True))
hash_calculator = AsyncMock(return_value="hash-pt")
monkeypatch.setattr(download_manager, "calculate_sha256", hash_calculator)
result = await manager._execute_download(
download_urls=download_urls,
@@ -445,10 +439,10 @@ async def test_execute_download_extracts_zip_pt_embedding(monkeypatch, tmp_path)
assert not zip_path.exists()
extracted = save_dir / "embedding.pt"
assert extracted.exists()
assert hash_calculator.await_args.args[0] == str(extracted)
saved_call = MetadataManager.save_metadata.await_args
assert saved_call.args[0] == str(extracted)
assert saved_call.args[1].sha256 == "hash-pt"
# SHA256 comes from metadata (API value), not recalculated
assert saved_call.args[1].sha256 == "sha256"
assert dummy_scanner.add_model_to_cache.await_count == 1

View File

@@ -9,7 +9,11 @@ from unittest.mock import AsyncMock, patch, MagicMock
import aiohttp
from py.services.downloader import Downloader, DownloadStalledError, DownloadRestartRequested
from py.services.downloader import (
Downloader,
DownloadStalledError,
DownloadRestartRequested,
)
class TestDownloadStreamControl:
@@ -118,6 +122,7 @@ class TestDownloaderConfiguration:
return instance1, instance2
import asyncio
instance1, instance2 = asyncio.run(get_instances())
assert instance1 is instance2
@@ -131,7 +136,7 @@ class TestDownloaderConfiguration:
downloader = Downloader()
assert downloader.chunk_size == 4 * 1024 * 1024 # 4MB
assert downloader.chunk_size == 16 * 1024 * 1024 # 16MB
assert downloader.max_retries == 5
assert downloader.base_delay == 2.0
assert downloader.session_timeout == 300
@@ -145,9 +150,9 @@ class TestDownloaderConfiguration:
downloader = Downloader()
assert 'User-Agent' in downloader.default_headers
assert 'ComfyUI-LoRA-Manager' in downloader.default_headers['User-Agent']
assert downloader.default_headers['Accept-Encoding'] == 'identity'
assert "User-Agent" in downloader.default_headers
assert "ComfyUI-LoRA-Manager" in downloader.default_headers["User-Agent"]
assert downloader.default_headers["Accept-Encoding"] == "identity"
# Cleanup
Downloader._instance = None
@@ -204,7 +209,10 @@ class TestDownloaderExceptions:
with pytest.raises(DownloadRestartRequested) as exc_info:
raise DownloadRestartRequested("Reconnect requested after resume")
assert "reconnect" in str(exc_info.value).lower() or "restart" in str(exc_info.value).lower()
assert (
"reconnect" in str(exc_info.value).lower()
or "restart" in str(exc_info.value).lower()
)
class TestDownloaderAuthHeaders:
@@ -217,8 +225,8 @@ class TestDownloaderAuthHeaders:
headers = downloader._get_auth_headers(use_auth=False)
assert 'User-Agent' in headers
assert 'Authorization' not in headers
assert "User-Agent" in headers
assert "Authorization" not in headers
Downloader._instance = None
@@ -231,12 +239,14 @@ class TestDownloaderAuthHeaders:
mock_settings = MagicMock()
mock_settings.get.return_value = None
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings):
with patch(
"py.services.downloader.get_settings_manager", return_value=mock_settings
):
headers = downloader._get_auth_headers(use_auth=True)
# Should still have User-Agent but no Authorization
assert 'User-Agent' in headers
assert 'Authorization' not in headers
assert "User-Agent" in headers
assert "Authorization" not in headers
Downloader._instance = None
@@ -249,14 +259,16 @@ class TestDownloaderAuthHeaders:
mock_settings = MagicMock()
mock_settings.get.return_value = "test-api-key-12345"
with patch('py.services.downloader.get_settings_manager', return_value=mock_settings):
with patch(
"py.services.downloader.get_settings_manager", return_value=mock_settings
):
headers = downloader._get_auth_headers(use_auth=True)
# Should have both User-Agent and Authorization
assert 'User-Agent' in headers
assert 'Authorization' in headers
assert 'test-api-key-12345' in headers['Authorization']
assert headers['Content-Type'] == 'application/json'
assert "User-Agent" in headers
assert "Authorization" in headers
assert "test-api-key-12345" in headers["Authorization"]
assert headers["Content-Type"] == "application/json"
Downloader._instance = None
@@ -286,6 +298,7 @@ class TestDownloaderSessionManagement:
# Mock datetime to return current time
from datetime import datetime, timedelta
current_time = datetime.now()
downloader._session_created_at = current_time
@@ -301,6 +314,7 @@ class TestDownloaderSessionManagement:
# Simulate an old session (older than timeout)
from datetime import datetime, timedelta
old_time = datetime.now() - timedelta(seconds=downloader.session_timeout + 1)
downloader._session_created_at = old_time
downloader._session = MagicMock()

View File

@@ -369,3 +369,289 @@ async def test_pool_filter_combined_all_filters(lora_service):
# - tags: tag1 ✓
assert len(filtered) == 1
assert filtered[0]["file_name"] == "match_all.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_include_text(lora_service):
"""Test filtering by name patterns with text matching (useRegex=False)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include patterns with text matching
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["character"], "exclude": [], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_anime_v1.safetensors",
"character_realistic_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_exclude_text(lora_service):
"""Test excluding by name patterns with text matching (useRegex=False)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test exclude patterns with text matching
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": [], "exclude": ["anime"], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_realistic_v1.safetensors",
"style_watercolor_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_include_regex(lora_service):
"""Test filtering by name patterns with regex matching (useRegex=True)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include patterns with regex matching - match files starting with "character_"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["^character_"], "exclude": [], "useRegex": True},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 2
file_names = {lora["file_name"] for lora in filtered}
assert file_names == {
"character_anime_v1.safetensors",
"character_realistic_v1.safetensors",
}
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_exclude_regex(lora_service):
"""Test excluding by name patterns with regex matching (useRegex=True)."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test exclude patterns with regex matching - exclude files ending with "_v1.safetensors"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {
"include": [],
"exclude": ["_v1\\.safetensors$"],
"useRegex": True,
},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 0 # All files match the exclude pattern
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_combined(lora_service):
"""Test combining include and exclude name patterns."""
sample_loras = [
{
"file_name": "character_anime_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "character_realistic_v1.safetensors",
"model_name": "Realistic Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "style_watercolor_v1.safetensors",
"model_name": "Watercolor Style",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Test include "character" but exclude "anime"
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {
"include": ["character"],
"exclude": ["anime"],
"useRegex": False,
},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1
assert filtered[0]["file_name"] == "character_realistic_v1.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_model_name_fallback(lora_service):
"""Test that name pattern filtering falls back to model_name when file_name doesn't match."""
sample_loras = [
{
"file_name": "abc123.safetensors",
"model_name": "Super Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
{
"file_name": "def456.safetensors",
"model_name": "Realistic Portrait",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Should match model_name even if file_name doesn't contain the pattern
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["anime"], "exclude": [], "useRegex": False},
}
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1
assert filtered[0]["file_name"] == "abc123.safetensors"
@pytest.mark.asyncio
async def test_pool_filter_name_patterns_invalid_regex(lora_service):
"""Test that invalid regex falls back to substring matching."""
sample_loras = [
{
"file_name": "character_anime[test]_v1.safetensors",
"model_name": "Anime Character",
"base_model": "Illustrious",
"folder": "",
"license_flags": build_license_flags(None),
},
]
# Invalid regex pattern (unclosed character class) should fall back to substring matching
# The pattern "anime[" is invalid regex but valid substring - it exists in the filename
pool_config = {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"license": {"noCreditRequired": False, "allowSelling": False},
"namePatterns": {"include": ["anime["], "exclude": [], "useRegex": True},
}
# Should not crash and should match using substring fallback
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
assert len(filtered) == 1 # Substring match works even with invalid regex

View File

@@ -492,7 +492,7 @@ async def test_analyze_remote_video(tmp_path):
class DummyFactory:
def create_parser(self, metadata):
async def parse_metadata(m, recipe_scanner):
async def parse_metadata(m, recipe_scanner=None, civitai_client=None):
return {"loras": []}
return SimpleNamespace(parse_metadata=parse_metadata)

View File

@@ -265,6 +265,32 @@ def test_delete_setting(manager):
assert manager.get("example") is None
def test_missing_mature_blur_level_defaults_to_r(tmp_path, monkeypatch):
manager = _create_manager_with_settings(
tmp_path,
monkeypatch,
{
"blur_mature_content": True,
"folder_paths": {},
},
)
assert manager.get("mature_blur_level") == "R"
def test_invalid_mature_blur_level_is_normalized_to_r(tmp_path, monkeypatch):
manager = _create_manager_with_settings(
tmp_path,
monkeypatch,
{
"mature_blur_level": "unsafe",
"folder_paths": {},
},
)
assert manager.get("mature_blur_level") == "R"
def test_model_name_display_setting_notifies_scanners(tmp_path, monkeypatch):
initial = {
"libraries": {"default": {"folder_paths": {}, "default_lora_root": "", "default_checkpoint_root": "", "default_embedding_root": ""}},

View File

@@ -0,0 +1,202 @@
"""Tests for SuiImageParamsParser."""
import pytest
import json
from py.recipes.parsers import SuiImageParamsParser
class TestSuiImageParamsParser:
"""Test cases for SuiImageParamsParser."""
def setup_method(self):
"""Set up test fixtures."""
self.parser = SuiImageParamsParser()
def test_is_metadata_matching_positive(self):
"""Test that parser correctly identifies SuiImage metadata format."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"model": "test_model"
}
}
metadata_str = json.dumps(metadata)
assert self.parser.is_metadata_matching(metadata_str) is True
def test_is_metadata_matching_negative(self):
"""Test that parser rejects non-SuiImage metadata."""
# Missing sui_image_params key
metadata = {
"other_params": {
"prompt": "test prompt"
}
}
metadata_str = json.dumps(metadata)
assert self.parser.is_metadata_matching(metadata_str) is False
def test_is_metadata_matching_invalid_json(self):
"""Test that parser handles invalid JSON gracefully."""
metadata_str = "not valid json"
assert self.parser.is_metadata_matching(metadata_str) is False
@pytest.mark.asyncio
async def test_parse_metadata_extracts_basic_fields(self):
"""Test parsing basic fields from SuiImage metadata."""
metadata = {
"sui_image_params": {
"prompt": "beautiful landscape",
"negativeprompt": "ugly, blurry",
"steps": 30,
"seed": 12345,
"cfgscale": 7.5,
"width": 512,
"height": 768,
"sampler": "Euler a",
"scheduler": "normal"
},
"sui_models": [],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert result.get('gen_params', {}).get('prompt') == "beautiful landscape"
assert result.get('gen_params', {}).get('negative_prompt') == "ugly, blurry"
assert result.get('gen_params', {}).get('steps') == 30
assert result.get('gen_params', {}).get('seed') == 12345
assert result.get('gen_params', {}).get('cfg_scale') == 7.5
assert result.get('gen_params', {}).get('width') == 512
assert result.get('gen_params', {}).get('height') == 768
assert result.get('gen_params', {}).get('size') == "512x768"
assert result.get('loras') == []
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint(self):
"""Test parsing checkpoint from sui_models."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"model": "checkpoint_model"
},
"sui_models": [
{
"name": "test_checkpoint.safetensors",
"param": "model",
"hash": "0x1234567890abcdef"
}
],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
checkpoint = result.get('checkpoint')
assert checkpoint is not None
assert checkpoint['type'] == 'checkpoint'
assert checkpoint['name'] == 'test_checkpoint'
assert checkpoint['hash'] == '1234567890abcdef'
@pytest.mark.asyncio
async def test_parse_metadata_extracts_lora(self):
"""Test parsing LoRA from sui_models."""
metadata = {
"sui_image_params": {
"prompt": "test prompt"
},
"sui_models": [
{
"name": "test_lora.safetensors",
"param": "lora",
"hash": "0xabcdef1234567890"
}
],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
loras = result.get('loras')
assert len(loras) == 1
assert loras[0]['type'] == 'lora'
assert loras[0]['name'] == 'test_lora'
assert loras[0]['file_name'] == 'test_lora.safetensors'
assert loras[0]['hash'] == 'abcdef1234567890'
@pytest.mark.asyncio
async def test_parse_metadata_handles_lora_in_name(self):
"""Test that LoRA is detected by 'lora' in name."""
metadata = {
"sui_image_params": {
"prompt": "test prompt"
},
"sui_models": [
{
"name": "style_lora_v2.safetensors",
"param": "some_other_param",
"hash": "0x1111111111111111"
}
],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
loras = result.get('loras')
assert len(loras) == 1
assert loras[0]['type'] == 'lora'
@pytest.mark.asyncio
async def test_parse_metadata_empty_models(self):
"""Test parsing with empty sui_models array."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"steps": 20
},
"sui_models": [],
"sui_extra_data": {
"date": "2024-01-01"
}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert result.get('loras') == []
assert result.get('checkpoint') is None
assert result.get('gen_params', {}).get('prompt') == "test prompt"
assert result.get('gen_params', {}).get('steps') == 20
@pytest.mark.asyncio
async def test_parse_metadata_alternative_field_names(self):
"""Test parsing with alternative field names."""
metadata = {
"sui_image_params": {
"prompt": "test prompt",
"negative_prompt": "bad quality", # Using underscore variant
"cfg_scale": 6.0 # Using underscore variant
},
"sui_models": [],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert result.get('gen_params', {}).get('negative_prompt') == "bad quality"
assert result.get('gen_params', {}).get('cfg_scale') == 6.0
@pytest.mark.asyncio
async def test_parse_metadata_error_handling(self):
"""Test that parser handles malformed data gracefully."""
# Missing required fields
metadata = {
"sui_image_params": {},
"sui_models": [],
"sui_extra_data": {}
}
metadata_str = json.dumps(metadata)
result = await self.parser.parse_metadata(metadata_str)
assert 'error' not in result
assert result.get('loras') == []
# Empty params result in empty gen_params dict
assert result.get('gen_params') == {}

View File

@@ -1,30 +1,7 @@
from py.utils.preview_selection import select_preview_media
import pytest
def test_select_preview_prefers_safe_media_when_blurred():
images = [
{"url": "nsfw", "type": "image", "nsfwLevel": 8},
{"url": "mid", "type": "image", "nsfwLevel": 4},
{"url": "safe", "type": "image", "nsfwLevel": 1},
]
selected, level = select_preview_media(images, blur_mature_content=True)
assert selected["url"] == "safe"
assert level == 1
def test_select_preview_returns_lowest_when_no_safe_media():
images = [
{"url": "x", "type": "image", "nsfwLevel": 16},
{"url": "r", "type": "image", "nsfwLevel": 4},
{"url": "xx", "type": "image", "nsfwLevel": 8},
]
selected, level = select_preview_media(images, blur_mature_content=True)
assert selected["url"] == "r"
assert level == 4
from py.utils.constants import NSFW_LEVELS
from py.utils.preview_selection import resolve_mature_threshold, select_preview_media
def test_select_preview_returns_first_when_blur_disabled():
@@ -37,3 +14,36 @@ def test_select_preview_returns_first_when_blur_disabled():
assert selected["url"] == "nsfw"
assert level == 32
@pytest.mark.parametrize(
("threshold_name", "expected_url"),
[
("PG13", "pg"),
("R", "pg13"),
("X", "r"),
("XXX", "x"),
],
)
def test_select_preview_respects_configurable_threshold(threshold_name, expected_url):
images = [
{"url": "xxx", "type": "image", "nsfwLevel": NSFW_LEVELS["XXX"]},
{"url": "x", "type": "image", "nsfwLevel": NSFW_LEVELS["X"]},
{"url": "r", "type": "image", "nsfwLevel": NSFW_LEVELS["R"]},
{"url": "pg13", "type": "image", "nsfwLevel": NSFW_LEVELS["PG13"]},
{"url": "pg", "type": "image", "nsfwLevel": NSFW_LEVELS["PG"]},
]
selected, level = select_preview_media(
images,
blur_mature_content=True,
mature_threshold=NSFW_LEVELS[threshold_name],
)
assert selected["url"] == expected_url
assert level == next(item["nsfwLevel"] for item in images if item["url"] == expected_url)
def test_resolve_mature_threshold_falls_back_to_r_for_invalid_value():
assert resolve_mature_threshold({"mature_blur_level": "invalid"}) == NSFW_LEVELS["R"]
assert resolve_mature_threshold({}) == NSFW_LEVELS["R"]

View File

@@ -8,6 +8,9 @@
:exclude-tags="state.excludeTags.value"
:include-folders="state.includeFolders.value"
:exclude-folders="state.excludeFolders.value"
:include-patterns="state.includePatterns.value"
:exclude-patterns="state.excludePatterns.value"
:use-regex="state.useRegex.value"
:no-credit-required="state.noCreditRequired.value"
:allow-selling="state.allowSelling.value"
:preview-items="state.previewItems.value"
@@ -16,6 +19,9 @@
@open-modal="openModal"
@update:include-folders="state.includeFolders.value = $event"
@update:exclude-folders="state.excludeFolders.value = $event"
@update:include-patterns="state.includePatterns.value = $event"
@update:exclude-patterns="state.excludePatterns.value = $event"
@update:use-regex="state.useRegex.value = $event"
@update:no-credit-required="state.noCreditRequired.value = $event"
@update:allow-selling="state.allowSelling.value = $event"
@refresh="state.refreshPreview"

View File

@@ -24,6 +24,15 @@
@edit-exclude="$emit('open-modal', 'excludeFolders')"
/>
<NamePatternsSection
:include-patterns="includePatterns"
:exclude-patterns="excludePatterns"
:use-regex="useRegex"
@update:include-patterns="$emit('update:includePatterns', $event)"
@update:exclude-patterns="$emit('update:excludePatterns', $event)"
@update:use-regex="$emit('update:useRegex', $event)"
/>
<LicenseSection
:no-credit-required="noCreditRequired"
:allow-selling="allowSelling"
@@ -46,6 +55,7 @@
import BaseModelSection from './sections/BaseModelSection.vue'
import TagsSection from './sections/TagsSection.vue'
import FoldersSection from './sections/FoldersSection.vue'
import NamePatternsSection from './sections/NamePatternsSection.vue'
import LicenseSection from './sections/LicenseSection.vue'
import LoraPoolPreview from './LoraPoolPreview.vue'
import type { BaseModelOption, LoraItem } from '../../composables/types'
@@ -61,6 +71,10 @@ defineProps<{
// Folders
includeFolders: string[]
excludeFolders: string[]
// Name patterns
includePatterns: string[]
excludePatterns: string[]
useRegex: boolean
// License
noCreditRequired: boolean
allowSelling: boolean
@@ -74,6 +88,9 @@ defineEmits<{
'open-modal': [modal: ModalType]
'update:includeFolders': [value: string[]]
'update:excludeFolders': [value: string[]]
'update:includePatterns': [value: string[]]
'update:excludePatterns': [value: string[]]
'update:useRegex': [value: boolean]
'update:noCreditRequired': [value: boolean]
'update:allowSelling': [value: boolean]
refresh: []

View File

@@ -0,0 +1,255 @@
<template>
<div class="section">
<div class="section__header">
<span class="section__title">NAME PATTERNS</span>
<label class="section__toggle">
<input
type="checkbox"
:checked="useRegex"
@change="$emit('update:useRegex', ($event.target as HTMLInputElement).checked)"
/>
<span class="section__toggle-label">Use Regex</span>
</label>
</div>
<div class="section__columns">
<!-- Include column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--include">INCLUDE</span>
</div>
<div class="section__input-wrapper">
<input
type="text"
v-model="includeInput"
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
class="section__input"
@keydown.enter="addInclude"
/>
<button type="button" class="section__add-btn" @click="addInclude">+</button>
</div>
<div class="section__patterns">
<FilterChip
v-for="pattern in includePatterns"
:key="pattern"
:label="pattern"
variant="include"
removable
@remove="removeInclude(pattern)"
/>
<div v-if="includePatterns.length === 0" class="section__empty">
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
</div>
</div>
</div>
<!-- Exclude column -->
<div class="section__column">
<div class="section__column-header">
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
</div>
<div class="section__input-wrapper">
<input
type="text"
v-model="excludeInput"
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
class="section__input"
@keydown.enter="addExclude"
/>
<button type="button" class="section__add-btn" @click="addExclude">+</button>
</div>
<div class="section__patterns">
<FilterChip
v-for="pattern in excludePatterns"
:key="pattern"
:label="pattern"
variant="exclude"
removable
@remove="removeExclude(pattern)"
/>
<div v-if="excludePatterns.length === 0" class="section__empty">
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import FilterChip from '../shared/FilterChip.vue'
const props = defineProps<{
includePatterns: string[]
excludePatterns: string[]
useRegex: boolean
}>()
const emit = defineEmits<{
'update:includePatterns': [value: string[]]
'update:excludePatterns': [value: string[]]
'update:useRegex': [value: boolean]
}>()
const includeInput = ref('')
const excludeInput = ref('')
const addInclude = () => {
const pattern = includeInput.value.trim()
if (pattern && !props.includePatterns.includes(pattern)) {
emit('update:includePatterns', [...props.includePatterns, pattern])
includeInput.value = ''
}
}
const addExclude = () => {
const pattern = excludeInput.value.trim()
if (pattern && !props.excludePatterns.includes(pattern)) {
emit('update:excludePatterns', [...props.excludePatterns, pattern])
excludeInput.value = ''
}
}
const removeInclude = (pattern: string) => {
emit('update:includePatterns', props.includePatterns.filter(p => p !== pattern))
}
const removeExclude = (pattern: string) => {
emit('update:excludePatterns', props.excludePatterns.filter(p => p !== pattern))
}
</script>
<style scoped>
.section {
margin-bottom: 16px;
}
.section__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section__title {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.7;
}
.section__toggle input[type="checkbox"] {
margin: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.section__toggle-label {
font-weight: 500;
}
.section__columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column {
min-width: 0;
}
.section__column-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.section__column-title {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include {
color: #4299e1;
}
.section__column-title--exclude {
color: #ef4444;
}
.section__input-wrapper {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.section__input {
flex: 1;
min-width: 0;
padding: 6px 8px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 12px;
outline: none;
}
.section__input:focus {
border-color: #4299e1;
}
.section__add-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.section__add-btn:hover {
background: var(--comfy-input-bg-hover, #444);
border-color: #4299e1;
}
.section__patterns {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 22px;
}
.section__empty {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.3;
font-style: italic;
min-height: 22px;
display: flex;
align-items: center;
}
</style>

View File

@@ -10,6 +10,11 @@ export interface LoraPoolConfig {
noCreditRequired: boolean
allowSelling: boolean
}
namePatterns: {
include: string[]
exclude: string[]
useRegex: boolean
}
includeEmptyLora?: boolean // Optional, deprecated (moved to Cycler)
}
preview: { matchCount: number; lastUpdated: number }

View File

@@ -62,6 +62,9 @@ export function useLoraPoolApi() {
foldersExclude?: string[]
noCreditRequired?: boolean
allowSelling?: boolean
namePatternsInclude?: string[]
namePatternsExclude?: string[]
namePatternsUseRegex?: boolean
page?: number
pageSize?: number
}
@@ -92,6 +95,13 @@ export function useLoraPoolApi() {
urlParams.set('allow_selling_generated_content', String(params.allowSelling))
}
// Name pattern filters
params.namePatternsInclude?.forEach(pattern => urlParams.append('name_pattern_include', pattern))
params.namePatternsExclude?.forEach(pattern => urlParams.append('name_pattern_exclude', pattern))
if (params.namePatternsUseRegex !== undefined) {
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
const data = await response.json()

View File

@@ -24,6 +24,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
const excludeFolders = ref<string[]>([])
const noCreditRequired = ref(false)
const allowSelling = ref(false)
const includePatterns = ref<string[]>([])
const excludePatterns = ref<string[]>([])
const useRegex = ref(false)
// Available options from API
const availableBaseModels = ref<BaseModelOption[]>([])
@@ -52,6 +55,11 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
license: {
noCreditRequired: noCreditRequired.value,
allowSelling: allowSelling.value
},
namePatterns: {
include: includePatterns.value,
exclude: excludePatterns.value,
useRegex: useRegex.value
}
},
preview: {
@@ -94,6 +102,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
updateIfChanged(excludeFolders, filters.folders?.exclude || [])
updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false)
updateIfChanged(allowSelling, filters.license?.allowSelling ?? false)
updateIfChanged(includePatterns, filters.namePatterns?.include || [])
updateIfChanged(excludePatterns, filters.namePatterns?.exclude || [])
updateIfChanged(useRegex, filters.namePatterns?.useRegex ?? false)
// matchCount doesn't trigger watchers, so direct assignment is fine
matchCount.value = preview?.matchCount || 0
@@ -125,6 +136,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
foldersExclude: excludeFolders.value,
noCreditRequired: noCreditRequired.value || undefined,
allowSelling: allowSelling.value || undefined,
namePatternsInclude: includePatterns.value,
namePatternsExclude: excludePatterns.value,
namePatternsUseRegex: useRegex.value,
pageSize: 6
})
@@ -150,7 +164,10 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
includeFolders,
excludeFolders,
noCreditRequired,
allowSelling
allowSelling,
includePatterns,
excludePatterns,
useRegex
], onFilterChange, { deep: true })
return {
@@ -162,6 +179,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
excludeFolders,
noCreditRequired,
allowSelling,
includePatterns,
excludePatterns,
useRegex,
// Available options
availableBaseModels,

View File

@@ -13,7 +13,7 @@ import {
} from './mode-change-handler'
const LORA_POOL_WIDGET_MIN_WIDTH = 500
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
const LORA_POOL_WIDGET_MIN_HEIGHT = 520
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT

View File

@@ -14,6 +14,7 @@ import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKey
import { forwardMiddleMouseToCanvas } from "./utils.js";
import { PreviewTooltip } from "./preview_tooltip.js";
import { ensureLmStyles } from "./lm_styles_loader.js";
import { getStrengthStepPreference } from "./settings.js";
export function addLorasWidget(node, name, opts, callback) {
ensureLmStyles();
@@ -416,7 +417,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - getStrengthStepPreference()).toFixed(2);
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
@@ -488,7 +489,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + getStrengthStepPreference()).toFixed(2);
// Sync clipStrength if collapsed
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
@@ -541,7 +542,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - 0.05).toFixed(2);
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - getStrengthStepPreference()).toFixed(2);
const newValue = formatLoraValue(lorasData);
updateWidgetValue(newValue);
@@ -611,7 +612,7 @@ export function addLorasWidget(node, name, opts, callback) {
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + 0.05).toFixed(2);
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + getStrengthStepPreference()).toFixed(2);
const newValue = formatLoraValue(lorasData);
updateWidgetValue(newValue);

View File

@@ -24,6 +24,9 @@ const NEW_TAB_TEMPLATE_DEFAULT = "Default";
const NEW_TAB_ZOOM_LEVEL = 0.8;
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
const STRENGTH_STEP_DEFAULT = 0.05;
// ============================================================================
// Helper Functions
// ============================================================================
@@ -232,6 +235,32 @@ const getNewTabTemplatePreference = (() => {
};
})();
const getStrengthStepPreference = (() => {
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 strength step.");
settingsUnavailableLogged = true;
}
return STRENGTH_STEP_DEFAULT;
}
try {
const value = settingManager.get(STRENGTH_STEP_SETTING_ID);
return value ?? STRENGTH_STEP_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read strength step setting, using default.", error);
settingsUnavailableLogged = true;
}
return STRENGTH_STEP_DEFAULT;
}
};
})();
// ============================================================================
// Register Extension with All Settings
// ============================================================================
@@ -293,6 +322,19 @@ app.registerExtension({
tooltip: "Choose a template workflow to load when creating a new workflow tab. 'Default (Blank)' keeps ComfyUI's original blank workflow behavior.",
category: ["LoRA Manager", "Workflow", "New Tab Template"],
},
{
id: STRENGTH_STEP_SETTING_ID,
name: "Strength Adjustment Step",
type: "slider",
attrs: {
min: 0.01,
max: 0.1,
step: 0.01,
},
defaultValue: STRENGTH_STEP_DEFAULT,
tooltip: "Step size for adjusting LoRA strength via arrow buttons or keyboard (default: 0.05)",
category: ["LoRA Manager", "LoRA Widget", "Strength Step"],
},
],
async setup() {
await loadWorkflowOptions();
@@ -375,4 +417,5 @@ export {
getTagSpaceReplacementPreference,
getUsageStatisticsPreference,
getNewTabTemplatePreference,
getStrengthStepPreference,
};

View File

@@ -283,6 +283,121 @@
align-items: center;
}
.section[data-v-9995b5ed] {
margin-bottom: 16px;
}
.section__header[data-v-9995b5ed] {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section__title[data-v-9995b5ed] {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-color, #fff);
opacity: 0.6;
}
.section__toggle[data-v-9995b5ed] {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.7;
}
.section__toggle input[type="checkbox"][data-v-9995b5ed] {
margin: 0;
width: 14px;
height: 14px;
cursor: pointer;
}
.section__toggle-label[data-v-9995b5ed] {
font-weight: 500;
}
.section__columns[data-v-9995b5ed] {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.section__column[data-v-9995b5ed] {
min-width: 0;
}
.section__column-header[data-v-9995b5ed] {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.section__column-title[data-v-9995b5ed] {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.section__column-title--include[data-v-9995b5ed] {
color: #4299e1;
}
.section__column-title--exclude[data-v-9995b5ed] {
color: #ef4444;
}
.section__input-wrapper[data-v-9995b5ed] {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.section__input[data-v-9995b5ed] {
flex: 1;
min-width: 0;
padding: 6px 8px;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 12px;
outline: none;
}
.section__input[data-v-9995b5ed]:focus {
border-color: #4299e1;
}
.section__add-btn[data-v-9995b5ed] {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--comfy-input-bg, #333);
border: 1px solid var(--comfy-input-border, #444);
border-radius: 4px;
color: var(--fg-color, #fff);
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.section__add-btn[data-v-9995b5ed]:hover {
background: var(--comfy-input-bg-hover, #444);
border-color: #4299e1;
}
.section__patterns[data-v-9995b5ed] {
display: flex;
flex-wrap: wrap;
gap: 4px;
min-height: 22px;
}
.section__empty[data-v-9995b5ed] {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.3;
font-style: italic;
min-height: 22px;
display: flex;
align-items: center;
}
.section[data-v-dea4adf6] {
margin-bottom: 16px;
}
@@ -483,12 +598,12 @@ to { transform: rotate(360deg);
transform: translateY(4px);
}
.summary-view[data-v-328e7526] {
.summary-view[data-v-83235a00] {
display: flex;
flex-direction: column;
height: 100%;
}
.summary-view__filters[data-v-328e7526] {
.summary-view__filters[data-v-83235a00] {
flex: 1;
overflow-y: auto;
padding-right: 4px;
@@ -979,7 +1094,7 @@ to { transform: rotate(360deg);
font-size: 13px;
}
.lora-pool-widget[data-v-4456abba] {
.lora-pool-widget[data-v-0bbd50ea] {
padding: 12px;
background: rgba(40, 44, 52, 0.6);
border-radius: 4px;
@@ -10505,12 +10620,12 @@ var PrimeVue = {
setup(app2, configOptions);
}
};
const _hoisted_1$m = { class: "filter-chip__text" };
const _hoisted_2$i = {
const _hoisted_1$n = { class: "filter-chip__text" };
const _hoisted_2$j = {
key: 0,
class: "filter-chip__count"
};
const _sfc_main$n = /* @__PURE__ */ defineComponent({
const _sfc_main$o = /* @__PURE__ */ defineComponent({
__name: "FilterChip",
props: {
label: {},
@@ -10528,8 +10643,8 @@ const _sfc_main$n = /* @__PURE__ */ defineComponent({
return openBlock(), createElementBlock("span", {
class: normalizeClass(["filter-chip", variantClass.value])
}, [
createBaseVNode("span", _hoisted_1$m, toDisplayString(__props.label), 1),
__props.count !== void 0 ? (openBlock(), createElementBlock("span", _hoisted_2$i, "(" + toDisplayString(__props.count) + ")", 1)) : createCommentVNode("", true),
createBaseVNode("span", _hoisted_1$n, toDisplayString(__props.label), 1),
__props.count !== void 0 ? (openBlock(), createElementBlock("span", _hoisted_2$j, "(" + toDisplayString(__props.count) + ")", 1)) : createCommentVNode("", true),
__props.removable ? (openBlock(), createElementBlock("button", {
key: 1,
class: "filter-chip__remove",
@@ -10547,8 +10662,8 @@ const _export_sfc = (sfc, props) => {
}
return target;
};
const FilterChip = /* @__PURE__ */ _export_sfc(_sfc_main$n, [["__scopeId", "data-v-7e36267d"]]);
const _sfc_main$m = /* @__PURE__ */ defineComponent({
const FilterChip = /* @__PURE__ */ _export_sfc(_sfc_main$o, [["__scopeId", "data-v-7e36267d"]]);
const _sfc_main$n = /* @__PURE__ */ defineComponent({
__name: "EditButton",
emits: ["click"],
setup(__props) {
@@ -10570,19 +10685,19 @@ const _sfc_main$m = /* @__PURE__ */ defineComponent({
};
}
});
const EditButton = /* @__PURE__ */ _export_sfc(_sfc_main$m, [["__scopeId", "data-v-8da8aa4b"]]);
const _hoisted_1$l = { class: "section" };
const _hoisted_2$h = { class: "section__header" };
const _hoisted_3$f = { class: "section__content" };
const _hoisted_4$d = {
const EditButton = /* @__PURE__ */ _export_sfc(_sfc_main$n, [["__scopeId", "data-v-8da8aa4b"]]);
const _hoisted_1$m = { class: "section" };
const _hoisted_2$i = { class: "section__header" };
const _hoisted_3$g = { class: "section__content" };
const _hoisted_4$e = {
key: 0,
class: "section__placeholder"
};
const _hoisted_5$b = {
const _hoisted_5$c = {
key: 1,
class: "section__chips"
};
const _sfc_main$l = /* @__PURE__ */ defineComponent({
const _sfc_main$m = /* @__PURE__ */ defineComponent({
__name: "BaseModelSection",
props: {
selected: {},
@@ -10596,15 +10711,15 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
return model == null ? void 0 : model.count;
};
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$l, [
createBaseVNode("div", _hoisted_2$h, [
return openBlock(), createElementBlock("div", _hoisted_1$m, [
createBaseVNode("div", _hoisted_2$i, [
_cache[1] || (_cache[1] = createBaseVNode("span", { class: "section__title" }, "BASE MODEL", -1)),
createVNode(EditButton, {
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("edit"))
})
]),
createBaseVNode("div", _hoisted_3$f, [
__props.selected.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$d, " All models ")) : (openBlock(), createElementBlock("div", _hoisted_5$b, [
createBaseVNode("div", _hoisted_3$g, [
__props.selected.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$e, " All models ")) : (openBlock(), createElementBlock("div", _hoisted_5$c, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.selected, (name) => {
return openBlock(), createBlock(FilterChip, {
key: name,
@@ -10619,32 +10734,32 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
};
}
});
const BaseModelSection = /* @__PURE__ */ _export_sfc(_sfc_main$l, [["__scopeId", "data-v-12f059e2"]]);
const _hoisted_1$k = { class: "section" };
const _hoisted_2$g = { class: "section__columns" };
const _hoisted_3$e = { class: "section__column" };
const _hoisted_4$c = { class: "section__column-header" };
const _hoisted_5$a = { class: "section__column-content" };
const _hoisted_6$a = {
const BaseModelSection = /* @__PURE__ */ _export_sfc(_sfc_main$m, [["__scopeId", "data-v-12f059e2"]]);
const _hoisted_1$l = { class: "section" };
const _hoisted_2$h = { class: "section__columns" };
const _hoisted_3$f = { class: "section__column" };
const _hoisted_4$d = { class: "section__column-header" };
const _hoisted_5$b = { class: "section__column-content" };
const _hoisted_6$b = {
key: 0,
class: "section__empty"
};
const _hoisted_7$8 = {
const _hoisted_7$9 = {
key: 1,
class: "section__chips"
};
const _hoisted_8$6 = { class: "section__column" };
const _hoisted_9$4 = { class: "section__column-header" };
const _hoisted_10$4 = { class: "section__column-content" };
const _hoisted_11$3 = {
const _hoisted_8$7 = { class: "section__column" };
const _hoisted_9$5 = { class: "section__column-header" };
const _hoisted_10$5 = { class: "section__column-content" };
const _hoisted_11$4 = {
key: 0,
class: "section__empty"
};
const _hoisted_12$3 = {
const _hoisted_12$4 = {
key: 1,
class: "section__chips"
};
const _sfc_main$k = /* @__PURE__ */ defineComponent({
const _sfc_main$l = /* @__PURE__ */ defineComponent({
__name: "TagsSection",
props: {
includeTags: {},
@@ -10653,20 +10768,20 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
emits: ["edit-include", "edit-exclude"],
setup(__props) {
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$k, [
return openBlock(), createElementBlock("div", _hoisted_1$l, [
_cache[4] || (_cache[4] = createBaseVNode("div", { class: "section__header" }, [
createBaseVNode("span", { class: "section__title" }, "TAGS")
], -1)),
createBaseVNode("div", _hoisted_2$g, [
createBaseVNode("div", _hoisted_3$e, [
createBaseVNode("div", _hoisted_4$c, [
createBaseVNode("div", _hoisted_2$h, [
createBaseVNode("div", _hoisted_3$f, [
createBaseVNode("div", _hoisted_4$d, [
_cache[2] || (_cache[2] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createVNode(EditButton, {
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("edit-include"))
})
]),
createBaseVNode("div", _hoisted_5$a, [
__props.includeTags.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_6$a, " None ")) : (openBlock(), createElementBlock("div", _hoisted_7$8, [
createBaseVNode("div", _hoisted_5$b, [
__props.includeTags.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_6$b, " None ")) : (openBlock(), createElementBlock("div", _hoisted_7$9, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.includeTags, (tag) => {
return openBlock(), createBlock(FilterChip, {
key: tag,
@@ -10677,15 +10792,15 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
]))
])
]),
createBaseVNode("div", _hoisted_8$6, [
createBaseVNode("div", _hoisted_9$4, [
createBaseVNode("div", _hoisted_8$7, [
createBaseVNode("div", _hoisted_9$5, [
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__column-title section__column-title--exclude" }, "EXCLUDE", -1)),
createVNode(EditButton, {
onClick: _cache[1] || (_cache[1] = ($event) => _ctx.$emit("edit-exclude"))
})
]),
createBaseVNode("div", _hoisted_10$4, [
__props.excludeTags.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_11$3, " None ")) : (openBlock(), createElementBlock("div", _hoisted_12$3, [
createBaseVNode("div", _hoisted_10$5, [
__props.excludeTags.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_11$4, " None ")) : (openBlock(), createElementBlock("div", _hoisted_12$4, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.excludeTags, (tag) => {
return openBlock(), createBlock(FilterChip, {
key: tag,
@@ -10701,32 +10816,32 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
};
}
});
const TagsSection = /* @__PURE__ */ _export_sfc(_sfc_main$k, [["__scopeId", "data-v-b869b780"]]);
const _hoisted_1$j = { class: "section" };
const _hoisted_2$f = { class: "section__columns" };
const _hoisted_3$d = { class: "section__column" };
const _hoisted_4$b = { class: "section__column-header" };
const _hoisted_5$9 = { class: "section__content" };
const _hoisted_6$9 = {
const TagsSection = /* @__PURE__ */ _export_sfc(_sfc_main$l, [["__scopeId", "data-v-b869b780"]]);
const _hoisted_1$k = { class: "section" };
const _hoisted_2$g = { class: "section__columns" };
const _hoisted_3$e = { class: "section__column" };
const _hoisted_4$c = { class: "section__column-header" };
const _hoisted_5$a = { class: "section__content" };
const _hoisted_6$a = {
key: 0,
class: "section__paths"
};
const _hoisted_7$7 = {
const _hoisted_7$8 = {
key: 1,
class: "section__empty"
};
const _hoisted_8$5 = { class: "section__column" };
const _hoisted_9$3 = { class: "section__column-header" };
const _hoisted_10$3 = { class: "section__content" };
const _hoisted_11$2 = {
const _hoisted_8$6 = { class: "section__column" };
const _hoisted_9$4 = { class: "section__column-header" };
const _hoisted_10$4 = { class: "section__content" };
const _hoisted_11$3 = {
key: 0,
class: "section__paths"
};
const _hoisted_12$2 = {
const _hoisted_12$3 = {
key: 1,
class: "section__empty"
};
const _sfc_main$j = /* @__PURE__ */ defineComponent({
const _sfc_main$k = /* @__PURE__ */ defineComponent({
__name: "FoldersSection",
props: {
includeFolders: {},
@@ -10747,13 +10862,13 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
emit2("update:excludeFolders", props.excludeFolders.filter((p2) => p2 !== path));
};
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$j, [
return openBlock(), createElementBlock("div", _hoisted_1$k, [
_cache[6] || (_cache[6] = createBaseVNode("div", { class: "section__header" }, [
createBaseVNode("span", { class: "section__title" }, "FOLDERS")
], -1)),
createBaseVNode("div", _hoisted_2$f, [
createBaseVNode("div", _hoisted_3$d, [
createBaseVNode("div", _hoisted_4$b, [
createBaseVNode("div", _hoisted_2$g, [
createBaseVNode("div", _hoisted_3$e, [
createBaseVNode("div", _hoisted_4$c, [
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createBaseVNode("button", {
type: "button",
@@ -10768,8 +10883,8 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
], -1)
])])
]),
createBaseVNode("div", _hoisted_5$9, [
__props.includeFolders.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_6$9, [
createBaseVNode("div", _hoisted_5$a, [
__props.includeFolders.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_6$a, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.includeFolders, (path) => {
return openBlock(), createBlock(FilterChip, {
key: path,
@@ -10779,11 +10894,11 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
onRemove: ($event) => removeInclude(path)
}, null, 8, ["label", "onRemove"]);
}), 128))
])) : (openBlock(), createElementBlock("div", _hoisted_7$7, " No folders selected "))
])) : (openBlock(), createElementBlock("div", _hoisted_7$8, " No folders selected "))
])
]),
createBaseVNode("div", _hoisted_8$5, [
createBaseVNode("div", _hoisted_9$3, [
createBaseVNode("div", _hoisted_8$6, [
createBaseVNode("div", _hoisted_9$4, [
_cache[5] || (_cache[5] = createBaseVNode("span", { class: "section__column-title section__column-title--exclude" }, "EXCLUDE", -1)),
createBaseVNode("button", {
type: "button",
@@ -10798,8 +10913,8 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
], -1)
])])
]),
createBaseVNode("div", _hoisted_10$3, [
__props.excludeFolders.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_11$2, [
createBaseVNode("div", _hoisted_10$4, [
__props.excludeFolders.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_11$3, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.excludeFolders, (path) => {
return openBlock(), createBlock(FilterChip, {
key: path,
@@ -10809,7 +10924,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
onRemove: ($event) => removeExclude(path)
}, null, 8, ["label", "onRemove"]);
}), 128))
])) : (openBlock(), createElementBlock("div", _hoisted_12$2, " No folders selected "))
])) : (openBlock(), createElementBlock("div", _hoisted_12$3, " No folders selected "))
])
])
])
@@ -10817,7 +10932,147 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
};
}
});
const FoldersSection = /* @__PURE__ */ _export_sfc(_sfc_main$j, [["__scopeId", "data-v-af9caf84"]]);
const FoldersSection = /* @__PURE__ */ _export_sfc(_sfc_main$k, [["__scopeId", "data-v-af9caf84"]]);
const _hoisted_1$j = { class: "section" };
const _hoisted_2$f = { class: "section__header" };
const _hoisted_3$d = { class: "section__toggle" };
const _hoisted_4$b = ["checked"];
const _hoisted_5$9 = { class: "section__columns" };
const _hoisted_6$9 = { class: "section__column" };
const _hoisted_7$7 = { class: "section__input-wrapper" };
const _hoisted_8$5 = ["placeholder"];
const _hoisted_9$3 = { class: "section__patterns" };
const _hoisted_10$3 = {
key: 0,
class: "section__empty"
};
const _hoisted_11$2 = { class: "section__column" };
const _hoisted_12$2 = { class: "section__input-wrapper" };
const _hoisted_13$2 = ["placeholder"];
const _hoisted_14$2 = { class: "section__patterns" };
const _hoisted_15$2 = {
key: 0,
class: "section__empty"
};
const _sfc_main$j = /* @__PURE__ */ defineComponent({
__name: "NamePatternsSection",
props: {
includePatterns: {},
excludePatterns: {},
useRegex: { type: Boolean }
},
emits: ["update:includePatterns", "update:excludePatterns", "update:useRegex"],
setup(__props, { emit: __emit }) {
const props = __props;
const emit2 = __emit;
const includeInput = ref("");
const excludeInput = ref("");
const addInclude = () => {
const pattern = includeInput.value.trim();
if (pattern && !props.includePatterns.includes(pattern)) {
emit2("update:includePatterns", [...props.includePatterns, pattern]);
includeInput.value = "";
}
};
const addExclude = () => {
const pattern = excludeInput.value.trim();
if (pattern && !props.excludePatterns.includes(pattern)) {
emit2("update:excludePatterns", [...props.excludePatterns, pattern]);
excludeInput.value = "";
}
};
const removeInclude = (pattern) => {
emit2("update:includePatterns", props.includePatterns.filter((p2) => p2 !== pattern));
};
const removeExclude = (pattern) => {
emit2("update:excludePatterns", props.excludePatterns.filter((p2) => p2 !== pattern));
};
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$j, [
createBaseVNode("div", _hoisted_2$f, [
_cache[4] || (_cache[4] = createBaseVNode("span", { class: "section__title" }, "NAME PATTERNS", -1)),
createBaseVNode("label", _hoisted_3$d, [
createBaseVNode("input", {
type: "checkbox",
checked: __props.useRegex,
onChange: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("update:useRegex", $event.target.checked))
}, null, 40, _hoisted_4$b),
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__toggle-label" }, "Use Regex", -1))
])
]),
createBaseVNode("div", _hoisted_5$9, [
createBaseVNode("div", _hoisted_6$9, [
_cache[5] || (_cache[5] = createBaseVNode("div", { class: "section__column-header" }, [
createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE")
], -1)),
createBaseVNode("div", _hoisted_7$7, [
withDirectives(createBaseVNode("input", {
type: "text",
"onUpdate:modelValue": _cache[1] || (_cache[1] = ($event) => includeInput.value = $event),
placeholder: __props.useRegex ? "Add regex pattern..." : "Add text pattern...",
class: "section__input",
onKeydown: withKeys(addInclude, ["enter"])
}, null, 40, _hoisted_8$5), [
[vModelText, includeInput.value]
]),
createBaseVNode("button", {
type: "button",
class: "section__add-btn",
onClick: addInclude
}, "+")
]),
createBaseVNode("div", _hoisted_9$3, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.includePatterns, (pattern) => {
return openBlock(), createBlock(FilterChip, {
key: pattern,
label: pattern,
variant: "include",
removable: "",
onRemove: ($event) => removeInclude(pattern)
}, null, 8, ["label", "onRemove"]);
}), 128)),
__props.includePatterns.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_10$3, toDisplayString(__props.useRegex ? "No regex patterns" : "No text patterns"), 1)) : createCommentVNode("", true)
])
]),
createBaseVNode("div", _hoisted_11$2, [
_cache[6] || (_cache[6] = createBaseVNode("div", { class: "section__column-header" }, [
createBaseVNode("span", { class: "section__column-title section__column-title--exclude" }, "EXCLUDE")
], -1)),
createBaseVNode("div", _hoisted_12$2, [
withDirectives(createBaseVNode("input", {
type: "text",
"onUpdate:modelValue": _cache[2] || (_cache[2] = ($event) => excludeInput.value = $event),
placeholder: __props.useRegex ? "Add regex pattern..." : "Add text pattern...",
class: "section__input",
onKeydown: withKeys(addExclude, ["enter"])
}, null, 40, _hoisted_13$2), [
[vModelText, excludeInput.value]
]),
createBaseVNode("button", {
type: "button",
class: "section__add-btn",
onClick: addExclude
}, "+")
]),
createBaseVNode("div", _hoisted_14$2, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.excludePatterns, (pattern) => {
return openBlock(), createBlock(FilterChip, {
key: pattern,
label: pattern,
variant: "exclude",
removable: "",
onRemove: ($event) => removeExclude(pattern)
}, null, 8, ["label", "onRemove"]);
}), 128)),
__props.excludePatterns.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_15$2, toDisplayString(__props.useRegex ? "No regex patterns" : "No text patterns"), 1)) : createCommentVNode("", true)
])
])
])
]);
};
}
});
const NamePatternsSection = /* @__PURE__ */ _export_sfc(_sfc_main$j, [["__scopeId", "data-v-9995b5ed"]]);
const _hoisted_1$i = { class: "section" };
const _hoisted_2$e = { class: "section__toggles" };
const _hoisted_3$c = { class: "toggle-item" };
@@ -10982,13 +11237,16 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
excludeTags: {},
includeFolders: {},
excludeFolders: {},
includePatterns: {},
excludePatterns: {},
useRegex: { type: Boolean },
noCreditRequired: { type: Boolean },
allowSelling: { type: Boolean },
previewItems: {},
matchCount: {},
isLoading: { type: Boolean }
},
emits: ["open-modal", "update:includeFolders", "update:excludeFolders", "update:noCreditRequired", "update:allowSelling", "refresh"],
emits: ["open-modal", "update:includeFolders", "update:excludeFolders", "update:includePatterns", "update:excludePatterns", "update:useRegex", "update:noCreditRequired", "update:allowSelling", "refresh"],
setup(__props) {
return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$g, [
@@ -11012,24 +11270,32 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
onEditInclude: _cache[5] || (_cache[5] = ($event) => _ctx.$emit("open-modal", "includeFolders")),
onEditExclude: _cache[6] || (_cache[6] = ($event) => _ctx.$emit("open-modal", "excludeFolders"))
}, null, 8, ["include-folders", "exclude-folders"]),
createVNode(NamePatternsSection, {
"include-patterns": __props.includePatterns,
"exclude-patterns": __props.excludePatterns,
"use-regex": __props.useRegex,
"onUpdate:includePatterns": _cache[7] || (_cache[7] = ($event) => _ctx.$emit("update:includePatterns", $event)),
"onUpdate:excludePatterns": _cache[8] || (_cache[8] = ($event) => _ctx.$emit("update:excludePatterns", $event)),
"onUpdate:useRegex": _cache[9] || (_cache[9] = ($event) => _ctx.$emit("update:useRegex", $event))
}, null, 8, ["include-patterns", "exclude-patterns", "use-regex"]),
createVNode(LicenseSection, {
"no-credit-required": __props.noCreditRequired,
"allow-selling": __props.allowSelling,
"onUpdate:noCreditRequired": _cache[7] || (_cache[7] = ($event) => _ctx.$emit("update:noCreditRequired", $event)),
"onUpdate:allowSelling": _cache[8] || (_cache[8] = ($event) => _ctx.$emit("update:allowSelling", $event))
"onUpdate:noCreditRequired": _cache[10] || (_cache[10] = ($event) => _ctx.$emit("update:noCreditRequired", $event)),
"onUpdate:allowSelling": _cache[11] || (_cache[11] = ($event) => _ctx.$emit("update:allowSelling", $event))
}, null, 8, ["no-credit-required", "allow-selling"])
]),
createVNode(LoraPoolPreview, {
items: __props.previewItems,
"match-count": __props.matchCount,
"is-loading": __props.isLoading,
onRefresh: _cache[9] || (_cache[9] = ($event) => _ctx.$emit("refresh"))
onRefresh: _cache[12] || (_cache[12] = ($event) => _ctx.$emit("refresh"))
}, null, 8, ["items", "match-count", "is-loading"])
]);
};
}
});
const LoraPoolSummaryView = /* @__PURE__ */ _export_sfc(_sfc_main$g, [["__scopeId", "data-v-328e7526"]]);
const LoraPoolSummaryView = /* @__PURE__ */ _export_sfc(_sfc_main$g, [["__scopeId", "data-v-83235a00"]]);
const _hoisted_1$f = { class: "lora-pool-modal__header" };
const _hoisted_2$b = { class: "lora-pool-modal__title-container" };
const _hoisted_3$a = { class: "lora-pool-modal__title" };
@@ -11672,7 +11938,7 @@ function useLoraPoolApi() {
});
};
const fetchLoras = async (params) => {
var _a2, _b, _c, _d;
var _a2, _b, _c, _d, _e2, _f;
isLoading.value = true;
try {
const urlParams = new URLSearchParams();
@@ -11692,6 +11958,11 @@ function useLoraPoolApi() {
if (params.allowSelling !== void 0) {
urlParams.set("allow_selling_generated_content", String(params.allowSelling));
}
(_e2 = params.namePatternsInclude) == null ? void 0 : _e2.forEach((pattern) => urlParams.append("name_pattern_include", pattern));
(_f = params.namePatternsExclude) == null ? void 0 : _f.forEach((pattern) => urlParams.append("name_pattern_exclude", pattern));
if (params.namePatternsUseRegex !== void 0) {
urlParams.set("name_pattern_use_regex", String(params.namePatternsUseRegex));
}
const response = await fetch(`/api/lm/loras/list?${urlParams}`);
const data = await response.json();
return {
@@ -11723,6 +11994,9 @@ function useLoraPoolState(widget) {
const excludeFolders = ref([]);
const noCreditRequired = ref(false);
const allowSelling = ref(false);
const includePatterns = ref([]);
const excludePatterns = ref([]);
const useRegex = ref(false);
const availableBaseModels = ref([]);
const availableTags = ref([]);
const folderTree = ref([]);
@@ -11745,6 +12019,11 @@ function useLoraPoolState(widget) {
license: {
noCreditRequired: noCreditRequired.value,
allowSelling: allowSelling.value
},
namePatterns: {
include: includePatterns.value,
exclude: excludePatterns.value,
useRegex: useRegex.value
}
},
preview: {
@@ -11758,7 +12037,7 @@ function useLoraPoolState(widget) {
return config;
};
const restoreFromConfig = (config) => {
var _a2, _b, _c, _d, _e2, _f;
var _a2, _b, _c, _d, _e2, _f, _g, _h, _i;
isRestoring = true;
try {
if (!(config == null ? void 0 : config.filters)) return;
@@ -11775,6 +12054,9 @@ function useLoraPoolState(widget) {
updateIfChanged(excludeFolders, ((_d = filters.folders) == null ? void 0 : _d.exclude) || []);
updateIfChanged(noCreditRequired, ((_e2 = filters.license) == null ? void 0 : _e2.noCreditRequired) ?? false);
updateIfChanged(allowSelling, ((_f = filters.license) == null ? void 0 : _f.allowSelling) ?? false);
updateIfChanged(includePatterns, ((_g = filters.namePatterns) == null ? void 0 : _g.include) || []);
updateIfChanged(excludePatterns, ((_h = filters.namePatterns) == null ? void 0 : _h.exclude) || []);
updateIfChanged(useRegex, ((_i = filters.namePatterns) == null ? void 0 : _i.useRegex) ?? false);
matchCount.value = (preview == null ? void 0 : preview.matchCount) || 0;
} finally {
isRestoring = false;
@@ -11799,6 +12081,9 @@ function useLoraPoolState(widget) {
foldersExclude: excludeFolders.value,
noCreditRequired: noCreditRequired.value || void 0,
allowSelling: allowSelling.value || void 0,
namePatternsInclude: includePatterns.value,
namePatternsExclude: excludePatterns.value,
namePatternsUseRegex: useRegex.value,
pageSize: 6
});
previewItems.value = result.items;
@@ -11819,7 +12104,10 @@ function useLoraPoolState(widget) {
includeFolders,
excludeFolders,
noCreditRequired,
allowSelling
allowSelling,
includePatterns,
excludePatterns,
useRegex
], onFilterChange, { deep: true });
return {
// Filter state
@@ -11830,6 +12118,9 @@ function useLoraPoolState(widget) {
excludeFolders,
noCreditRequired,
allowSelling,
includePatterns,
excludePatterns,
useRegex,
// Available options
availableBaseModels,
availableTags,
@@ -11902,6 +12193,9 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
"exclude-tags": unref(state).excludeTags.value,
"include-folders": unref(state).includeFolders.value,
"exclude-folders": unref(state).excludeFolders.value,
"include-patterns": unref(state).includePatterns.value,
"exclude-patterns": unref(state).excludePatterns.value,
"use-regex": unref(state).useRegex.value,
"no-credit-required": unref(state).noCreditRequired.value,
"allow-selling": unref(state).allowSelling.value,
"preview-items": unref(state).previewItems.value,
@@ -11910,16 +12204,19 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
onOpenModal: openModal,
"onUpdate:includeFolders": _cache[0] || (_cache[0] = ($event) => unref(state).includeFolders.value = $event),
"onUpdate:excludeFolders": _cache[1] || (_cache[1] = ($event) => unref(state).excludeFolders.value = $event),
"onUpdate:noCreditRequired": _cache[2] || (_cache[2] = ($event) => unref(state).noCreditRequired.value = $event),
"onUpdate:allowSelling": _cache[3] || (_cache[3] = ($event) => unref(state).allowSelling.value = $event),
"onUpdate:includePatterns": _cache[2] || (_cache[2] = ($event) => unref(state).includePatterns.value = $event),
"onUpdate:excludePatterns": _cache[3] || (_cache[3] = ($event) => unref(state).excludePatterns.value = $event),
"onUpdate:useRegex": _cache[4] || (_cache[4] = ($event) => unref(state).useRegex.value = $event),
"onUpdate:noCreditRequired": _cache[5] || (_cache[5] = ($event) => unref(state).noCreditRequired.value = $event),
"onUpdate:allowSelling": _cache[6] || (_cache[6] = ($event) => unref(state).allowSelling.value = $event),
onRefresh: unref(state).refreshPreview
}, null, 8, ["selected-base-models", "available-base-models", "include-tags", "exclude-tags", "include-folders", "exclude-folders", "no-credit-required", "allow-selling", "preview-items", "match-count", "is-loading", "onRefresh"]),
}, null, 8, ["selected-base-models", "available-base-models", "include-tags", "exclude-tags", "include-folders", "exclude-folders", "include-patterns", "exclude-patterns", "use-regex", "no-credit-required", "allow-selling", "preview-items", "match-count", "is-loading", "onRefresh"]),
createVNode(BaseModelModal, {
visible: unref(modalState).isModalOpen("baseModels"),
models: unref(state).availableBaseModels.value,
selected: unref(state).selectedBaseModels.value,
onClose: unref(modalState).closeModal,
"onUpdate:selected": _cache[4] || (_cache[4] = ($event) => unref(state).selectedBaseModels.value = $event)
"onUpdate:selected": _cache[7] || (_cache[7] = ($event) => unref(state).selectedBaseModels.value = $event)
}, null, 8, ["visible", "models", "selected", "onClose"]),
createVNode(TagsModal, {
visible: unref(modalState).isModalOpen("includeTags"),
@@ -11927,7 +12224,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
selected: unref(state).includeTags.value,
variant: "include",
onClose: unref(modalState).closeModal,
"onUpdate:selected": _cache[5] || (_cache[5] = ($event) => unref(state).includeTags.value = $event)
"onUpdate:selected": _cache[8] || (_cache[8] = ($event) => unref(state).includeTags.value = $event)
}, null, 8, ["visible", "tags", "selected", "onClose"]),
createVNode(TagsModal, {
visible: unref(modalState).isModalOpen("excludeTags"),
@@ -11935,7 +12232,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
selected: unref(state).excludeTags.value,
variant: "exclude",
onClose: unref(modalState).closeModal,
"onUpdate:selected": _cache[6] || (_cache[6] = ($event) => unref(state).excludeTags.value = $event)
"onUpdate:selected": _cache[9] || (_cache[9] = ($event) => unref(state).excludeTags.value = $event)
}, null, 8, ["visible", "tags", "selected", "onClose"]),
createVNode(FoldersModal, {
visible: unref(modalState).isModalOpen("includeFolders"),
@@ -11943,7 +12240,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
selected: unref(state).includeFolders.value,
variant: "include",
onClose: unref(modalState).closeModal,
"onUpdate:selected": _cache[7] || (_cache[7] = ($event) => unref(state).includeFolders.value = $event)
"onUpdate:selected": _cache[10] || (_cache[10] = ($event) => unref(state).includeFolders.value = $event)
}, null, 8, ["visible", "folders", "selected", "onClose"]),
createVNode(FoldersModal, {
visible: unref(modalState).isModalOpen("excludeFolders"),
@@ -11951,13 +12248,13 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
selected: unref(state).excludeFolders.value,
variant: "exclude",
onClose: unref(modalState).closeModal,
"onUpdate:selected": _cache[8] || (_cache[8] = ($event) => unref(state).excludeFolders.value = $event)
"onUpdate:selected": _cache[11] || (_cache[11] = ($event) => unref(state).excludeFolders.value = $event)
}, null, 8, ["visible", "folders", "selected", "onClose"])
]);
};
}
});
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$a, [["__scopeId", "data-v-4456abba"]]);
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$a, [["__scopeId", "data-v-0bbd50ea"]]);
const _hoisted_1$9 = { class: "last-used-preview" };
const _hoisted_2$6 = { class: "last-used-preview__content" };
const _hoisted_3$5 = ["src", "onError"];
@@ -14720,7 +15017,7 @@ function updateDownstreamLoaders(startNode, visited = /* @__PURE__ */ new Set())
}
}
const LORA_POOL_WIDGET_MIN_WIDTH = 500;
const LORA_POOL_WIDGET_MIN_HEIGHT = 400;
const LORA_POOL_WIDGET_MIN_HEIGHT = 520;
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500;
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448;
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT;

File diff suppressed because one or more lines are too long