Compare commits

..

142 Commits

Author SHA1 Message Date
Will Miao
796acba764 chore: bump version from 0.9.11 to 0.9.12
Update the project version in pyproject.toml to prepare for the next release.
2026-01-19 17:37:19 +08:00
Will Miao
3aab0cc916 feat: add v0.9.12 release notes and update LoRA Randomizer workflow example
- Introduce LoRA Randomizer system with LoRA Pool and Randomizer nodes
- Add recipe folders, bulk operations, search, sorting, and favorites
- Enable video recipe support and ComfyUI Nodes 2.0 compatibility
- Include performance improvements for faster startup and loading
- Update example workflow for LoRA Randomizer template reference
2026-01-19 16:23:49 +08:00
Will Miao
4c2c8c2bc8 feat(randomizer): add mode change listener to update downstream trigger words
Add LoraRandomizer extension that monitors node mode changes and triggers
updates to connected downstream trigger word toggle nodes, matching the
behavior implemented for Lora Stacker nodes.
2026-01-19 14:39:44 +08:00
Will Miao
e44180b832 feat(ui): exclude lock button from drag init in LoRA widget
Add `.lm-lora-lock-button` to the list of elements that should not trigger drag initialization in the LoRA widget event handler. This prevents unintended drag actions when interacting with the lock button, improving user experience and interaction clarity.
2026-01-19 12:53:59 +08:00
Will Miao
4ff397e9c1 fix(modals): preserve model type during navigation (#771)
Move cleanupNavigationShortcuts() call before setting navigationModelType
to ensure the correct model type is preserved when using left/right arrow
keys to navigate between models. Previously, the cleanup would immediately
nullify navigationModelType, causing type-specific modal sections (like
trigger words for embeddings and usage tips for loras) to disappear.
2026-01-19 09:17:05 +08:00
Will Miao
633ad2d386 fix(test): add fetch polyfill and update context menu test for new API implementation
- Add fetch polyfill to test setup for jsdom environment
- Update context menu test to match new implementation that uses fetch API
- Remove deprecated handleDownloadButton expectation
- Fix mock indices for multiple fetch calls

Resolves test failures from commit b0f0158 which refactored GlobalContextMenu
to use fetch API directly instead of calling exampleImagesManager.
2026-01-19 08:34:31 +08:00
Will Miao
1dee7f5cf9 feat(constants): standardize formatting and expand diffusion model list
- Normalize string quotes to double quotes across all constants for consistency
- Add trailing commas in dictionaries and lists to improve diff readability
- Expand DIFFUSION_MODEL_BASE_MODELS with additional Wan Video and Qwen models
- Fix comment spacing in NSFW_LEVELS dictionary
- Maintain all existing functionality while improving code style
2026-01-19 01:20:46 +08:00
Will Miao
b0f0158f98 feat(example-images): add force parameter to retry failed downloads
When force=true is passed via API, models in failed_models set are
re-downloaded instead of being skipped. On successful download, model is
removed from failed_models set.

This provides a manual batch repair mechanism for users when CivitAI
media server is temporarily down and causes empty folders.

Changes:
- Backend: Add force parameter to start_download(), _download_all_example_images(), _process_model()
- Backend: Skip failed_models check when force=true
- Backend: Remove model from failed_models on successful force retry
- Frontend: GlobalContextMenu now calls API with force=true directly
- Tests: Update mock to accept force parameter
2026-01-18 21:58:12 +08:00
Will Miao
7f2e8a0afb feat(search): add SQLite FTS5 full-text search index for recipes
Introduce a new RecipeFTSIndex class that provides fast prefix-based search across recipe fields (title, tags, LoRA names/models, prompts) using SQLite's FTS5 extension. The implementation supports sub-100ms search times for large datasets (20k+ recipes) and includes asynchronous indexing, incremental updates, and comprehensive unit tests.
2026-01-18 20:44:22 +08:00
Will Miao
7a7517cfb6 fix(test): add PointerEvent polyfills and update drag interaction test to match implementation 2026-01-18 16:32:01 +08:00
Will Miao
f0c852ef23 fix(randomizer): convert numeric config values to proper types to prevent string subtraction errors 2026-01-18 12:40:58 +08:00
Will Miao
839bcbd37f fix(settings): add default_unet_root to SYNC_KEYS for proper frontend sync
The default_unet_root setting was not being synced from backend to frontend
because it was missing from the _SYNC_KEYS tuple in misc_handlers.py. This
caused the "Default Diffusion Model Root" setting to always display "No Default"
even when a valid path was configured in settings.json.
2026-01-18 12:38:46 +08:00
Will Miao
ab6a4844f0 chore: remove unused md files 2026-01-18 11:59:50 +08:00
Will Miao
dad549f65f feat(download): auto-route diffusion models to unet folder based on baseModel, see #770
CivitAI does not distinguish between checkpoint and diffusion model types -
both are labeled as "checkpoint". For certain base model types like
"ZImageTurbo", all models are actually diffusion models and should be
saved to the unet/diffusion model folder instead of the checkpoint folder.

- Add DIFFUSION_MODEL_BASE_MODELS constant for known diffusion model types
- Add default_unet_root setting with auto-set logic
- Route downloads to unet folder when baseModel matches known diffusion types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 11:58:20 +08:00
Will Miao
aab1797269 Revert "feat: add automatic retry for failed example image downloads"
This reverts commit cb460fcdb0.
2026-01-18 10:55:30 +08:00
Will Miao
cb460fcdb0 feat: add automatic retry for failed example image downloads
- Add failed_model_timestamps to track when models fail
- Retry failed models after 24-hour cooldown period
- Skip retry if example folder already has files
- Skip retry if failure was less than 24 hours ago
- Log count of failed models with retry message
- Fix unbound snapshot variable in exception path
- Remove duplicate/unreachable directory check code
- Update string quotes to double quotes (PEP 8)

This fixes the issue where failed models were permanently skipped in
auto-download mode, even when their example folders were empty.
2026-01-18 08:55:49 +08:00
Will Miao
88e7f671d2 fix(autocomplete): resolve instability in Vue DOM mode and fix WanVideo node binding
- Fix infinite reinitialization loop by only validating stale widget.inputEl when it's actually in DOM
- Improve findWidgetInputElement to specifically search for textarea for text widgets, avoiding mismatches with checkbox inputs on nodes like WanVideo Lora Select that have toggle switches
- Add data-node-id based element search as primary strategy for better reliability across rendering modes
- Fix autocomplete initialization to properly handle element DOM state transitions

Fixes autocomplete failing after Canvas ↔ Vue DOM mode switches and WanVideo node always failing to trigger autocomplete.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-17 14:19:20 +08:00
Will Miao
07d599810d feat(debug): replace websocket with ComfyUI UI system for metadata display
- Update DebugMetadata node to return metadata via ComfyUI's UI system instead of websocket
- Add new JsonDisplayWidget Vue component for displaying metadata in the UI
- Remove dependency on PromptServer and websocket communication
- Improve error handling with proper UI feedback
- Maintain backward compatibility with existing metadata collection system
2026-01-16 21:29:53 +08:00
Will Miao
4f3c91b307 feat: migrate LoRA Manager top menu to actionBarButtons API
- Replace custom button creation and attachment logic with built-in actionBarButtons API
- Remove manual DOM manipulation for button positioning and retry logic
- Add custom styling and icon replacement for better visual integration
- Maintain existing functionality for opening LoRA Manager in same/new window
- Simplify extension setup by leveraging ComfyUI's extension system
2026-01-16 21:15:56 +08:00
Will Miao
ad7d372887 fix: use CivArchive provider when source is 'civarchive' (#769)
When users paste CivArchive URLs, the system now fetches metadata from
CivArchive API first instead of Civitai. This prevents download failures
when a model has been deleted from Civitai but remains available on
CivArchive with alternative mirrors.

Changes:
- Source-aware metadata fetching: Uses CivArchive API when source='civarchive'
- URL prioritization: Prefers non-Civitai mirrors for CivArchive downloads
- Fallback mechanism: Falls back to default provider if CivArchive fails

Fixes #769
2026-01-16 10:57:22 +08:00
Will Miao
4e909f3008 fix: enable wheel event handling in tags widget for Vue DOM render mode 2026-01-15 20:15:18 +08:00
Will Miao
bd0dfd4ef5 fix: lora entry selection and strength display issues
- Fix lora entry click-to-select broken after pointer events refactoring
  - Only stopPropagation() after pointer moves beyond 3 pixel threshold
  - This allows click events to fire on lora entries for selection
  - Applied to all drag handlers: initDrag, initHeaderDrag, initReorderDrag

- Fix strength value display to always show 2 decimal places
  - Use toFixed(2) when updating strength input during drag
  - Ensures consistent display (e.g., "1.00" instead of "1", "1.40" instead of "1.4")
2026-01-15 19:04:56 +08:00
Will Miao
c5b597dc89 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2026-01-15 16:05:41 +08:00
Will Miao
bd4958edc3 fix: improve loras widget drag functionality in Vue DOM render mode
- Use pointer events (pointerdown/pointermove/pointerup/pointercancel) with proper capture
- Fix drag not updating strength values by avoiding re-renders during drag
- Fix cursor stuck in resize state by ensuring proper cleanup
- Fix cursor showing wrong icon on hover (should be pointer)
- Ensure strength values display fixed width with 2 decimal places
- Remove unnecessary data-capture-wheel attribute (no wheel adjustment in loras widget)
- Add font-variant-numeric: tabular-nums for consistent number display

This ensures loras widget works consistently in both Canvas and Vue DOM render modes.
2026-01-15 15:57:48 +08:00
Will Miao
428a2ce420 fix: support multiple include folders in LoRA pool widget
- Add folder_include parameter support in backend API handlers
- Add folder_include to FilterCriteria and implement multi-folder filtering logic
- Update frontend to send all include folders instead of only the first
- Add tests for single/multiple include folders, include with exclude, and non-recursive filtering
2026-01-15 15:17:33 +08:00
Will Miao
5636437df2 fix: enable autocomplete in Vue DOM render mode
In Vue DOM render mode, widget.inputEl is not in the DOM, causing autocomplete to fail. This commit:

- Adds findWidgetInputElement() helper to search DOM for actual input elements
- Checks if widget.inputEl is in document before using it
- Falls back to DOM search for Vue-rendered widgets using .lg-node-widget containers
- Implements async initialization with retry logic (20 attempts, 50ms interval)
- Adds debug logging for troubleshooting
- Prevents duplicate initialization with isInitializing flag

Fixes autocomplete functionality for Lora Loader nodes when ComfyUI uses Vue DOM rendering instead of canvas rendering.
2026-01-15 14:34:05 +08:00
Will Miao
10c0668b02 fix: enable prompt search functionality in recipes page
- Add prompt option to recipes default searchOptions state
- Update SearchManager to propagate prompt option to backend
2026-01-15 09:45:03 +08:00
Will Miao
0c67ff85ee build: rebuild Vue widgets with slider compatibility fixes 2026-01-15 07:03:17 +08:00
Will Miao
cde6151c71 fix: make sliders compatible with Vue DOM render mode
Add data-capture-wheel attribute to SingleSlider and DualRangeSlider
components to prevent wheel events from being intercepted by the canvas
in ComfyUI's new Vue DOM render mode. This allows mouse wheel to work
for adjusting slider values while still enabling workflow zoom on
non-interactive widget areas.

Also update event handling to use pointer events with proper stop
propagation and pointer capture for reliable drag operations in both
rendering modes.

Update development guide with Section 8 documenting Vue DOM render mode
event handling patterns and best practices.
2026-01-15 07:03:05 +08:00
Will Miao
9ed5319ad2 refactor: remove Lora Demo node
Remove the Lora Demo Node (LoraDemoNode) and all related imports and mappings
from the codebase.
2026-01-14 22:44:53 +08:00
Will Miao
40756b7dd3 feat: add clear button to search inputs in modals
Add a clear button (X icon) to the search bars in BaseModelModal and TagsModal. The button appears when there is search text, and clicking it clears the search input and refocuses the search field.
2026-01-14 21:38:42 +08:00
Will Miao
2a9ceb9e85 feat: auto-focus search bar in LoRA pool modals 2026-01-14 21:22:52 +08:00
Will Miao
30077099ec fix: improve LoRA Randomizer toggle UX and semantic clarity
- Fix toggle UX consistency: both toggles now follow 'enabled → slider enabled' pattern
- Rename useSameClipStrength to useCustomClipRange for semantic clarity
- Update 'Respect Recommended Strength' label to 'Preset Strength Scale'
- Add explicit conversion logic in composable for backend compatibility
- Add visual disabled state for clip strength slider container
2026-01-14 18:43:46 +08:00
Will Miao
fc8240e99e feat: add "Respect Recommended Strength" feature to LoRA Randomizer
Add support for respecting recommended strength values from LoRA usage_tips
when randomizing LoRA selection.

Features:
- New toggle setting to enable/disable recommended strength respect (default off)
- Scale range slider (0-2, default 0.5-1.0) to adjust recommended values
- Uses recommended strength × random(scale) when feature enabled
- Fallbacks to original Model/Clip Strength range when no recommendation exists
- Clip strength recommendations only apply when using Custom Range mode

Backend changes:
- Parse usage_tips JSON string to extract strength/clipStrength
- Apply scale factor to recommended values during randomization
- Pass new parameters through API route and node

Frontend changes:
- Update RandomizerConfig type with new properties
- Add new UI section with toggle and dual-range slider
- Wire up state management and event handlers
- No layout shift (removed description text)

Tests:
- Add tests for enabled/disabled recommended strength in API routes
- Add test verifying config passed to service
- All existing tests pass

Build: Include compiled Vue widgets
2026-01-14 16:34:24 +08:00
Will Miao
4951ff358e feat: add WSL and Docker support for file location opening
- Add WSL detection and Windows path conversion using wslpath
- Add Docker/Kubernetes detection via /.dockerenv and /proc/1/cgroup
- Implement clipboard fallback for containerized environments
- Update open_file_location handler to detect WSL/Docker before POSIX
- Update open_settings_location handler with same detection logic
- Add clipboard API integration with graceful fallback in frontend
- Add translations for clipboard feature across all 10 languages
- Add unit tests for _is_wsl(), _is_docker(), and _wsl_to_windows_path()

Fixes file manager opening failures in WSL and Docker environments.
2026-01-14 15:49:35 +08:00
Will Miao
73f2a34d08 fix: prevent cursor flickering when dragging slider handles
Fix issue where mouse cursor flickers between 'grabbing' and 'default'
while dragging slider handles. The cursor now remains 'grabbing'
throughout the entire drag operation regardless of mouse position.

Changes:
- Add dynamic 'is-dragging' class to SingleSlider and DualRangeSlider
- Apply cursor: grabbing to root component when dragging state is active
2026-01-14 11:47:47 +08:00
Will Miao
394eebe070 fix: avoid scanner.py false positives in test fixtures
Replace NODE_CLASS_MAPPINGS.update({...}) with direct assignment
to prevent ComfyUI Manager scanner from detecting test mock nodes
as actual plugin nodes.

The scanner.py pattern '_CLASS_MAPPINGS\.update\s*\(\s*{([^}]*)}\s*\)'
was matching test fixtures that use .update() to register mock nodes,
causing false positive conflict warnings.
2026-01-14 10:21:44 +08:00
Will Miao
bc08a45214 feat: improve code formatting and readability in model handlers
- Add blank line after module docstring for better PEP 8 compliance
- Reformat long lines to adhere to 88-character limit using Black-style formatting
- Improve string consistency by using double quotes consistently
- Enhance readability of complex list comprehensions and method calls
- Maintain all existing functionality while improving code structure
2026-01-13 22:57:15 +08:00
Will Miao
0c96e8d328 chore: rename example workflow files to use underscores 2026-01-13 20:03:18 +08:00
Will Miao
859277a7eb feat(ui): enhance tag hover states and adjust toggle switch alignment
- Improve tag chip hover states in TagsModal with contextual colors for include/exclude modes
- Adjust toggle switch thumb vertical alignment in LicenseSection and LoraRandomizerSettingsView
- Remove debug console.log from loras widget value update
2026-01-13 19:54:36 +08:00
Will Miao
9e510d64ec feat(lora-randomizer): prevent early watch triggers by tracking mount state
Add isMounted ref to LoraRandomizerWidget to avoid premature updates from the loras widget watch. The watch now only responds after the component is fully mounted, and the onMounted hook captures the initial loras widget value before enabling the watcher. This prevents the watch from overwriting valid initial data with empty values during component initialization.
2026-01-13 19:22:51 +08:00
Will Miao
430ba84cf7 feat(workflow): add lora randomizer template workflow 2026-01-13 19:17:28 +08:00
Will Miao
0ae2d084f4 feat(lora-randomizer): add segmented scale mode to strength sliders
- Add `scaleMode` and `segments` props to DualRangeSlider component
- Implement segmented scale visualization with configurable segment widths
- Define strength segments for model and clip strength sliders with expanded middle range
- Enable finer control in common value ranges via wheel step multipliers
2026-01-13 16:16:11 +08:00
Will Miao
514846cd4a feat(lora-randomizer): refactor randomization logic and add input preprocessing
- Add `_preprocess_loras_input` method to handle different widget input formats
- Move core randomization logic to `LoraService` for better separation of concerns
- Update `_select_loras` method to use new service-based approach
- Add comprehensive test fixtures for license filtering scenarios
- Include debug print statement for pool config inspection during development

This refactor improves code organization by centralizing business logic in the service layer while maintaining backward compatibility with existing widget inputs.
2026-01-13 15:47:59 +08:00
Will Miao
1ebd2c93a0 feat: add .opencode to gitignore and refactor lora routes
- Add .opencode directory to gitignore for agent-related files
- Refactor lora_routes.py with consistent string formatting and improved route registration
- Add DualRangeSlider Vue component for enhanced UI controls
2026-01-13 13:59:36 +08:00
Will Miao
688baef2f0 feat(dom-widgets): forward middle mouse events to canvas for panning
Add `forwardMiddleMouseToCanvas` utility to forward middle mouse button events from DOM widgets to the ComfyUI canvas, enabling workflow panning when the cursor is over a widget. The function is implemented in `vue-widgets/src/main.ts` and documented in the developer guide. Additionally, fix `getPoolConfigFromConnectedNode` to return null for inactive pool nodes.
2026-01-13 11:45:12 +08:00
Will Miao
6a17e75782 docs: add frontend UI architecture and ComfyUI widget guidelines
- Document dual UI systems: standalone web UI and ComfyUI custom node widgets
- Add ComfyUI widget development guidelines including styling and constraints
- Update terminology in LoraRandomizerNode from 'frontend/backend' to 'fixed/always' for clarity
- Include UI constraints for ComfyUI widgets: minimize vertical space, avoid dynamic height changes, keep UI simple
2026-01-13 11:20:50 +08:00
Will Miao
bce6b0e610 feat(randomizer): add LoRA locking and roll modes
- Implement LoRA locking to prevent specific LoRAs from being changed during randomization
- Add visual styling for locked state with amber accents and distinct backgrounds
- Introduce `roll_mode` configuration with 'backend' (execute current selection while generating new) and 'frontend' (execute newly generated selection) behaviors
- Move LoraPoolNode to 'Lora Manager/randomizer' category and remove standalone class mappings
- Standardize RETURN_NAMES in LoraRandomizerNode for consistency
2026-01-12 21:53:47 +08:00
Will Miao
177b20263d feat: add LoraDemoNode and LoraRandomizerNode with documentation
- Import and register two new nodes: LoraDemoNode and LoraRandomizerNode
- Update import exception handling for better readability with multi-line formatting
- Add comprehensive documentation file `docs/custom-node-ui-output.md` for UI output usage in custom nodes
- Ensure proper node registration in NODE_CLASS_MAPPINGS for ComfyUI integration
- Maintain backward compatibility with existing node structure and import fallbacks
2026-01-12 15:06:38 +08:00
Will Miao
65cede7335 feat(lora-pool): add folder filtering and preview tooltip enhancements
- Add include/exclude folder modals for advanced filtering
- Implement folder tree search with auto-expand functionality
- Add hover tooltip to preview header showing matching LoRA thumbnails
- Format match count with locale string for better readability
- Prevent event propagation on refresh button click
- Improve folder tree component with expand/collapse controls
2026-01-12 10:08:16 +08:00
Will Miao
9719dd4d07 docs(dom_widget_dev_guide): clarify dynamic resizing and add performance note
- Add performance note explaining that providing `getMinHeight` and `getHeight` via `options` avoids expensive DOM measurements
- Expand dynamic resizing section with detailed update sequence and common scenarios table
- Update LoraPoolSummaryView.vue with `min-height: 0` to allow flex shrinking
- Update main.ts to provide `getMinHeight` via options and adjust `computeLayoutSize` for performance
2026-01-12 09:22:18 +08:00
Will Miao
7a5f4514f3 feat(lora-pool): add external value handling and config update support
- Add `onSetValue` callback to handle external updates like workflow loading
- Implement `updateConfig` method for direct widget value updates
- Add value change detection in `restoreFromConfig` to prevent unnecessary updates
- Remove debug console log on component mount
- Extend widget value type to support legacy config format
2026-01-11 20:37:17 +08:00
Will Miao
b44ef9ceaa feat(ui): update LoRA pool widget color scheme and empty state styling
- Change primary accent color from green to blue across multiple components
- Update background colors for better visual consistency
- Improve empty state styling in TagsSection with better padding and background
- Add box-sizing to BaseModelSection for consistent layout
- Update CSS comments to reflect new color scheme
2026-01-11 19:54:44 +08:00
Will Miao
647728b2e1 feat: rename demo widget to lora-manager-widgets and remove demo node
- Update documentation to reflect new widget filename `lora-manager-widgets.js`
- Remove `LoraManagerDemoNode` import and registration from `__init__.py`
- Translate development guide from Chinese to English for broader accessibility
- Clean up obsolete demo references to align with actual widget implementation
2026-01-11 19:08:55 +08:00
Will Miao
3d348900ac feat(randomizer): add lora pool Vue widget 2026-01-11 16:26:38 +08:00
Will Miao
32249d1886 feat: add Vue widget demo node and development support
- Add LoraManagerDemoNode to node mappings for Vue widget demonstration
- Update .gitignore to exclude Vue widget development artifacts (node_modules, .vite, dist)
- Implement automatic Vue widget build check in development mode with fallback handling
- Maintain pytest compatibility with proper import error handling
2026-01-10 17:45:26 +08:00
Will Miao
f842ea990e feat(metadata): prevent overwriting high-quality Civitai API metadata with archive data. See #764
- Update `is_civitai_api_metadata` to exclude both "archive_db" and "civarchive" sources
- Skip Civitai metadata updates when existing metadata is higher quality than incoming archive data
- Add test to verify API metadata is preserved when CivArchive provides lower-quality data
2026-01-09 19:47:32 +08:00
Will Miao
f2e12c0fd3 feat: add "Strength Range" option to LoRA preset parameters dropdown, fixes #386 2026-01-08 22:19:19 +08:00
Will Miao
f62b3f62be feat(recipe_scanner): prioritize local sibling images and persist repairs
Updated image path resolution logic to prioritize local sibling images in the same directory as recipes. When a stored image path differs from a local sibling, the system now automatically updates the recipe file to use the local path and persists this repair. This improves reliability when recipe assets are moved or reorganized, ensuring images remain accessible even if original paths become invalid.
2026-01-08 15:53:01 +08:00
Will Miao
b57a317c82 feat(docs): add DOMWidget development guide for vanilla JavaScript 2026-01-08 13:56:53 +08:00
Will Miao
fa063ba1ce fix: Reprocess example images with missing folders, improve error handling, and add new tests. Fixes #760 2026-01-08 00:25:36 +08:00
Will Miao
eb30595d23 feat(header): improve CSS formatting and spacing 2026-01-07 16:20:46 +08:00
Will Miao
fd7cb3300d fix: Disable virtual scroll keyboard navigation when editing content or a modal is open. See #759 2026-01-07 00:01:09 +08:00
Will Miao
f199c9b591 feat: propagate version info to model update record creation. Fixes #756
- Pass `version_info` parameter through download manager to model update service
- Enhance `_create_record` to use version info when creating records for missing versions
- Add `_extract_single_version` helper method for consistent version extraction
- Improve handling of version metadata during library synchronization
2026-01-06 08:46:55 +08:00
Will Miao
255ca4fc93 fix: Reposition keyboard navigation tooltip and its arrow to the left. Fixes #753 2026-01-04 19:26:13 +08:00
Will Miao
09c1bd78cd feat: Add and hide loading indicators for tag operations. Fixes #755 2026-01-04 19:12:15 +08:00
Will Miao
edbcca9bbd refactor: simplify symlink cache invalidation by removing background rescan and noise_mtime in favor of a root-path-only fingerprint. 2026-01-03 19:29:53 +08:00
Will Miao
8c68298202 feat: rename WanVideoLoraSelect node's class name. 2026-01-03 17:12:21 +08:00
Will Miao
a80380d1f0 chore: reduce symlink cache and scan log verbosity from info to debug level. 2026-01-03 14:50:05 +08:00
Will Miao
f13f22c949 chore: Improve logging by adding SHA256 hash calculation timing and clarifying metadata creation messages. 2026-01-03 08:56:34 +08:00
Will Miao
07aeeb6c70 feat: Deselect moved items and no longer automatically exit bulk mode after a successful move. Fixes #749 2026-01-02 20:36:06 +08:00
Will Miao
4317b06049 fix: Prevent EXIF metadata operations on video files by adding type checks. 2026-01-02 20:18:03 +08:00
Will Miao
ab85ba54a9 feat: Implement recipe repair cancellation with UI support and refactor LoadingManager to a singleton. 2026-01-02 20:03:27 +08:00
Will Miao
837c32c42f feat: implement task cancellation for model scanning and bulk operations 2026-01-02 18:48:28 +08:00
Will Miao
953117efa1 feat: add logging setup and standalone mode detection to LoRA Manager
- Initialize logging configuration via `setup_logging()` when not in standalone mode
- Detect standalone mode using environment variables `LORA_MANAGER_STANDALONE` and `HF_HUB_DISABLE_TELEMETRY`
- Remove redundant `STANDALONE_MODE` variable that previously checked `sys.modules`
2025-12-31 18:52:44 +08:00
Will Miao
afa5533145 feat: reduce log verbosity by changing info logs to debug
Changed logging level from INFO to DEBUG for performance-related messages in model management service. This reduces noise in production logs while maintaining debugging capability for performance analysis.
2025-12-31 16:14:24 +08:00
Will Miao
102defe29c feat: add open settings location endpoint
- Add `open_settings_location` method to `FileSystemHandler` to open OS file explorer at settings file location
- Register new POST route `/api/lm/settings/open-location` for settings file access
- Inject `SettingsManager` dependency into `FileSystemHandler` constructor
- Add cross-platform support for Windows, macOS, and Linux file explorers
- Include error handling for missing settings files and system exceptions
2025-12-31 16:09:23 +08:00
Will Miao
8120716cd8 feat: enhance model move functionality with cache entry updates
- Return cache entry data from model move operations for immediate UI updates
- Add recalculate_type parameter to update_single_model_cache for proper type adjustment
- Propagate cache entry through API layer to frontend MoveManager
- Enable virtual scroller to update moved items with new cache data
2025-12-31 10:33:22 +08:00
Will Miao
2b239c3747 feat: add MoveManager for handling model movement, including UI, bulk operations, and path management. 2025-12-30 23:04:22 +08:00
Will Miao
a59c31bc06 fix: Synchronize aiohttp session creation and refresh with an asyncio lock to prevent race conditions. 2025-12-30 19:47:30 +08:00
Will Miao
d30c8e13df feat: implement various UI helpers including clipboard, toasts, theme toggling, and Civitai integration, and add RecipeModal component. 2025-12-29 16:14:55 +08:00
Will Miao
5d5a2a998a feat: Implement model move, import, and download functionalities with corresponding UI and API updates. 2025-12-28 21:18:27 +08:00
Will Miao
e5b557504e feat: Add context menu option to move checkpoint models between type folders and complete various UI translations. 2025-12-28 17:52:30 +08:00
Will Miao
e43aa5cae4 feat: Update recipe syntax API to accept a recipe ID and add a new test for the endpoint. 2025-12-27 22:09:15 +08:00
Will Miao
f5d5bffa61 feat: Improve Nunchaku LoRA loading with copy_with_ctx support and add unit tests. see #733 2025-12-27 21:46:14 +08:00
pixelpaws
7d6b717385 Merge pull request #743 from willmiao/sort-by-usage-count
Sort by usage count
2025-12-26 22:51:29 +08:00
Will Miao
d9ce2c56c0 feat: Add 'times used' translation keys and implement usage-based sorting in the model service. 2025-12-26 22:39:51 +08:00
pixelpaws
914d24b8bf Merge pull request #723 from stone9k/main
feat(usage_count): sorting by usage_count + usage_count on ModelCard
2025-12-26 22:18:06 +08:00
pixelpaws
1329294981 Merge branch 'sort-by-usage-count' into main 2025-12-26 22:17:03 +08:00
Will Miao
475906a25e perf: Add detailed performance logging to model retrieval, filtering, and sorting operations. see #711 2025-12-25 22:36:24 +08:00
Will Miao
84b68cff90 test: Add clone method to _DummyLoraLoader and import copy. 2025-12-25 21:48:58 +08:00
Will Miao
41759f5e67 refactor: use model.clone() instead of copy.deepcopy() for model duplication, see #733 2025-12-25 21:15:28 +08:00
Will Miao
91cd88f1df feat: Add recursive root folder scanning with API and UI updates. fixes #737 2025-12-25 21:07:52 +08:00
Will Miao
e5869648fb feat: Add keyboard navigation hints and a breadcrumb container to the recipes page, and refactor control layout. 2025-12-24 20:46:14 +08:00
Will Miao
7b139b9b1d refactor: Move base_model resolution to occur before checkpoint formatting and remove a gen_params checkpoint assertion. 2025-12-24 20:35:06 +08:00
Will Miao
a552f07448 feat: Refactor checkpoint metadata to use Civitai API naming conventions and remove gen_params checkpoint syncing. 2025-12-24 20:25:39 +08:00
Will Miao
6486107ca2 feat: Introduce recipe management with data models, scanning, enrichment, and repair for generation configurations. 2025-12-24 20:02:20 +08:00
Will Miao
6330c65d41 feat: Add recipe metadata repair functionality with UI, API, and progress tracking. 2025-12-23 21:50:58 +08:00
Will Miao
00e6904664 feat: Introduce "No tags" filter option for models and recipes. fixes #728 2025-12-23 18:48:35 +08:00
pixelpaws
39195aa529 Merge pull request #735 from willmiao/recipe-folder
Recipe folder
2025-12-23 18:21:48 +08:00
Will Miao
fc0a834beb feat: Introduce generation parameter merging from request, Civitai, and embedded image metadata, and enhance ComfyUI metadata parsing. 2025-12-23 15:31:04 +08:00
Will Miao
b044b329fc feat: Update recipes page with default descending date sort, refactor state properties for search/filters, and add new localization strings. 2025-12-23 11:57:25 +08:00
Will Miao
502c29c6bd test: add prompt filter option to recipes page tests 2025-12-23 11:00:08 +08:00
Will Miao
bc9dd317f7 feat: Add prompt search filter for recipes and fix 'Favorites' localization across multiple languages. 2025-12-23 10:52:12 +08:00
Will Miao
61816cf75d refactor: reposition bulk operations button in recipes UI 2025-12-23 10:09:04 +08:00
Will Miao
db7f09797b feat: Introduce recipe favoriting with star icon toggle and filter options. 2025-12-23 10:07:09 +08:00
Will Miao
6e64f97e2b feat: add bulk move recipes endpoint
Add new move_recipes_bulk endpoint to handle moving multiple recipes simultaneously. This improves efficiency when reorganizing recipe collections by allowing batch operations instead of individual moves.

- Add move_recipes_bulk handler method with proper error handling
- Register new POST /api/lm/recipes/move-bulk route
- Implement bulk move logic in persistence service
- Validate required parameters (recipe_ids and target_path)
- Handle common error cases including validation, not found, and server errors
2025-12-23 09:15:07 +08:00
Will Miao
3f646aa0c9 feat: add recipe root directory and move recipe endpoints
- Add GET /api/lm/recipes/roots endpoint to retrieve recipe root directories
- Add POST /api/lm/recipe/move endpoint to move recipes between directories
- Register new endpoints in route definitions
- Implement error handling for both new endpoints with proper status codes
- Enable recipe management operations for better file organization
2025-12-23 09:13:57 +08:00
Will Miao
67fb205b43 feat: add folder-based recipe organization and navigation
- Add new API endpoints for folder operations: get_folders, get_folder_tree, and get_unified_folder_tree
- Extend recipe listing handler to support folder and recursive filtering parameters
- Register new folder-related routes in route definitions
- Enable users to organize and browse recipes using folder structures
2025-12-23 09:12:27 +08:00
Will Miao
dd89aa49c1 feat: Add HTML and attribute escaping for trigger words and class tokens to prevent XSS vulnerabilities, along with new frontend tests. Fixes #732 2025-12-23 08:47:15 +08:00
Will Miao
3ba5c4c2ab refactor: improve update_lora_filename_by_hash logic and add a test to verify recipe updates. 2025-12-22 17:58:04 +08:00
Will Miao
7caca0163e feat: Add support for remote video analysis and preview for recipe imports. see #420 2025-12-21 21:42:28 +08:00
Will Miao
30fd0470de feat: Add support for video recipe previews by conditionally optimizing media during persistence and updating UI components to display videos. 2025-12-21 20:00:44 +08:00
Will Miao
63b087fc80 feat: Implement cache busting for static assets, remove client-side version mismatch banner, and add project overview documentation. 2025-12-19 22:40:36 +08:00
Will Miao
154ae82519 feat(metadata_processor): enhance primary sampler selection logic
- Add pre-processing step to populate missing parameters for candidate samplers, especially for SamplerCustomAdvanced requiring tracing
- Change sampler selection from most recent (closest to downstream) to first in execution order to prioritize base samplers over refine samplers
- Improve parameter handling by updating sampler parameters with traced values before ranking
- Maintain backward compatibility with fallback to first sampler if no criteria match
2025-12-19 01:30:08 +08:00
Will Miao
c8a179488a feat(metadata): enhance primary sampler detection and workflow tracing
- Add support for `basic_pipe` nodes in metadata processor to handle pipeline nodes like FromBasicPipe
- Optimize `find_primary_checkpoint` by accepting optional `primary_sampler_id` to avoid redundant calculations
- Update `get_workflow_trace` to pass known primary sampler ID for improved efficiency
2025-12-18 22:30:41 +08:00
Will Miao
ca6bb43406 feat: remove path separator normalization for cross-platform compatibility
Removed the forced normalization of path separators to forward slashes in BaseModelService to maintain platform-specific separators. Updated test cases to use os.sep for constructing expected paths, ensuring tests work correctly across different operating systems while preserving native path representations.
2025-12-17 19:07:08 +08:00
Will Miao
a07720a3bf feat: Add model path tracing to accurately identify the primary checkpoint in workflows and include new tests. 2025-12-17 12:52:52 +08:00
Will Miao
bdb4422cbc feat(ui): adjust modal header width and enhance close button z-index, fixes #729
- Decrease modal header width from 85% to 84% for better visual alignment
- Add z-index: 10 to close button to ensure it remains above other modal elements
2025-12-17 10:34:04 +08:00
Will Miao
099a71b2cc feat(config): seed root symlink mappings before deep scanning
Add `_seed_root_symlink_mappings` method to ensure symlinked root folders are recorded before deep scanning, preventing them from being missed during directory traversal. This ensures that root symlinks are properly captured in the path mappings.

Additionally, normalize separators in relative paths for cross-platform consistency in `BaseModelService`, and update tests to verify root symlinks are preserved in the cache.
2025-12-16 22:05:40 +08:00
Will Miao
3382d83aee feat: remove prewarm cache and improve recipe scanner initialization
- Remove prewarm_cache startup hook from BaseRecipeRoutes
- Add post-scan task management to RecipeScanner for proper cleanup
- Ensure LoRA scanner initialization completes before recipe enrichment
- Schedule post-scan enrichment after cache initialization
- Improve error handling and task cancellation during shutdown
2025-12-16 21:00:04 +08:00
Will Miao
7e133e4b9d feat: rename SaveImage class to SaveImageLM for clarity
The SaveImage class has been renamed to SaveImageLM to better reflect its purpose within the Lora Manager module. This change ensures consistent naming across import statements, class mappings, and the actual class definition, improving code readability and maintainability.
2025-12-15 22:09:26 +08:00
Will Miao
2494fa19a6 feat(config): add background symlink rescan and simplify cache validation
- Added threading import and optional `_rescan_thread` for background operations
- Simplified `_load_symlink_cache` to only validate path mappings, removing fingerprint checks
- Updated `_initialize_symlink_mappings` to rebuild preview roots and schedule rescan when cache is loaded
- Added `_schedule_symlink_rescan` method to perform background validation of symlinks
- Cleared `_path_mappings` at start of `_scan_symbolic_links` to prevent stale entries
- Background rescan improves performance by deferring symlink validation after cache load
2025-12-15 18:46:23 +08:00
Will Miao
5359129fad feat(config): improve symlink cache logging and add performance timing
- Add `time` import for performance measurement
- Change debug logs to info level for better visibility of cache operations
- Add detailed logging for cache validation failures and successes
- Include timing metrics for symlink initialization and scanning
- Log cache save/load operations with mapping counts
2025-12-14 15:58:58 +08:00
pixelpaws
4743b3c406 Merge pull request #724 from stone9k/patch-1
fix(trigger_word_toggle): missing consumeExistingState after refactor
2025-12-13 15:58:23 +08:00
stone9k
32d94be08a fix(trigger_word_toggle): missing consumeExistingState after refactor 2025-12-12 18:50:28 +01:00
stone9k
56143eb170 feat(usage_count): sorting by usage_count + usage_count on ModelCard 2025-12-12 16:39:24 +01:00
Will Miao
817de3a0ae test: improve vi.mock calls to preserve original module exports
Updated vi.mock calls in test files to use async importOriginal pattern, ensuring original module exports are preserved while mocking specific functions. This prevents unintended side effects and maintains better test isolation.
2025-12-11 18:27:55 +08:00
Will Miao
675d49e4ce feat(security): escape HTML attributes and content in model modal, fixes #720
- Import `escapeAttribute` and `escapeHtml` utilities from shared utils
- Remove duplicate `escapeAttribute` function from ModelModal.js
- Apply escaping to file path attributes in model modal and trigger words
- Escape folder path HTML content to prevent XSS vulnerabilities
- Ensure safe handling of user-controlled data in UI components
2025-12-11 18:08:35 +08:00
Will Miao
fbb95bc623 feat(context-menu): pass file path to NSFW level selector
- Add `cardPath` parameter to `show` method in NsfwLevelSelector component
- Include `filePath` from card dataset when calling selector in ModelContextMenuMixin
- Clear `cardPath` from dataset when hiding selector to prevent stale data

This enables the NSFW level selector to access the file path context, which may be needed for backend operations when changing NSFW levels.
2025-12-09 22:13:00 +08:00
Will Miao
6b3a11e01a fix(config): ensure symlink mappings are recorded before duplicate check
Update symlink traversal logic to always record path mappings before checking for visited directories. This prevents valid link->target pairs from being dropped when the target directory has already been visited via another path. Also correct path mapping lookup to properly replace link paths with their actual target paths.
2025-12-09 21:46:33 +08:00
Will Miao
40f7f14c1b ci: add symlink verification step to backend tests workflow
Add a Python script step to verify that the CI environment supports directory symlinks before running tests. This ensures that symlink-dependent tests will not fail due to environment limitations.
2025-12-09 21:32:50 +08:00
Will Miao
a6e23a7630 feat(example-images): add NSFW level setting endpoint
Add new POST endpoint `/api/lm/example-images/set-nsfw-level` to allow updating NSFW classification for individual example images. The endpoint supports both regular and custom images, validates required parameters, and updates the corresponding model metadata. This enables users to manually adjust NSFW ratings for better content filtering.
2025-12-09 20:37:16 +08:00
Will Miao
3fc72d6bc1 feat(config): replace symlink scanning with cached mapping system
- Add `get_settings_dir` import for cache directory resolution
- Replace `_scan_symbolic_links` and `_rebuild_preview_roots` with unified `_initialize_symlink_mappings` method
- Implement fingerprint-based cache validation using root mtimes, inodes, and noise-aware timestamps
- Add helper methods for path normalization, cache location, and symlink root aggregation
- Improve performance by avoiding redundant symlink traversal when directory structure is unchanged
2025-12-09 19:51:01 +08:00
Will Miao
a3a00bbeed feat(trigger_word_toggle): refactor trigger word filtering logic, fixes #718 and fixes #285
- Simplify and consolidate the logic for processing trigger words and groups
- Remove redundant code paths and improve maintainability
- Ensure consistent behavior between list and string trigger data inputs
- Preserve existing functionality for strength adjustment and group mode
2025-12-09 14:16:56 +08:00
Will Miao
74bfd397aa feat: add CSP middleware to allow remote media previews, fixes #710, see #715
Introduce `relax_csp_for_remote_media` middleware that modifies Content Security Policy headers to permit loading media from trusted external domains (Civitai and Genur). This is necessary for LoRA Manager UI previews when ComfyUI runs with `--disable-api-nodes`, which otherwise blocks remote images and videos. The middleware is inserted after ComfyUI's `block_external_middleware` to properly extend the restrictive CSP header.
2025-12-09 10:37:35 +08:00
Will Miao
5000478991 feat(download): support multiple model file extensions in archive extraction
- Add `_get_supported_extensions_for_type` method to return allowed extensions per model type
- Rename `_extract_safetensors_from_archive` to `_extract_model_files_from_archive` and extend to filter by allowed extensions
- Update error message to list supported extensions when archive contains no valid files
- Add test for extracting .pt embedding files from zip archives
2025-12-07 09:00:47 +08:00
Will Miao
40cd2e23ac feat(i18n): add model navigation translations for multiple languages
Add navigation section to locale files for model browsing functionality. Includes labels and tooltips for previous/next model navigation with keyboard shortcuts (←/→ arrows). Translations added for German, English, Spanish, French, Hebrew, Japanese, Korean, and Russian locales to support international users.
2025-12-06 10:01:09 +08:00
Will Miao
6efe59bd9e feat(model-modal): add dynamic update availability indicators, see #715
- Add update badge to versions tab button when model has updates
- Sync update status between modal and model cards in gallery
- Pass `onUpdateStatusChange` callback to versions tab for real-time updates
- Introduce `updateAvailabilityState` to track update status changes
- Improve user awareness of available model updates across UI components
2025-12-06 09:43:15 +08:00
Will Miao
83f379df33 feat(modal): add scrollbar-gutter to prevent layout shift 2025-12-05 22:27:42 +08:00
Will Miao
4d6f4fcf69 feat(model-modal): add keyboard navigation and UI controls for model browsing, fixes #714 and #350
- Add CSS for modal navigation buttons with hover and disabled states
- Implement keyboard shortcuts (arrow keys) for navigating between models
- Add navigation controls UI to modal header with previous/next buttons
- Store navigation state to enable sequential model browsing
- Clean up event handlers to prevent memory leaks when modal closes
2025-12-05 22:25:17 +08:00
Will Miao
22ee37b817 feat: parse aggregate commercial use values, see #708
Add support for parsing comma-separated and JSON-style commercial use permission values in both Python backend and JavaScript frontend. Implement helper functions to split aggregated values into individual permissions while preserving original values when no aggregation is detected.

Added comprehensive test coverage for the new parsing functionality to ensure correct handling of various input formats including strings, arrays, and iterable objects with aggregated commercial use values.
2025-11-30 17:18:28 +08:00
238 changed files with 39589 additions and 5263 deletions

View File

@@ -47,6 +47,30 @@ jobs:
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements-dev.txt pip install -r requirements-dev.txt
- name: Verify symlink support
run: |
python - <<'PY'
import os
import pathlib
import tempfile
root = pathlib.Path(tempfile.mkdtemp(prefix="lm-symlink-check-"))
target = root / "target"
target.mkdir()
link = root / "link"
try:
link.symlink_to(target, target_is_directory=True)
except OSError as exc:
raise SystemExit(f"Failed to create directory symlink in CI: {exc}")
is_link = os.path.islink(link)
is_dir = os.path.isdir(link)
realpath = os.path.realpath(link)
print(f"islink={is_link} isdir={is_dir} realpath={realpath}")
if not (is_link and is_dir and realpath == str(target)):
raise SystemExit("Directory symlink is not functioning correctly in CI; aborting.")
PY
- name: Run pytest with coverage - name: Run pytest with coverage
env: env:
COVERAGE_FILE: coverage/backend/.coverage COVERAGE_FILE: coverage/backend/.coverage

9
.gitignore vendored
View File

@@ -1,4 +1,5 @@
__pycache__/ __pycache__/
.pytest_cache/
settings.json settings.json
path_mappings.yaml path_mappings.yaml
output/* output/*
@@ -10,3 +11,11 @@ node_modules/
coverage/ coverage/
.coverage .coverage
model_cache/ model_cache/
# agent
.opencode/
# Vue widgets development cache (but keep build output)
vue-widgets/node_modules/
vue-widgets/.vite/
vue-widgets/dist/

202
AGENTS.md
View File

@@ -1,22 +1,192 @@
# Repository Guidelines # AGENTS.md
## Project Structure & Module Organization This file provides guidance for agentic coding assistants working in this repository.
ComfyUI LoRA Manager pairs a Python backend with browser-side widgets. Backend modules live in <code>py/</code> with HTTP entry points in <code>py/routes/</code>, feature logic in <code>py/services/</code>, shared helpers in <code>py/utils/</code>, and custom nodes in <code>py/nodes/</code>. UI scripts extend ComfyUI from <code>web/comfyui/</code>, while deploy-ready assets remain in <code>static/</code> and <code>templates/</code>. Localization files live in <code>locales/</code>, example workflows in <code>example_workflows/</code>, and interim tests such as <code>test_i18n.py</code> sit beside their source until a dedicated <code>tests/</code> tree lands.
## Build, Test, and Development Commands ## Development Commands
- <code>pip install -r requirements.txt</code> installs backend dependencies.
- <code>python standalone.py --port 8188</code> launches the standalone server for iterative development.
- <code>python -m pytest test_i18n.py</code> runs the current regression suite; target new files explicitly, e.g. <code>python -m pytest tests/test_recipes.py</code>.
- <code>python scripts/sync_translation_keys.py</code> synchronizes locale keys after UI string updates.
## Coding Style & Naming Conventions ### Backend Development
Follow PEP 8 with four-space indentation and descriptive snake_case file and function names such as <code>settings_manager.py</code>. Classes stay PascalCase, constants in UPPER_SNAKE_CASE, and loggers retrieved via <code>logging.getLogger(__name__)</code>. Prefer explicit type hints and docstrings on public APIs. JavaScript under <code>web/comfyui/</code> uses ES modules with camelCase helpers and the <code>_widget.js</code> suffix for UI components.
## Testing Guidelines ```bash
Pytest powers backend tests. Name modules <code>test_<feature>.py</code> and keep them near the code or in a future <code>tests/</code> package. Mock ComfyUI dependencies through helpers in <code>standalone.py</code>, keep filesystem fixtures deterministic, and ensure translations are covered. Run <code>python -m pytest</code> before submitting changes. # Install dependencies
pip install -r requirements.txt
pip install -r requirements-dev.txt
## Commit & Pull Request Guidelines # Run standalone server (port 8188 by default)
Commits follow the conventional format, e.g. <code>feat(settings): add default model path</code>, and should stay focused on a single concern. Pull requests must outline the problem, summarize the solution, list manual verification steps (server run, targeted pytest), and link related issues. Include screenshots or GIFs for UI or locale updates and call out migration steps such as <code>settings.json</code> adjustments. python standalone.py --port 8188
# Run all backend tests
pytest
# Run specific test file
pytest tests/test_recipes.py
# Run specific test function
pytest tests/test_recipes.py::test_function_name
# Run backend tests with coverage
COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py \
--cov=standalone \
--cov-report=term-missing \
--cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json
```
### Frontend Development
```bash
# Install frontend dependencies
npm install
# Run frontend tests
npm test
# Run frontend tests in watch mode
npm run test:watch
# Run frontend tests with coverage
npm run test:coverage
```
## Python Code Style
### Imports
- Use `from __future__ import annotations` for forward references in type hints
- Group imports: standard library, third-party, local (separated by blank lines)
- Use absolute imports within `py/` package: `from ..services import X`
- Mock ComfyUI dependencies in tests using `tests/conftest.py` patterns
### Formatting & Types
- PEP 8 with 4-space indentation
- Type hints required for function signatures and class attributes
- Use `TYPE_CHECKING` guard for type-checking-only imports
- Prefer dataclasses for simple data containers
- Use `Optional[T]` for nullable types, `Union[T, None]` only when necessary
### Naming Conventions
- Files: `snake_case.py` (e.g., `model_scanner.py`, `lora_service.py`)
- Classes: `PascalCase` (e.g., `ModelScanner`, `LoraService`)
- Functions/variables: `snake_case` (e.g., `get_instance`, `model_type`)
- Constants: `UPPER_SNAKE_CASE` (e.g., `VALID_LORA_TYPES`)
- Private members: `_single_underscore` (protected), `__double_underscore` (name-mangled)
### Error Handling
- Use `logging.getLogger(__name__)` for module-level loggers
- Define custom exceptions in `py/services/errors.py`
- Use `asyncio.Lock` for thread-safe singleton patterns
- Raise specific exceptions with descriptive messages
- Log errors at appropriate levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
### Async Patterns
- Use `async def` for I/O-bound operations
- Mark async tests with `@pytest.mark.asyncio`
- Use `async with` for context managers
- Singleton pattern with class-level locks: see `ModelScanner.get_instance()`
- Use `aiohttp.web.Response` for HTTP responses
### Testing Patterns
- Use `pytest` with `--import-mode=importlib`
- Fixtures in `tests/conftest.py` handle ComfyUI mocking
- Use `@pytest.mark.no_settings_dir_isolation` for tests needing real paths
- Test files: `tests/test_*.py`
- Use `tmp_path_factory` for temporary directory isolation
## JavaScript Code Style
### Imports & Modules
- ES modules with `import`/`export`
- Use `import { app } from "../../scripts/app.js"` for ComfyUI integration
- Export named functions/classes: `export function foo() {}`
- Widget files use `*_widget.js` suffix
### Naming & Formatting
- camelCase for functions, variables, object properties
- PascalCase for classes/constructors
- Constants: `UPPER_SNAKE_CASE` (e.g., `CONVERTED_TYPE`)
- Files: `snake_case.js` or `kebab-case.js`
- 2-space indentation preferred (follow existing file conventions)
### Widget Development
- Use `app.registerExtension()` to register ComfyUI extensions
- Use `node.addDOMWidget(name, type, element, options)` for custom widgets
- Event handlers attached via `addEventListener` or widget callbacks
- See `web/comfyui/utils.js` for shared utilities
## Architecture Patterns
### Service Layer
- Use `ServiceRegistry` singleton for dependency injection
- Services follow singleton pattern via `get_instance()` class method
- Separate scanners (discovery) from services (business logic)
- Handlers in `py/routes/handlers/` implement route logic
### Model Types
- BaseModelService is abstract base for LoRA, Checkpoint, Embedding services
- ModelScanner provides file discovery and hash-based deduplication
- Persistent cache in SQLite via `PersistentModelCache`
- Metadata sync from CivitAI/CivArchive via `MetadataSyncService`
### Routes & Handlers
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, etc.
- Handlers are pure functions taking dependencies as parameters
- Use `WebSocketManager` for real-time progress updates
- Return `aiohttp.web.json_response` or `web.Response`
### Recipe System
- Base metadata in `py/recipes/base.py`
- Enrichment adds model metadata: `RecipeEnrichmentService`
- Parsers for different formats in `py/recipes/parsers/`
## Important Notes
- Always use English for comments (per copilot-instructions.md)
- Dual mode: ComfyUI plugin (uses folder_paths) vs standalone (reads settings.json)
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
- Settings auto-saved in user directory or portable mode
- WebSocket broadcasts for real-time updates (downloads, scans)
- Symlink handling requires normalized paths
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
- Run `python scripts/sync_translation_keys.py` after UI string updates
## Frontend UI Architecture
This project has two distinct UI systems:
### 1. Standalone Lora Manager Web UI
- Location: `./static/` and `./templates/`
- Purpose: Full-featured web application for managing LoRA models
- Tech stack: Vanilla JS + CSS, served by the standalone server
- Development: Uses npm for frontend testing (`npm test`, `npm run test:watch`, etc.)
### 2. ComfyUI Custom Node Widgets
- Location: `./web/comfyui/`
- Purpose: Widgets and UI logic that ComfyUI loads as custom node extensions
- Tech stack: Vanilla JS + Vue.js widgets (in `./vue-widgets/` and built to `./web/comfyui/vue-widgets/`)
- Widget styling: Primary styles in `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
- Development: No npm build step for these widgets (Vue widgets use build system)
### Widget Development Guidelines
- Use `app.registerExtension()` to register ComfyUI extensions (ComfyUI integration layer)
- Use `node.addDOMWidget()` for custom DOM widgets
- Widget styles should follow the patterns in `./web/comfyui/lm_styles.css`
- Selected state: `rgba(66, 153, 225, 0.3)` background, `rgba(66, 153, 225, 0.6)` border
- Hover state: `rgba(66, 153, 225, 0.2)` background
- Color palette matches the Lora Manager accent color (blue #4299e1)
- Use oklch() for color values when possible (defined in `./static/css/base.css`)
- Vue widget components are in `./vue-widgets/src/components/` and built to `./web/comfyui/vue-widgets/`
- When modifying widget styles, check `./web/comfyui/lm_styles.css` for consistency with other ComfyUI widgets
## Configuration & Localization Tips
Copy <code>settings.json.example</code> to <code>settings.json</code> and adapt model directories before running the standalone server. Store reference assets in <code>civitai/</code> or <code>docs/</code> to keep runtime directories deploy-ready. Whenever UI text changes, update every <code>locales/&lt;lang&gt;.json</code> file and rerun the translation sync script so ComfyUI surfaces localized strings.

211
CLAUDE.md Normal file
View File

@@ -0,0 +1,211 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
## Development Commands
### Backend Development
```bash
# Install dependencies
pip install -r requirements.txt
# Install development dependencies (for testing)
pip install -r requirements-dev.txt
# Run standalone server (port 8188 by default)
python standalone.py --port 8188
# Run backend tests with coverage
COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py \
--cov=standalone \
--cov-report=term-missing \
--cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json
# Run specific test file
pytest tests/test_recipes.py
```
### Frontend Development
```bash
# Install frontend dependencies
npm install
# Run frontend tests
npm test
# Run frontend tests in watch mode
npm run test:watch
# Run frontend tests with coverage
npm run test:coverage
```
### Localization
```bash
# Sync translation keys after UI string updates
python scripts/sync_translation_keys.py
```
## Architecture
### Backend Structure (Python)
**Core Entry Points:**
- `__init__.py` - ComfyUI plugin entry point, registers nodes and routes
- `standalone.py` - Standalone server that mocks ComfyUI dependencies
- `py/lora_manager.py` - Main LoraManager class that registers HTTP routes
**Service Layer** (`py/services/`):
- `ServiceRegistry` - Singleton service registry for dependency management
- `ModelServiceFactory` - Factory for creating model services (LoRA, Checkpoint, Embedding)
- Scanner services (`lora_scanner.py`, `checkpoint_scanner.py`, `embedding_scanner.py`) - Model file discovery and indexing
- `model_scanner.py` - Base scanner with hash-based deduplication and metadata extraction
- `persistent_model_cache.py` - SQLite-based cache for model metadata
- `metadata_sync_service.py` - Syncs metadata from CivitAI/CivArchive APIs
- `civitai_client.py` / `civarchive_client.py` - API clients for external services
- `downloader.py` / `download_manager.py` - Model download orchestration
- `recipe_scanner.py` - Recipe file management and image association
- `settings_manager.py` - Application settings with migration support
- `websocket_manager.py` - WebSocket broadcasting for real-time updates
- `use_cases/` - Business logic orchestration (auto-organize, bulk refresh, downloads)
**Routes Layer** (`py/routes/`):
- Route registrars organize endpoints by domain (models, recipes, previews, example images, updates)
- `handlers/` - Request handlers implementing business logic
- Routes use aiohttp and integrate with ComfyUI's PromptServer
**Recipe System** (`py/recipes/`):
- `base.py` - Base recipe metadata structure
- `enrichment.py` - Enriches recipes with model metadata
- `merger.py` - Merges recipe data from multiple sources
- `parsers/` - Parsers for different recipe formats (PNG, JSON, workflow)
**Custom Nodes** (`py/nodes/`):
- `lora_loader.py` - LoRA loader nodes with preset support
- `save_image.py` - Enhanced save image with pattern-based filenames
- `trigger_word_toggle.py` - Toggle trigger words in prompts
- `lora_stacker.py` - Stack multiple LoRAs
- `prompt.py` - Prompt node with autocomplete
- `wanvideo_lora_select.py` - WanVideo-specific LoRA selection
**Configuration** (`py/config.py`):
- Manages folder paths for models, checkpoints, embeddings
- Handles symlink mappings for complex directory structures
- Auto-saves paths to settings.json in ComfyUI mode
### Frontend Structure (JavaScript)
**ComfyUI Widgets** (`web/comfyui/`):
- Vanilla JavaScript ES modules extending ComfyUI's LiteGraph-based UI
- `loras_widget.js` - Main LoRA selection widget with preview
- `loras_widget_events.js` - Event handling for widget interactions
- `autocomplete.js` - Autocomplete for trigger words and embeddings
- `preview_tooltip.js` - Preview tooltip for model cards
- `top_menu_extension.js` - Adds "Launch LoRA Manager" menu item
- `trigger_word_highlight.js` - Syntax highlighting for trigger words
- `utils.js` - Shared utilities and API helpers
**Widget Development:**
- Widgets use `app.registerExtension` and `getCustomWidgets` hooks
- `node.addDOMWidget(name, type, element, options)` embeds HTML in nodes
- See `docs/dom_widget_dev_guide.md` for complete DOMWidget development guide
**Web Source** (`web-src/`):
- Modern frontend components (if migrating from static)
- `components/` - Reusable UI components
- `styles/` - CSS styling
### Key Patterns
**Dual Mode Operation:**
- ComfyUI plugin mode: Integrates with ComfyUI's PromptServer, uses folder_paths
- Standalone mode: Mocks ComfyUI dependencies via `standalone.py`, reads paths from settings.json
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
**Settings Management:**
- Settings stored in user directory (via `platformdirs`) or portable mode (in repo)
- Migration system tracks settings schema version
- Template in `settings.json.example` defines defaults
**Model Scanning Flow:**
1. Scanner walks folder paths, computes file hashes
2. Hash-based deduplication prevents duplicate processing
3. Metadata extracted from safetensors headers
4. Persistent cache stores results in SQLite
5. Background sync fetches CivitAI/CivArchive metadata
6. WebSocket broadcasts updates to connected clients
**Recipe System:**
- Recipes store LoRA combinations with parameters
- Supports import from workflow JSON, PNG metadata
- Images associated with recipes via sibling file detection
- Enrichment adds model metadata for display
**Frontend-Backend Communication:**
- REST API for CRUD operations
- WebSocket for real-time progress updates (downloads, scans)
- API endpoints follow `/loras/*` pattern
## Code Style
**Python:**
- PEP 8 with 4-space indentation
- snake_case for files, functions, variables
- PascalCase for classes
- Type hints preferred
- English comments only (per copilot-instructions.md)
- Loggers via `logging.getLogger(__name__)`
**JavaScript:**
- ES modules with camelCase
- Files use `*_widget.js` suffix for ComfyUI widgets
- Prefer vanilla JS, avoid framework dependencies
## Testing
**Backend Tests:**
- pytest with `--import-mode=importlib`
- Test files: `tests/test_*.py`
- Fixtures in `tests/conftest.py`
- Mock ComfyUI dependencies using standalone.py patterns
- Markers: `@pytest.mark.asyncio` for async tests, `@pytest.mark.no_settings_dir_isolation` for real paths
**Frontend Tests:**
- Vitest with jsdom environment
- Test files: `tests/frontend/**/*.test.js`
- Setup in `tests/frontend/setup.js`
- Coverage via `npm run test:coverage`
## Important Notes
**Settings Location:**
- ComfyUI mode: Auto-saves folder paths to user settings directory
- Standalone mode: Use `settings.json` (copy from `settings.json.example`)
- Portable mode: Set `"use_portable_settings": true` in settings.json
**API Integration:**
- CivitAI API key required for downloads (add to settings)
- CivArchive API used as fallback for deleted models
- Metadata archive database available for offline metadata
**Symlink Handling:**
- Config scans symlinks to map virtual paths to physical locations
- Preview validation uses normalized preview root paths
- Fingerprinting prevents redundant symlink rescans
**ComfyUI Node Development:**
- Nodes defined in `py/nodes/`, registered in `__init__.py`
- Frontend widgets in `web/comfyui/`, matched by node type
- Use `WEB_DIRECTORY = "./web/comfyui"` convention
**Recipe Image Association:**
- Recipes scan for sibling images in same directory
- Supports repair/migration of recipe image paths
- See `py/services/recipe_scanner.py` for implementation details

103
IFLOW.md
View File

@@ -1,103 +0,0 @@
# ComfyUI LoRA Manager - iFlow 上下文
## 项目概述
ComfyUI LoRA Manager 是一个全面的工具集,用于简化 ComfyUI 中 LoRA 模型的组织、下载和应用。它提供了强大的功能,如配方管理、检查点组织和一键工作流集成,使模型操作更快、更流畅、更简单。
该项目是一个 Python 后端与 JavaScript 前端结合的 Web 应用程序,既可以作为 ComfyUI 的自定义节点运行,也可以作为独立应用程序运行。
## 项目结构
```
D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\
├── py/ # Python 后端代码
│ ├── config.py # 全局配置
│ ├── lora_manager.py # 主入口点
│ ├── controllers/ # 控制器
│ ├── metadata_collector/ # 元数据收集器
│ ├── middleware/ # 中间件
│ ├── nodes/ # ComfyUI 节点
│ ├── recipes/ # 配方相关
│ ├── routes/ # API 路由
│ ├── services/ # 业务逻辑服务
│ ├── utils/ # 工具函数
│ └── validators/ # 验证器
├── static/ # 静态资源 (CSS, JS, 图片)
├── templates/ # HTML 模板
├── locales/ # 国际化文件
├── tests/ # 测试代码
├── standalone.py # 独立模式入口
├── requirements.txt # Python 依赖
├── package.json # Node.js 依赖和脚本
└── README.md # 项目说明
```
## 核心组件
### 后端 (Python)
- **主入口**: `py/lora_manager.py``standalone.py`
- **配置**: `py/config.py` 管理全局配置和路径
- **路由**: `py/routes/` 目录下包含各种 API 路由
- **服务**: `py/services/` 目录下包含业务逻辑,如模型扫描、下载管理等
- **模型管理**: 使用 `ModelServiceFactory` 来管理不同类型的模型 (LoRA, Checkpoint, Embedding)
### 前端 (JavaScript)
- **构建工具**: 使用 Node.js 和 npm 进行依赖管理和测试
- **测试**: 使用 Vitest 进行前端测试
## 构建和运行
### 安装依赖
```bash
# Python 依赖
pip install -r requirements.txt
# Node.js 依赖 (用于测试)
npm install
```
### 运行 (ComfyUI 模式)
作为 ComfyUI 的自定义节点安装后,在 ComfyUI 中启动即可。
### 运行 (独立模式)
```bash
# 使用默认配置运行
python standalone.py
# 指定主机和端口
python standalone.py --host 127.0.0.1 --port 9000
```
### 测试
#### 后端测试
```bash
# 安装开发依赖
pip install -r requirements-dev.txt
# 运行测试
pytest
```
#### 前端测试
```bash
# 运行测试
npm run test
# 运行测试并生成覆盖率报告
npm run test:coverage
```
## 开发约定
- **代码风格**: Python 代码应遵循 PEP 8 规范
- **测试**: 新功能应包含相应的单元测试
- **配置**: 使用 `settings.json` 文件进行用户配置
- **日志**: 使用 Python 标准库 `logging` 模块进行日志记录

View File

@@ -34,6 +34,17 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes ## Release Notes
### v0.9.12
* **LoRA Randomizer System** - Introduced a comprehensive LoRA randomization system featuring LoRA Pool and LoRA Randomizer nodes for flexible and dynamic generation workflows.
* **LoRA Randomizer Template** - Refer to the new "LoRA Randomizer" template workflow for detailed examples of flexible randomization modes, lock & reuse options, and other features.
* **Recipe Folders** - Introduced a folder system for the Recipes page, allowing users to freely organize recipes just like they do with models.
* **Recipe Bulk Operations** - Added bulk mode support for batch moving, deleting, and setting base models for selected recipes with intuitive controls like click-and-drag selection, drag-to-folder, and Ctrl+A (Select All).
* **Prompt Search & Sorting** - Search recipes by prompt content and sort by Recipe Name, Imported Date, or LoRA Count for better browsing.
* **Recipe Favorites** - Mark specific recipes as favorites for quick access.
* **Video Recipe Support** - Enabled support for video recipes (import via LM extension or URL; video file import not supported).
* **Performance Improvements** - Fixed performance issues for dramatically improved startup and loading speed. After first scan, subsequent loads are instant regardless of collection size.
* **ComfyUI Nodes 2.0 Support** - Basic support for ComfyUI Nodes 2.0.
### v0.9.10 ### v0.9.10
* **Smarter Update Matching** - Users can now choose to check and group updates by matching base model only or with no base-model constraint; version lists also support toggling between same-base versions or all versions. * **Smarter Update Matching** - Users can now choose to check and group updates by matching base model only or with no base-model constraint; version lists also support toggling between same-base versions or all versions.
* **Flexible Tag Filtering** - The filter panel now supports tag exclusion: click a tag to include, click again to exclude, and click a third time to clear, enabling stronger and more flexible tag filters. * **Flexible Tag Filtering** - The filter panel now supports tag exclusion: click a tag to include, click again to exclude, and click a third time to clear, enabling stronger and more flexible tag filters.

View File

@@ -4,12 +4,16 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.trigger_word_toggle import TriggerWordToggle from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.prompt import PromptLoraManager from .py.nodes.prompt import PromptLoraManager
from .py.nodes.lora_stacker import LoraStacker from .py.nodes.lora_stacker import LoraStacker
from .py.nodes.save_image import SaveImage from .py.nodes.save_image import SaveImageLM
from .py.nodes.debug_metadata import DebugMetadata from .py.nodes.debug_metadata import DebugMetadata
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect from .py.nodes.wanvideo_lora_select import WanVideoLoraSelectLM
from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText from .py.nodes.wanvideo_lora_select_from_text import WanVideoLoraSelectFromText
from .py.nodes.lora_pool import LoraPoolNode
from .py.nodes.lora_randomizer import LoraRandomizerNode
from .py.metadata_collector import init as init_metadata_collector from .py.metadata_collector import init as init_metadata_collector
except ImportError: # pragma: no cover - allows running under pytest without package install except (
ImportError
): # pragma: no cover - allows running under pytest without package install
import importlib import importlib
import pathlib import pathlib
import sys import sys
@@ -20,14 +24,28 @@ except ImportError: # pragma: no cover - allows running under pytest without pa
PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager PromptLoraManager = importlib.import_module("py.nodes.prompt").PromptLoraManager
LoraManager = importlib.import_module("py.lora_manager").LoraManager LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader LoraManagerLoader = importlib.import_module(
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader "py.nodes.lora_loader"
TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle ).LoraManagerLoader
LoraManagerTextLoader = importlib.import_module(
"py.nodes.lora_loader"
).LoraManagerTextLoader
TriggerWordToggle = importlib.import_module(
"py.nodes.trigger_word_toggle"
).TriggerWordToggle
LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker LoraStacker = importlib.import_module("py.nodes.lora_stacker").LoraStacker
SaveImage = importlib.import_module("py.nodes.save_image").SaveImage SaveImageLM = importlib.import_module("py.nodes.save_image").SaveImageLM
DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata DebugMetadata = importlib.import_module("py.nodes.debug_metadata").DebugMetadata
WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect WanVideoLoraSelectLM = importlib.import_module(
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText "py.nodes.wanvideo_lora_select"
).WanVideoLoraSelectLM
WanVideoLoraSelectFromText = importlib.import_module(
"py.nodes.wanvideo_lora_select_from_text"
).WanVideoLoraSelectFromText
LoraPoolNode = importlib.import_module("py.nodes.lora_pool").LoraPoolNode
LoraRandomizerNode = importlib.import_module(
"py.nodes.lora_randomizer"
).LoraRandomizerNode
init_metadata_collector = importlib.import_module("py.metadata_collector").init init_metadata_collector = importlib.import_module("py.metadata_collector").init
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
@@ -36,17 +54,38 @@ NODE_CLASS_MAPPINGS = {
LoraManagerTextLoader.NAME: LoraManagerTextLoader, LoraManagerTextLoader.NAME: LoraManagerTextLoader,
TriggerWordToggle.NAME: TriggerWordToggle, TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker, LoraStacker.NAME: LoraStacker,
SaveImage.NAME: SaveImage, SaveImageLM.NAME: SaveImageLM,
DebugMetadata.NAME: DebugMetadata, DebugMetadata.NAME: DebugMetadata,
WanVideoLoraSelect.NAME: WanVideoLoraSelect, WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
LoraPoolNode.NAME: LoraPoolNode,
LoraRandomizerNode.NAME: LoraRandomizerNode,
} }
WEB_DIRECTORY = "./web/comfyui" WEB_DIRECTORY = "./web/comfyui"
# Check and build Vue widgets if needed (development mode)
try:
from .py.vue_widget_builder import check_and_build_vue_widgets
# Auto-build in development, warn only if fails
check_and_build_vue_widgets(auto_build=True, warn_only=True)
except ImportError:
# Fallback for pytest
import importlib
check_and_build_vue_widgets = importlib.import_module(
"py.vue_widget_builder"
).check_and_build_vue_widgets
check_and_build_vue_widgets(auto_build=True, warn_only=True)
except Exception as e:
import logging
logging.warning(f"[LoRA Manager] Vue widget build check skipped: {e}")
# Initialize metadata collector # Initialize metadata collector
init_metadata_collector() init_metadata_collector()
# Register routes on import # Register routes on import
LoraManager.add_routes() LoraManager.add_routes()
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY'] __all__ = ["NODE_CLASS_MAPPINGS", "WEB_DIRECTORY"]

View File

@@ -0,0 +1,544 @@
# DOMWidget Development Guide
This document provides a comprehensive guide for developing custom DOMWidgets in ComfyUI using Vanilla JavaScript. DOMWidgets allow you to embed standard HTML elements (div, video, canvas, input, etc.) into ComfyUI nodes while benefitting from the frontend's automatic layout and zoom management.
## 1. Core Concepts
In ComfyUI, a `DOMWidget` extends the default LiteGraph Canvas rendering logic. It maintains an HTML layer on top of the Canvas, making complex interactions and media displays significantly easier to implement than pure Canvas drawing.
### Key APIs
* **`app.registerExtension`**: The entry point for registering extensions.
* **`getCustomWidgets`**: A hook for defining new widget types associated with specific input types.
* **`node.addDOMWidget`**: The core method to add HTML elements to a node.
---
## 2. Basic Structure
A standard custom DOMWidget extension typically follows this structure:
```javascript
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "My.Custom.Extension",
async getCustomWidgets() {
return {
// Define a new widget type named "MY_WIDGET_TYPE"
MY_WIDGET_TYPE(node, inputName, inputData, app) {
// 1. Create the HTML element
const container = document.createElement("div");
container.innerHTML = "Hello <b>DOMWidget</b>!";
// 2. Setup styles (Optional but recommended)
container.style.color = "white";
container.style.backgroundColor = "#222";
container.style.padding = "5px";
// 3. Add the DOMWidget and return the result
const widget = node.addDOMWidget(inputName, "MY_WIDGET_TYPE", container, {
// Configuration options
getValue() {
return container.innerText;
},
setValue(v) {
container.innerText = v;
}
});
// 4. Return in the standard format
return { widget };
}
};
}
});
```
---
## ComfyUI Dual Rendering Modes
ComfyUI frontend supports two rendering modes:
| Mode | Description | DOM Structure |
| :--- | :--- | :--- |
| **Canvas Mode** | Traditional rendering where widgets are rendered on top of canvas using absolute positioning | Uses `.dom-widget` class on containers |
| **Vue DOM Mode** | New rendering mode where nodes and widgets are rendered as Vue components | Uses `.lg-node-widget` class on containers with dynamic IDs (e.g., `v-1-0`) |
### Mode Switching
The frontend switches between modes via `LiteGraph.vueNodesMode` boolean:
- `LiteGraph.vueNodesMode = true` → Vue DOM Mode
- `LiteGraph.vueNodesMode = false` → Canvas Mode
**Key Behavior**: Mode switching triggers DOM re-rendering WITHOUT page reload. Widget elements are destroyed and recreated, so any event listeners or references to old DOM elements become invalid.
### Testing Mode Switches via Chrome DevTools MCP
```javascript
// Trigger render mode change
LiteGraph.vueNodesMode = !LiteGraph.vueNodesMode;
// Force canvas redraw (optional but helps trigger re-render)
if (app.canvas) {
app.canvas.draw(true, true);
}
```
### Development Notes
When implementing widgets that attach event listeners or maintain external references:
1. **Use `node.onRemoved`** to clean up when node is deleted
2. **Detect DOM changes** by checking if widget input element is still in document: `document.body.contains(inputElement)`
3. **Poll for mode changes** by watching `LiteGraph.vueNodesMode` and re-initializing when it changes
4. **Use `loadedGraphNode` hook** for initial setup (guarantees DOM is fully rendered)
---
## 3. The `addDOMWidget` API
```javascript
node.addDOMWidget(name, type, element, options)
```
### Parameters
1. **`name`**: The internal name of the widget (usually matches the input name).
2. **`type`**: The type identifier for the widget.
3. **`element`**: The actual HTMLElement to embed.
4. **`options`**: (Object) Configuration for lifecycle, sizing, and persistence.
### Common `options` Fields
| Field | Type | Description |
| :--- | :--- | :--- |
| `getValue` | `Function` | Defines how to retrieve the widget's value for serialization. |
| `setValue` | `Function` | Defines how to restore the widget's state from workflow data. |
| `getMinHeight` | `Function` | Returns the minimum height in pixels. |
| `getHeight` | `Function` | Returns the preferred height (supports numbers or percentage strings like `"50%"`). |
| `onResize` | `Function` | Callback triggered when the widget is resized. |
| `hideOnZoom`| `Boolean` | Whether to hide the DOM element when zoomed out to improve performance (default: `true`). |
| `selectOn` | `string[]` | Events on the element that should trigger node selection (default: `['focus', 'click']`). |
---
## 4. Size Control
Custom DOMWidgets must actively inform the parent Node of their size requirements to ensure the Node layout is calculated correctly and connection wires remain aligned.
### 4.1 Core Mechanism
Whether in Canvas Mode or Vue Mode, the underlying logic model (`LGraphNode`) calls the widget's `computeLayoutSize` method to determine dimensions. This logic is used to calculate the Node's total size and the position of input/output slots.
### 4.2 Controlling Height
It is recommended to use the `options` parameter to define height behavior.
**Performance Note:** providing `getMinHeight` and `getHeight` via `options` allows the system to skip expensive DOM measurements (`getComputedStyle`) during rendering loop. This significantly improves performance and prevents FPS drops during node resizing.
**Method 1: Using `options` (Recommended)**
```javascript
const widget = node.addDOMWidget("MyWidget", "custom", element, {
// Specify minimum height in pixels
getMinHeight: () => 150,
// Or specify preferred height (pixels or percentage string)
// getHeight: () => "50%",
});
```
**Method 2: Using CSS Variables**
You can also set specific CSS variables on the root element:
```javascript
element.style.setProperty("--comfy-widget-min-height", "150px");
// or --comfy-widget-height
```
### 4.3 Controlling Width
By default, a DOMWidget's width automatically stretches to fit the Node's width (which is determined by the Title or other Input Slots).
If you must **force the Node to be wider** to accommodate your widget, you need to override the widget instance's `computeLayoutSize` method:
```javascript
const widget = node.addDOMWidget("WideWidget", "custom", element);
// Override the default layout calculation
widget.computeLayoutSize = (targetNode) => {
return {
minHeight: 150, // Must return height
minWidth: 300 // Force the Node to be at least 300px wide
};
};
```
### 4.4 Dynamic Resizing
If your widget's content changes dynamically (e.g., expanding sections, loading images, or CSS changes), the DOM element will resize, but the Canvas-rendered Node background and Slots will not automatically follow. You must manually trigger a synchronization.
**The Update Sequence:**
Whenever the **actual rendering height** of your DOM element changes, execute the following "three-step combo":
```javascript
// 1. Calculate the new optimal size for the node based on current widget requirements
const newSize = node.computeSize();
// 2. Apply the new size to the node model (updates bounding box and slot positions)
node.setSize(newSize);
// 3. Mark the canvas as dirty to trigger a redraw in the next animation frame
node.setDirtyCanvas(true, true);
```
**Common Scenarios:**
| Scenario | Actual Height Change? | Update Required? |
| :--- | :--- | :--- |
| **Expand/Collapse content** | **Yes** | ✅ **Yes**. Prevents widget from overflowing node boundaries. |
| **Image/Video finished loading** | **Yes** | ✅ **Yes**. Initial height might be 0 until the media loads. |
| **Changing `minHeight`** | **Maybe** | ❓ **Only if** the change causes the element's actual height to shift. |
| **Changing font size/styles** | **Yes** | ✅ **Yes**. Text reflow often changes the total height. |
| **User dragging node corner** | **Yes** | ❌ **No**. LiteGraph handles this internally. |
---
## 5. State Persistence (Serialization)
### 5.1 Default Behavior
DOMWidgets have **serialization enabled** by default (`serialize` property is `true`).
* **Saving**: ComfyUI attempts to read the widget's value to save into the Workflow file.
* **Loading**: ComfyUI reads the value from the Workflow file and assigns it to the widget.
### 5.2 Custom Serialization
To make persistence work effectively (saving internal DOM state and restoring it), you must implement `getValue` and `setValue` in the `options`:
* **`getValue`**: Returns the state to be saved (Number, String, or Object).
* **`setValue`**: Receives the restored value and updates the DOM element.
**Example:**
```javascript
const inputEl = document.createElement("input");
const widget = node.addDOMWidget("MyInput", "custom", inputEl, {
// 1. Called during Save
getValue: () => {
return inputEl.value;
},
// 2. Called during Load or Copy/Paste
setValue: (value) => {
inputEl.value = value || "";
}
});
// Optional: Listen for changes to update widget.value immediately
inputEl.addEventListener("change", () => {
widget.value = inputEl.value; // Triggers callbacks
});
```
### 5.3 The Restoration Mechanism (`configure`)
* **`configure(data)`**: When a Workflow is loaded, `LGraphNode` calls its `configure(data)` method.
* **`setValue` Chain**: During `configure`, the Node iterates over the saved `widgets_values` array and assigns each value (`widget.value = savedValue`). For DOMWidgets, this assignment triggers the `setValue` callback defined in your options.
Therefore, `options.setValue` is the critical hook for restoring widget state.
### 5.4 Disabling Serialization
If your widget is purely for display (e.g., a real-time monitor or generated chart) and doesn't need to save state, disable serialization to reduce workflow file size.
**Note**: You cannot set this via `options`. You must modify the widget instance directly.
```javascript
const widget = node.addDOMWidget("DisplayOnly", "custom", element);
widget.serialize = false; // Explicitly disable
```
---
## 6. Lifecycle & Events
### 6.1 `onResize`
When the Node size changes (e.g., user drags the corner), the widget can receive a notification via `options`:
```javascript
const widget = node.addDOMWidget("ResizingWidget", "custom", element, {
onResize: (w) => {
// 'w' is the widget instance
// Adjust internal DOM layout here if necessary
console.log("Widget resized");
}
});
```
### 6.2 Construction & Mounting
* **Construction**: Occurs immediately when `addDOMWidget` is called.
* **Mounting**:
* **Canvas Mode**: Appended to `.dom-widget-container` via `DomWidget.vue`.
* **Vue Mode**: Appended inside the Node component via `WidgetDOM.vue`.
* **Caution**: When `addDOMWidget` returns, the element may not be in the `document.body` yet. If you need to access layout properties like `getBoundingClientRect`, use `setTimeout` or wait for the first `onResize`.
### 6.3 Cleanup
If you create external references (like `setInterval` or global event listeners), ensure you clean them up using `node.onRemoved`:
```javascript
node.onRemoved = function() {
clearInterval(myInterval);
// Call original onRemoved if it existed
};
```
---
## 7. Styling & Best Practices
### 7.1 Styling
Since DOMWidgets are placed in absolute positioned containers or managed by Vue, ensure your container handles sizing gracefully:
```javascript
container.style.width = "100%";
container.style.boxSizing = "border-box";
```
### 7.2 Path References
When importing `app`, adjust the path based on your extension's folder depth. Typically:
`import { app } from "../../scripts/app.js";`
### 7.3 Security
If setting `innerHTML` dynamically, ensure the content is sanitized or trusted to prevent XSS attacks.
### 7.4 UI Constraints for ComfyUI Custom Node Widgets
When developing DOMWidgets as internal UI widgets for ComfyUI custom nodes, keep the following constraints in mind:
#### 7.4.1 Minimize Vertical Space
ComfyUI nodes are often displayed in a compact graph view with many nodes visible simultaneously. Avoid excessive vertical spacing that could clutter the workspace.
- Keep layouts compact and efficient
- Use appropriate padding and margins (4-8px typically)
- Stack related controls vertically but avoid unnecessary spacing
#### 7.4.2 Avoid Dynamic Height Changes
Dynamic height changes (expand/collapse sections, showing/hiding content) can cause node layout recalculations and affect connection wire positioning.
- Prefer static layouts over expandable/collapsible sections
- Use tooltips or overlays for additional information instead
- If dynamic height is unavoidable, manually trigger layout updates (see Section 4.4)
#### 7.4.3 Keep UI Simple and Intuitive
As internal widgets for ComfyUI custom nodes, the UI should be accessible to users without technical implementation details.
- Use clear, user-friendly terminology (avoid "frontend/backend roll" in favor of "fixed/always randomize")
- Focus on user intent rather than implementation details
- Avoid complex interactions that may confuse users
#### 7.4.4 Forward Middle Mouse Events to Canvas
By default, when a DOM widget receives pointer events (e.g., mouse clicks, drags), these events are captured by the widget and not forwarded to the ComfyUI canvas. This prevents users from panning the workflow using the middle mouse button when the cursor is over a DOM widget.
To enable workflow panning over your widget, you should forward middle mouse events (button 1) to the canvas using the `forwardMiddleMouseToCanvas` utility function:
```javascript
import { forwardMiddleMouseToCanvas } from "./utils.js";
// In your widget creation function
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
// ... other styles ...
// Forward middle mouse events to canvas for panning
forwardMiddleMouseToCanvas(container);
const widget = node.addDOMWidget(name, type, container, { ... });
```
The `forwardMiddleMouseToCanvas` function:
- Forwards `pointerdown` events with button 1 (middle mouse button) to `app.canvas.processMouseDown`
- Forwards `pointermove` events while middle mouse button is pressed to `app.canvas.processMouseMove`
- Forwards `pointerup` events with button 1 to `app.canvas.processMouseUp`
This allows users to pan the workflow canvas even when their mouse cursor is hovering over your DOM widget.
---
## 8. Event Handling in Vue DOM Render Mode
ComfyUI frontend supports two rendering modes for nodes:
- **Legacy Canvas Mode**: Traditional rendering where widgets are rendered on top of the canvas using absolute positioning
- **Vue DOM Render Mode**: New rendering mode where nodes and widgets are rendered as Vue components
In Vue DOM render mode, event handling works differently. The frontend uses `useCanvasInteractions` composable to manage event forwarding to the canvas. This can cause custom event handlers in your widgets (e.g., mouse wheel for sliders, custom drag operations) to be intercepted by the canvas.
### 8.1 Wheel Event Handling
By default in Vue DOM render mode, wheel events on widgets may be forwarded to the canvas for workflow zoom, overriding your custom wheel handlers (e.g., adjusting slider values with mouse wheel).
To fix this, use the `data-capture-wheel="true"` attribute on elements that should capture wheel events:
```vue
<!-- Vue component template -->
<div class="my-slider" data-capture-wheel="true" @wheel="onWheel">
<!-- Slider content -->
</div>
<script setup lang="ts">
const onWheel = (event: WheelEvent) => {
event.preventDefault()
// Custom wheel handling logic here
}
</script>
```
**How it works:**
- ComfyUI's `useCanvasInteractions.ts` checks `target?.closest('[data-capture-wheel="true"]')` before forwarding wheel events
- If an element (or its ancestor) has this attribute, wheel events are not forwarded to canvas
- Your custom `@wheel` handler will work as expected
**Granular control:**
- Apply `data-capture-wheel="true"` to specific interactive elements (e.g., sliders, scrollable areas)
- Widget container without this attribute will allow workflow zoom when wheel is used elsewhere
- This allows users to both: adjust widget values with wheel, and zoom workflow with wheel in widget's non-interactive areas
**Example from DualRangeSlider.vue:**
```vue
<template>
<div
class="dual-range-slider"
:class="{ disabled, 'is-dragging': dragging !== null }"
data-capture-wheel="true"
@wheel="onWheel"
>
<!-- Slider tracks and handles -->
</div>
</template>
```
### 8.2 Pointer Event Handling
In Vue DOM render mode, pointer events (click, drag, etc.) may also be captured by the canvas system. For custom drag operations:
1. **Use event modifiers to stop propagation:**
```vue
<div
@pointerdown.stop="startDrag"
@pointermove.stop="onDrag"
@pointerup.stop="stopDrag"
>
```
2. **Use pointer capture for reliable drag tracking:**
```javascript
const startDrag = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement
target.setPointerCapture(event.pointerId)
// ... drag initialization
}
const stopDrag = (event: PointerEvent) => {
const target = event.currentTarget as HTMLElement
target.releasePointerCapture(event.pointerId)
// ... drag cleanup
}
```
3. **Use `touch-action: none` CSS for touch devices:**
```css
.my-draggable {
touch-action: none;
}
```
### 8.3 Compatibility Checklist
Ensure your widget works in both rendering modes:
| Feature | Canvas Mode | Vue DOM Mode | Solution |
|---------|-------------|--------------|----------|
| Mouse wheel on sliders | Works by default | Needs `data-capture-wheel` | Add `data-capture-wheel="true"` to slider elements |
| Custom drag operations | Works with `stopPropagation()` | Needs `stopPropagation()` | Use `.stop` modifier and pointer capture |
| Middle mouse panning | Manual forwarding required | Manual forwarding required | Use `forwardMiddleMouseToCanvas()` |
| Workflow zoom on widget edges | Works by default | Works by default | No action needed (works by default) |
### 8.4 Testing Recommendations
Test your widget in both rendering modes:
1. Toggle between Canvas Mode and Vue DOM Mode in ComfyUI settings
2. Verify custom interactions (wheel, drag, etc.) work in both modes
3. Verify canvas interactions (zoom, pan) still work when cursor is over non-interactive widget areas
4. Test with touch devices if applicable
---
## 9. Complete Example: Text Counter
This example implements a simple widget that displays the character count of another text widget in the same node.
```javascript
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "Comfy.TextCounter",
getCustomWidgets() {
return {
TEXT_COUNTER(node, inputName) {
const el = document.createElement("div");
Object.assign(el.style, {
background: "#222",
border: "1px solid #444",
padding: "8px",
borderRadius: "4px",
fontSize: "12px",
color: "#eee"
});
const label = document.createElement("span");
label.innerText = "Characters: 0";
el.appendChild(label);
const widget = node.addDOMWidget(inputName, "TEXT_COUNTER", el, {
getValue() { return ""; }, // Nothing to save
setValue(v) { }, // Nothing to restore
getMinHeight() { return 40; }
});
// Disable serialization for this display-only widget
widget.serialize = false;
// Custom method to update UI
widget.updateCount = (text) => {
label.innerText = `Characters: ${text.length}`;
};
return { widget };
}
};
},
nodeCreated(node) {
// Logic to link widgets after the node is initialized
if (node.comfyClass === "MyTextNode") {
const counterWidget = node.widgets.find(w => w.type === "TEXT_COUNTER");
const textWidget = node.widgets.find(w => w.name === "text");
if (counterWidget && textWidget) {
// Hook into the text widget's callback
const oldCallback = textWidget.callback;
textWidget.callback = function(v) {
if (oldCallback) oldCallback.apply(this, arguments);
counterWidget.updateCount(v);
};
}
}
}
});
```

View File

Before

Width:  |  Height:  |  Size: 668 KiB

After

Width:  |  Height:  |  Size: 668 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

File diff suppressed because one or more lines are too long

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "Update", "update": "Update",
"updateAvailable": "Update verfügbar" "updateAvailable": "Update verfügbar"
},
"usage": {
"timesUsed": "Verwendungsanzahl"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "Recipe-Daten reparieren",
"loading": "Recipe-Daten werden repariert...",
"success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "Ersteller", "creator": "Ersteller",
"title": "Rezept-Titel", "title": "Rezept-Titel",
"loraName": "LoRA-Dateiname", "loraName": "LoRA-Dateiname",
"loraModel": "LoRA-Modellname" "loraModel": "LoRA-Modellname",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "Lizenz", "license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich", "noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt", "allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags",
"clearAll": "Alle Filter löschen" "clearAll": "Alle Filter löschen"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "Einstellungsordner öffnen", "label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen", "tooltip": "Den Ordner mit der settings.json öffnen",
"success": "Einstellungsordner geöffnet", "success": "Einstellungsordner geöffnet",
"failed": "Einstellungsordner konnte nicht geöffnet werden" "failed": "Einstellungsordner konnte nicht geöffnet werden",
"copied": "Einstellungspfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Einstellungspfad: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "Inhaltsfilterung", "contentFiltering": "Inhaltsfilterung",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner", "defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
"defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultCheckpointRootHelp": "Legen Sie den Standard-Checkpoint-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultUnetRoot": "Standard-Diffusion-Modell-Stammordner",
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultEmbeddingRoot": "Standard-Embedding-Stammordner", "defaultEmbeddingRoot": "Standard-Embedding-Stammordner",
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"noDefault": "Kein Standard" "noDefault": "Kein Standard"
@@ -443,7 +459,10 @@
"dateAsc": "Älteste", "dateAsc": "Älteste",
"size": "Dateigröße", "size": "Dateigröße",
"sizeDesc": "Größte", "sizeDesc": "Größte",
"sizeAsc": "Kleinste" "sizeAsc": "Kleinste",
"usage": "Anzahl Nutzung",
"usageDesc": "Meiste",
"usageAsc": "Wenigste"
}, },
"refresh": { "refresh": {
"title": "Modelliste aktualisieren", "title": "Modelliste aktualisieren",
@@ -518,6 +537,7 @@
"replacePreview": "Vorschau ersetzen", "replacePreview": "Vorschau ersetzen",
"setContentRating": "Inhaltsbewertung festlegen", "setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben", "moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren",
"excludeModel": "Modell ausschließen", "excludeModel": "Modell ausschließen",
"deleteModel": "Modell löschen", "deleteModel": "Modell löschen",
"shareRecipe": "Rezept teilen", "shareRecipe": "Rezept teilen",
@@ -588,10 +608,26 @@
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus" "selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
} }
}, },
"sort": {
"title": "Rezepte sortieren nach...",
"name": "Name",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Datum",
"dateDesc": "Neueste",
"dateAsc": "Älteste",
"lorasCount": "LoRA-Anzahl",
"lorasCountDesc": "Meiste",
"lorasCountAsc": "Wenigste"
},
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren" "title": "Rezeptliste aktualisieren"
}, },
"filteredByLora": "Gefiltert nach LoRA" "filteredByLora": "Gefiltert nach LoRA",
"favorites": {
"title": "Nur Favoriten anzeigen",
"action": "Favoriten"
}
}, },
"duplicates": { "duplicates": {
"found": "{count} Duplikat-Gruppen gefunden", "found": "{count} Duplikat-Gruppen gefunden",
@@ -617,11 +653,25 @@
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}" "prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
},
"repair": {
"starting": "Rezept-Metadaten werden repariert...",
"success": "Rezept-Metadaten erfolgreich repariert",
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Checkpoint-Modelle" "title": "Checkpoint-Modelle",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben"
}
}, },
"embeddings": { "embeddings": {
"title": "Embedding-Modelle" "title": "Embedding-Modelle"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", "recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden." "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "Dateispeicherort erfolgreich geöffnet", "success": "Dateispeicherort erfolgreich geöffnet",
"failed": "Fehler beim Öffnen des Dateispeicherorts" "failed": "Fehler beim Öffnen des Dateispeicherorts",
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}"
}, },
"metadata": { "metadata": {
"version": "Version", "version": "Version",
@@ -871,11 +924,13 @@
"addPresetParameter": "Voreingestellten Parameter hinzufügen...", "addPresetParameter": "Voreingestellten Parameter hinzufügen...",
"strengthMin": "Stärke Min", "strengthMin": "Stärke Min",
"strengthMax": "Stärke Max", "strengthMax": "Stärke Max",
"strengthRange": "Stärkenbereich",
"strength": "Stärke", "strength": "Stärke",
"clipStrength": "Clip-Stärke", "clipStrength": "Clip-Stärke",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Wert", "valuePlaceholder": "Wert",
"add": "Hinzufügen" "add": "Hinzufügen",
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "Trigger Words", "label": "Trigger Words",
@@ -914,6 +969,13 @@
"recipes": "Rezepte", "recipes": "Rezepte",
"versions": "Versionen" "versions": "Versionen"
}, },
"navigation": {
"label": "Modellnavigation",
"previousWithShortcut": "Vorheriges Modell (←)",
"nextWithShortcut": "Nächstes Modell (→)",
"noPrevious": "Kein vorheriges Modell verfügbar",
"noNext": "Kein weiteres Modell verfügbar"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.", "verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}", "verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
"noTagsToAdd": "Keine Tags zum Hinzufügen", "noTagsToAdd": "Keine Tags zum Hinzufügen",
"bulkTagsUpdating": "Tags für {count} Modell(e) werden aktualisiert...",
"tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt", "tagsAddedSuccessfully": "Erfolgreich {tagCount} Tag(s) zu {count} {type}(s) hinzugefügt",
"tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt", "tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
"tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)", "tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "Fehler beim Laden der LoRA-Stammverzeichnisse: {message}", "loraRootsFailed": "Fehler beim Laden der LoRA-Stammverzeichnisse: {message}",
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}", "checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}", "embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
"mappingsUpdated": "Basis-Modell-Pfad-Zuordnungen aktualisiert ({count} Zuordnung{plural})", "mappingsUpdated": "Basis-Modell-Pfad-Zuordnungen aktualisiert ({count} Zuordnung{plural})",
"mappingsCleared": "Basis-Modell-Pfad-Zuordnungen gelöscht", "mappingsCleared": "Basis-Modell-Pfad-Zuordnungen gelöscht",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Metadaten erfolgreich aktualisiert", "metadataRefreshed": "Metadaten erfolgreich aktualisiert",
"metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}", "metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}",
"metadataUpdateComplete": "Metadaten-Update abgeschlossen", "metadataUpdateComplete": "Metadaten-Update abgeschlossen",
"operationCancelled": "Vorgang vom Benutzer abgebrochen",
"operationCancelledPartial": "Vorgang abgebrochen. {success} Elemente verarbeitet.",
"metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}", "metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}",
"bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert", "bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert",
"bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert", "bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}", "bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben", "bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!", "exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}" "exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -32,7 +32,7 @@
"korean": "한국어", "korean": "한국어",
"french": "Français", "french": "Français",
"spanish": "Español", "spanish": "Español",
"Hebrew": "עברית" "Hebrew": "עברית"
}, },
"fileSize": { "fileSize": {
"zero": "0 Bytes", "zero": "0 Bytes",
@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "Update", "update": "Update",
"updateAvailable": "Update available" "updateAvailable": "Update available"
},
"usage": {
"timesUsed": "Times used"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "Repair recipes data",
"loading": "Repairing recipe data...",
"success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "Creator", "creator": "Creator",
"title": "Recipe Title", "title": "Recipe Title",
"loraName": "LoRA Filename", "loraName": "LoRA Filename",
"loraModel": "LoRA Model Name" "loraModel": "LoRA Model Name",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "License", "license": "License",
"noCreditRequired": "No Credit Required", "noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling", "allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags",
"clearAll": "Clear All Filters" "clearAll": "Clear All Filters"
}, },
"theme": { "theme": {
@@ -219,9 +231,11 @@
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai", "civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"openSettingsFileLocation": { "openSettingsFileLocation": {
"label": "Open settings folder", "label": "Open settings folder",
"tooltip": "Open the folder containing settings.json", "tooltip": "Open folder containing settings.json",
"success": "Opened settings.json folder", "success": "Opened settings.json folder",
"failed": "Failed to open settings.json folder" "failed": "Failed to open settings.json folder",
"copied": "Settings path copied to clipboard: {{path}}",
"clipboardFallback": "Settings path: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "Content Filtering", "contentFiltering": "Content Filtering",
@@ -302,11 +316,13 @@
"loadingLibraries": "Loading libraries...", "loadingLibraries": "Loading libraries...",
"noLibraries": "No libraries configured", "noLibraries": "No libraries configured",
"defaultLoraRoot": "Default LoRA Root", "defaultLoraRoot": "Default LoRA Root",
"defaultLoraRootHelp": "Set the default LoRA root directory for downloads, imports and moves", "defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves",
"defaultCheckpointRoot": "Default Checkpoint Root", "defaultCheckpointRoot": "Default Checkpoint Root",
"defaultCheckpointRootHelp": "Set the default checkpoint root directory for downloads, imports and moves", "defaultCheckpointRootHelp": "Set default checkpoint root directory for downloads, imports and moves",
"defaultUnetRoot": "Default Diffusion Model Root",
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
"defaultEmbeddingRoot": "Default Embedding Root", "defaultEmbeddingRoot": "Default Embedding Root",
"defaultEmbeddingRootHelp": "Set the default embedding root directory for downloads, imports and moves", "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
"noDefault": "No Default" "noDefault": "No Default"
}, },
"priorityTags": { "priorityTags": {
@@ -336,7 +352,7 @@
"templateOptions": { "templateOptions": {
"flatStructure": "Flat Structure", "flatStructure": "Flat Structure",
"byBaseModel": "By Base Model", "byBaseModel": "By Base Model",
"byAuthor": "By Author", "byAuthor": "By Author",
"byFirstTag": "By First Tag", "byFirstTag": "By First Tag",
"baseModelFirstTag": "Base Model + First Tag", "baseModelFirstTag": "Base Model + First Tag",
"baseModelAuthor": "Base Model + Author", "baseModelAuthor": "Base Model + Author",
@@ -347,7 +363,7 @@
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})", "customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
"modelTypes": { "modelTypes": {
"lora": "LoRA", "lora": "LoRA",
"checkpoint": "Checkpoint", "checkpoint": "Checkpoint",
"embedding": "Embedding" "embedding": "Embedding"
}, },
"baseModelPathMappings": "Base Model Path Mappings", "baseModelPathMappings": "Base Model Path Mappings",
@@ -420,11 +436,11 @@
"proxyHost": "Proxy Host", "proxyHost": "Proxy Host",
"proxyHostPlaceholder": "proxy.example.com", "proxyHostPlaceholder": "proxy.example.com",
"proxyHostHelp": "The hostname or IP address of your proxy server", "proxyHostHelp": "The hostname or IP address of your proxy server",
"proxyPort": "Proxy Port", "proxyPort": "Proxy Port",
"proxyPortPlaceholder": "8080", "proxyPortPlaceholder": "8080",
"proxyPortHelp": "The port number of your proxy server", "proxyPortHelp": "The port number of your proxy server",
"proxyUsername": "Username (Optional)", "proxyUsername": "Username (Optional)",
"proxyUsernamePlaceholder": "username", "proxyUsernamePlaceholder": "username",
"proxyUsernameHelp": "Username for proxy authentication (if required)", "proxyUsernameHelp": "Username for proxy authentication (if required)",
"proxyPassword": "Password (Optional)", "proxyPassword": "Password (Optional)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
@@ -443,7 +459,10 @@
"dateAsc": "Oldest", "dateAsc": "Oldest",
"size": "File Size", "size": "File Size",
"sizeDesc": "Largest", "sizeDesc": "Largest",
"sizeAsc": "Smallest" "sizeAsc": "Smallest",
"usage": "Use Count",
"usageDesc": "Most",
"usageAsc": "Least"
}, },
"refresh": { "refresh": {
"title": "Refresh model list", "title": "Refresh model list",
@@ -518,6 +537,7 @@
"replacePreview": "Replace Preview", "replacePreview": "Replace Preview",
"setContentRating": "Set Content Rating", "setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder", "moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata",
"excludeModel": "Exclude Model", "excludeModel": "Exclude Model",
"deleteModel": "Delete Model", "deleteModel": "Delete Model",
"shareRecipe": "Share Recipe", "shareRecipe": "Share Recipe",
@@ -588,10 +608,26 @@
"selectLoraRoot": "Please select a LoRA root directory" "selectLoraRoot": "Please select a LoRA root directory"
} }
}, },
"sort": {
"title": "Sort recipes by...",
"name": "Name",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Date",
"dateDesc": "Newest",
"dateAsc": "Oldest",
"lorasCount": "LoRA Count",
"lorasCountDesc": "Most",
"lorasCountAsc": "Least"
},
"refresh": { "refresh": {
"title": "Refresh recipe list" "title": "Refresh recipe list"
}, },
"filteredByLora": "Filtered by LoRA" "filteredByLora": "Filtered by LoRA",
"favorites": {
"title": "Show Favorites Only",
"action": "Favorites"
}
}, },
"duplicates": { "duplicates": {
"found": "Found {count} duplicate groups", "found": "Found {count} duplicate groups",
@@ -617,11 +653,25 @@
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"getInfoFailed": "Failed to get information for missing LoRAs", "getInfoFailed": "Failed to get information for missing LoRAs",
"prepareError": "Error preparing LoRAs for download: {message}" "prepareError": "Error preparing LoRAs for download: {message}"
},
"repair": {
"starting": "Repairing recipe metadata...",
"success": "Recipe metadata repaired successfully",
"skipped": "Recipe already at latest version, no repair needed",
"failed": "Failed to repair recipe: {message}",
"missingId": "Cannot repair recipe: Missing recipe ID"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Checkpoint Models" "title": "Checkpoint Models",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "Move to {otherType} Folder"
}
}, },
"embeddings": { "embeddings": {
"title": "Embedding Models" "title": "Embedding Models"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "Recursive search is available in tree view only", "recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view", "collapseAllDisabled": "Not available in list view",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move." "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "File location opened successfully", "success": "File location opened successfully",
"failed": "Failed to open file location" "failed": "Failed to open file location",
"copied": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}"
}, },
"metadata": { "metadata": {
"version": "Version", "version": "Version",
@@ -871,11 +924,13 @@
"addPresetParameter": "Add preset parameter...", "addPresetParameter": "Add preset parameter...",
"strengthMin": "Strength Min", "strengthMin": "Strength Min",
"strengthMax": "Strength Max", "strengthMax": "Strength Max",
"strengthRange": "Strength Range",
"strength": "Strength", "strength": "Strength",
"clipStrength": "Clip Strength", "clipStrength": "Clip Strength",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Value", "valuePlaceholder": "Value",
"add": "Add" "add": "Add",
"invalidRange": "Invalid range format. Use x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "Trigger Words", "label": "Trigger Words",
@@ -914,6 +969,13 @@
"recipes": "Recipes", "recipes": "Recipes",
"versions": "Versions" "versions": "Versions"
}, },
"navigation": {
"label": "Model navigation",
"previousWithShortcut": "Previous model (←)",
"nextWithShortcut": "Next model (→)",
"noPrevious": "No previous model available",
"noNext": "No next model available"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.", "verificationCompleteSuccess": "Verification complete. All files are confirmed duplicates.",
"verificationFailed": "Failed to verify hashes: {message}", "verificationFailed": "Failed to verify hashes: {message}",
"noTagsToAdd": "No tags to add", "noTagsToAdd": "No tags to add",
"bulkTagsUpdating": "Updating tags for {count} model(s)...",
"tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)", "tagsAddedSuccessfully": "Successfully added {tagCount} tag(s) to {count} {type}(s)",
"tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)", "tagsReplacedSuccessfully": "Successfully replaced tags for {count} {type}(s) with {tagCount} tag(s)",
"tagsAddFailed": "Failed to add tags to {count} model(s)", "tagsAddFailed": "Failed to add tags to {count} model(s)",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "Failed to load LoRA roots: {message}", "loraRootsFailed": "Failed to load LoRA roots: {message}",
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}", "checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
"embeddingRootsFailed": "Failed to load embedding roots: {message}", "embeddingRootsFailed": "Failed to load embedding roots: {message}",
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})", "mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
"mappingsCleared": "Base model path mappings cleared", "mappingsCleared": "Base model path mappings cleared",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Metadata refreshed successfully", "metadataRefreshed": "Metadata refreshed successfully",
"metadataRefreshFailed": "Failed to refresh metadata: {message}", "metadataRefreshFailed": "Failed to refresh metadata: {message}",
"metadataUpdateComplete": "Metadata update complete", "metadataUpdateComplete": "Metadata update complete",
"operationCancelled": "Operation cancelled by user",
"operationCancelledPartial": "Operation cancelled. {success} items processed.",
"metadataFetchFailed": "Failed to fetch metadata: {message}", "metadataFetchFailed": "Failed to fetch metadata: {message}",
"bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s", "bulkMetadataCompleteAll": "Successfully refreshed all {count} {type}s",
"bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s", "bulkMetadataCompletePartial": "Refreshed {success} of {total} {type}s",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Failed moves:\n{failures}", "bulkMoveFailures": "Failed moves:\n{failures}",
"bulkMoveSuccess": "Successfully moved {successCount} {type}s", "bulkMoveSuccess": "Successfully moved {successCount} {type}s",
"exampleImagesDownloadSuccess": "Successfully downloaded example images!", "exampleImagesDownloadSuccess": "Successfully downloaded example images!",
"exampleImagesDownloadFailed": "Failed to download example images: {message}" "exampleImagesDownloadFailed": "Failed to download example images: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1471,4 +1538,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "Actualización", "update": "Actualización",
"updateAvailable": "Actualización disponible" "updateAvailable": "Actualización disponible"
},
"usage": {
"timesUsed": "Veces usado"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "Reparar datos de recetas",
"loading": "Reparando datos de recetas...",
"success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "Creador", "creator": "Creador",
"title": "Título de la receta", "title": "Título de la receta",
"loraName": "Nombre de archivo LoRA", "loraName": "Nombre de archivo LoRA",
"loraModel": "Nombre del modelo LoRA" "loraModel": "Nombre del modelo LoRA",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "Licencia", "license": "Licencia",
"noCreditRequired": "Sin crédito requerido", "noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida", "allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas",
"clearAll": "Limpiar todos los filtros" "clearAll": "Limpiar todos los filtros"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "Abrir carpeta de ajustes", "label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json", "tooltip": "Abrir la carpeta que contiene settings.json",
"success": "Carpeta de settings.json abierta", "success": "Carpeta de settings.json abierta",
"failed": "No se pudo abrir la carpeta de settings.json" "failed": "No se pudo abrir la carpeta de settings.json",
"copied": "Ruta de configuración copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta de configuración: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "Filtrado de contenido", "contentFiltering": "Filtrado de contenido",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos", "defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint", "defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
"defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos", "defaultCheckpointRootHelp": "Establecer el directorio raíz predeterminado de checkpoint para descargas, importaciones y movimientos",
"defaultUnetRoot": "Raíz predeterminada de Diffusion Model",
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
"defaultEmbeddingRoot": "Raíz predeterminada de embedding", "defaultEmbeddingRoot": "Raíz predeterminada de embedding",
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"noDefault": "Sin predeterminado" "noDefault": "Sin predeterminado"
@@ -443,7 +459,10 @@
"dateAsc": "Más antiguo", "dateAsc": "Más antiguo",
"size": "Tamaño de archivo", "size": "Tamaño de archivo",
"sizeDesc": "Mayor", "sizeDesc": "Mayor",
"sizeAsc": "Menor" "sizeAsc": "Menor",
"usage": "Número de usos",
"usageDesc": "Más",
"usageAsc": "Menos"
}, },
"refresh": { "refresh": {
"title": "Actualizar lista de modelos", "title": "Actualizar lista de modelos",
@@ -518,6 +537,7 @@
"replacePreview": "Reemplazar vista previa", "replacePreview": "Reemplazar vista previa",
"setContentRating": "Establecer clasificación de contenido", "setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta", "moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos",
"excludeModel": "Excluir modelo", "excludeModel": "Excluir modelo",
"deleteModel": "Eliminar modelo", "deleteModel": "Eliminar modelo",
"shareRecipe": "Compartir receta", "shareRecipe": "Compartir receta",
@@ -588,10 +608,26 @@
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA" "selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
} }
}, },
"sort": {
"title": "Ordenar recetas por...",
"name": "Nombre",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Fecha",
"dateDesc": "Más reciente",
"dateAsc": "Más antiguo",
"lorasCount": "Cant. de LoRAs",
"lorasCountDesc": "Más",
"lorasCountAsc": "Menos"
},
"refresh": { "refresh": {
"title": "Actualizar lista de recetas" "title": "Actualizar lista de recetas"
}, },
"filteredByLora": "Filtrado por LoRA" "filteredByLora": "Filtrado por LoRA",
"favorites": {
"title": "Mostrar solo favoritos",
"action": "Favoritos"
}
}, },
"duplicates": { "duplicates": {
"found": "Se encontraron {count} grupos de duplicados", "found": "Se encontraron {count} grupos de duplicados",
@@ -617,11 +653,25 @@
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"getInfoFailed": "Error al obtener información de LoRAs faltantes", "getInfoFailed": "Error al obtener información de LoRAs faltantes",
"prepareError": "Error preparando LoRAs para descarga: {message}" "prepareError": "Error preparando LoRAs para descarga: {message}"
},
"repair": {
"starting": "Reparando metadatos de la receta...",
"success": "Metadatos de la receta reparados con éxito",
"skipped": "La receta ya está en la última versión, no se necesita reparación",
"failed": "Error al reparar la receta: {message}",
"missingId": "No se puede reparar la receta: falta el ID de la receta"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Modelos checkpoint" "title": "Modelos checkpoint",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}"
}
}, },
"embeddings": { "embeddings": {
"title": "Modelos embedding" "title": "Modelos embedding"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", "recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista", "collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento." "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "Ubicación del archivo abierta exitosamente", "success": "Ubicación del archivo abierta exitosamente",
"failed": "Error al abrir la ubicación del archivo" "failed": "Error al abrir la ubicación del archivo",
"copied": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}"
}, },
"metadata": { "metadata": {
"version": "Versión", "version": "Versión",
@@ -871,11 +924,13 @@
"addPresetParameter": "Añadir parámetro preestablecido...", "addPresetParameter": "Añadir parámetro preestablecido...",
"strengthMin": "Fuerza mínima", "strengthMin": "Fuerza mínima",
"strengthMax": "Fuerza máxima", "strengthMax": "Fuerza máxima",
"strengthRange": "Rango de fuerza",
"strength": "Fuerza", "strength": "Fuerza",
"clipStrength": "Fuerza de Clip", "clipStrength": "Fuerza de Clip",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Valor", "valuePlaceholder": "Valor",
"add": "Añadir" "add": "Añadir",
"invalidRange": "Formato de rango inválido. Use x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "Palabras clave", "label": "Palabras clave",
@@ -914,6 +969,13 @@
"recipes": "Recetas", "recipes": "Recetas",
"versions": "Versiones" "versions": "Versiones"
}, },
"navigation": {
"label": "Navegación de modelos",
"previousWithShortcut": "Modelo anterior (←)",
"nextWithShortcut": "Siguiente modelo (→)",
"noPrevious": "No hay modelo anterior disponible",
"noNext": "No hay siguiente modelo disponible"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.", "verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
"verificationFailed": "Error al verificar hashes: {message}", "verificationFailed": "Error al verificar hashes: {message}",
"noTagsToAdd": "No hay etiquetas para añadir", "noTagsToAdd": "No hay etiquetas para añadir",
"bulkTagsUpdating": "Actualizando etiquetas para {count} modelo(s)...",
"tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)", "tagsAddedSuccessfully": "Se añadieron exitosamente {tagCount} etiqueta(s) a {count} {type}(s)",
"tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)", "tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
"tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)", "tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "Error al cargar raíces de LoRA: {message}", "loraRootsFailed": "Error al cargar raíces de LoRA: {message}",
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}", "checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}", "embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})", "mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
"mappingsCleared": "Mapeos de rutas de modelo base limpiados", "mappingsCleared": "Mapeos de rutas de modelo base limpiados",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Metadatos actualizados exitosamente", "metadataRefreshed": "Metadatos actualizados exitosamente",
"metadataRefreshFailed": "Error al actualizar metadatos: {message}", "metadataRefreshFailed": "Error al actualizar metadatos: {message}",
"metadataUpdateComplete": "Actualización de metadatos completada", "metadataUpdateComplete": "Actualización de metadatos completada",
"operationCancelled": "Operación cancelada por el usuario",
"operationCancelledPartial": "Operación cancelada. {success} elementos procesados.",
"metadataFetchFailed": "Error al obtener metadatos: {message}", "metadataFetchFailed": "Error al obtener metadatos: {message}",
"bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s", "bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s",
"bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s", "bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Movimientos fallidos:\n{failures}", "bulkMoveFailures": "Movimientos fallidos:\n{failures}",
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s", "bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!", "exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}" "exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "Mise à jour", "update": "Mise à jour",
"updateAvailable": "Mise à jour disponible" "updateAvailable": "Mise à jour disponible"
},
"usage": {
"timesUsed": "Nombre d'utilisations"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "Réparer les données de recettes",
"loading": "Réparation des données de recettes...",
"success": "{count} recettes réparées avec succès.",
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
"error": "Échec de la réparation des recettes : {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "Créateur", "creator": "Créateur",
"title": "Titre de la recipe", "title": "Titre de la recipe",
"loraName": "Nom de fichier LoRA", "loraName": "Nom de fichier LoRA",
"loraModel": "Nom du modèle LoRA" "loraModel": "Nom du modèle LoRA",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "Licence", "license": "Licence",
"noCreditRequired": "Crédit non requis", "noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée", "allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag",
"clearAll": "Effacer tous les filtres" "clearAll": "Effacer tous les filtres"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "Ouvrir le dossier des paramètres", "label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json", "tooltip": "Ouvrir le dossier contenant settings.json",
"success": "Dossier settings.json ouvert", "success": "Dossier settings.json ouvert",
"failed": "Impossible d'ouvrir le dossier settings.json" "failed": "Impossible d'ouvrir le dossier settings.json",
"copied": "Chemin des paramètres copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin des paramètres: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "Filtrage du contenu", "contentFiltering": "Filtrage du contenu",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements", "defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
"defaultCheckpointRoot": "Racine Checkpoint par défaut", "defaultCheckpointRoot": "Racine Checkpoint par défaut",
"defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements", "defaultCheckpointRootHelp": "Définir le répertoire racine checkpoint par défaut pour les téléchargements, imports et déplacements",
"defaultUnetRoot": "Racine Diffusion Model par défaut",
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
"defaultEmbeddingRoot": "Racine Embedding par défaut", "defaultEmbeddingRoot": "Racine Embedding par défaut",
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements", "defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
"noDefault": "Aucun par défaut" "noDefault": "Aucun par défaut"
@@ -443,7 +459,10 @@
"dateAsc": "Plus ancien", "dateAsc": "Plus ancien",
"size": "Taille du fichier", "size": "Taille du fichier",
"sizeDesc": "Plus grand", "sizeDesc": "Plus grand",
"sizeAsc": "Plus petit" "sizeAsc": "Plus petit",
"usage": "Nombre d'utilisations",
"usageDesc": "Plus",
"usageAsc": "Moins"
}, },
"refresh": { "refresh": {
"title": "Actualiser la liste des modèles", "title": "Actualiser la liste des modèles",
@@ -518,6 +537,7 @@
"replacePreview": "Remplacer l'aperçu", "replacePreview": "Remplacer l'aperçu",
"setContentRating": "Définir la classification du contenu", "setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier", "moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées",
"excludeModel": "Exclure le modèle", "excludeModel": "Exclure le modèle",
"deleteModel": "Supprimer le modèle", "deleteModel": "Supprimer le modèle",
"shareRecipe": "Partager la recipe", "shareRecipe": "Partager la recipe",
@@ -588,10 +608,26 @@
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA" "selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
} }
}, },
"sort": {
"title": "Trier les recettes par...",
"name": "Nom",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Date",
"dateDesc": "Plus récent",
"dateAsc": "Plus ancien",
"lorasCount": "Nombre de LoRAs",
"lorasCountDesc": "Plus",
"lorasCountAsc": "Moins"
},
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes" "title": "Actualiser la liste des recipes"
}, },
"filteredByLora": "Filtré par LoRA" "filteredByLora": "Filtré par LoRA",
"favorites": {
"title": "Afficher uniquement les favoris",
"action": "Favoris"
}
}, },
"duplicates": { "duplicates": {
"found": "Trouvé {count} groupes de doublons", "found": "Trouvé {count} groupes de doublons",
@@ -617,11 +653,25 @@
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}" "prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
},
"repair": {
"starting": "Réparation des métadonnées de la recette...",
"success": "Métadonnées de la recette réparées avec succès",
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
"failed": "Échec de la réparation de la recette : {message}",
"missingId": "Impossible de réparer la recette : ID de recette manquant"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Modèles Checkpoint" "title": "Modèles Checkpoint",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}"
}
}, },
"embeddings": { "embeddings": {
"title": "Modèles Embedding" "title": "Modèles Embedding"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", "recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste", "collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement." "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "Emplacement du fichier ouvert avec succès", "success": "Emplacement du fichier ouvert avec succès",
"failed": "Échec de l'ouverture de l'emplacement du fichier" "failed": "Échec de l'ouverture de l'emplacement du fichier",
"copied": "Chemin copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin: {{path}}"
}, },
"metadata": { "metadata": {
"version": "Version", "version": "Version",
@@ -871,11 +924,13 @@
"addPresetParameter": "Ajouter un paramètre prédéfini...", "addPresetParameter": "Ajouter un paramètre prédéfini...",
"strengthMin": "Force Min", "strengthMin": "Force Min",
"strengthMax": "Force Max", "strengthMax": "Force Max",
"strengthRange": "Gamme de force",
"strength": "Force", "strength": "Force",
"clipStrength": "Force Clip", "clipStrength": "Force Clip",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Valeur", "valuePlaceholder": "Valeur",
"add": "Ajouter" "add": "Ajouter",
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "Mots-clés", "label": "Mots-clés",
@@ -914,6 +969,13 @@
"recipes": "Recipes", "recipes": "Recipes",
"versions": "Versions" "versions": "Versions"
}, },
"navigation": {
"label": "Navigation des modèles",
"previousWithShortcut": "Modèle précédent (←)",
"nextWithShortcut": "Modèle suivant (→)",
"noPrevious": "Aucun modèle précédent",
"noNext": "Aucun modèle suivant"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.", "verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
"verificationFailed": "Échec de la vérification des hash : {message}", "verificationFailed": "Échec de la vérification des hash : {message}",
"noTagsToAdd": "Aucun tag à ajouter", "noTagsToAdd": "Aucun tag à ajouter",
"bulkTagsUpdating": "Mise à jour des tags pour {count} modèle(s)...",
"tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)", "tagsAddedSuccessfully": "{tagCount} tag(s) ajouté(s) avec succès à {count} {type}(s)",
"tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)", "tagsReplacedSuccessfully": "Tags remplacés avec succès pour {count} {type}(s) avec {tagCount} tag(s)",
"tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)", "tagsAddFailed": "Échec de l'ajout des tags à {count} modèle(s)",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "Échec du chargement des racines LoRA : {message}", "loraRootsFailed": "Échec du chargement des racines LoRA : {message}",
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}", "checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}", "embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})", "mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
"mappingsCleared": "Mappages de chemin de modèle de base effacés", "mappingsCleared": "Mappages de chemin de modèle de base effacés",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Métadonnées actualisées avec succès", "metadataRefreshed": "Métadonnées actualisées avec succès",
"metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}", "metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}",
"metadataUpdateComplete": "Mise à jour des métadonnées terminée", "metadataUpdateComplete": "Mise à jour des métadonnées terminée",
"operationCancelled": "Opération annulée par l'utilisateur",
"operationCancelledPartial": "Opération annulée. {success} éléments traités.",
"metadataFetchFailed": "Échec de la récupération des métadonnées : {message}", "metadataFetchFailed": "Échec de la récupération des métadonnées : {message}",
"bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s", "bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s",
"bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés", "bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Échecs de déplacement :\n{failures}", "bulkMoveFailures": "Échecs de déplacement :\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès", "bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !", "exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}" "exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "עדכון", "update": "עדכון",
"updateAvailable": "עדכון זמין" "updateAvailable": "עדכון זמין"
},
"usage": {
"timesUsed": "מספר שימושים"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "תיקון נתוני מתכונים",
"loading": "מתקן נתוני מתכונים...",
"success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "יוצר", "creator": "יוצר",
"title": "כותרת מתכון", "title": "כותרת מתכון",
"loraName": "שם קובץ LoRA", "loraName": "שם קובץ LoRA",
"loraModel": "שם מודל LoRA" "loraModel": "שם מודל LoRA",
"prompt": "הנחיה"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "רישיון", "license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש", "noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה", "allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות",
"clearAll": "נקה את כל המסננים" "clearAll": "נקה את כל המסננים"
}, },
"theme": { "theme": {
@@ -221,13 +233,16 @@
"label": "פתח תיקיית הגדרות", "label": "פתח תיקיית הגדרות",
"tooltip": "פתח את התיקייה שמכילה את settings.json", "tooltip": "פתח את התיקייה שמכילה את settings.json",
"success": "תיקיית settings.json נפתחה", "success": "תיקיית settings.json נפתחה",
"failed": "לא ניתן לפתוח את תיקיית settings.json" "failed": "לא ניתן לפתוח את תיקיית settings.json",
"copied": "נתיב ההגדרות הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב ההגדרות: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "סינון תוכן", "contentFiltering": "סינון תוכן",
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"folderSettings": "הגדרות תיקייה", "folderSettings": "הגדרות תיקייה",
"priorityTags": "תגיות עדיפות",
"downloadPathTemplates": "תבניות נתיב הורדה", "downloadPathTemplates": "תבניות נתיב הורדה",
"exampleImages": "תמונות דוגמה", "exampleImages": "תמונות דוגמה",
"updateFlags": "תגי עדכון", "updateFlags": "תגי עדכון",
@@ -235,8 +250,7 @@
"misc": "שונות", "misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"storageLocation": "מיקום ההגדרות", "storageLocation": "מיקום ההגדרות",
"proxySettings": "הגדרות פרוקסי", "proxySettings": "הגדרות פרוקסי"
"priorityTags": "תגיות עדיפות"
}, },
"storage": { "storage": {
"locationLabel": "מצב נייד", "locationLabel": "מצב נייד",
@@ -298,17 +312,39 @@
}, },
"folderSettings": { "folderSettings": {
"activeLibrary": "ספרייה פעילה", "activeLibrary": "ספרייה פעילה",
"activeLibraryHelp": "החלפה בין הספריות המוגדרות תעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.", "activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף.",
"loadingLibraries": "טוען ספריות...", "loadingLibraries": "טוען ספריות...",
"noLibraries": "לא הוגדרו ספריות", "noLibraries": "לא הוגדרו ספריות",
"defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA", "defaultLoraRoot": "תיקיית שורש ברירת מחדל של LoRA",
"defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות", "defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות",
"defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint", "defaultCheckpointRoot": "תיקיית שורש ברירת מחדל של Checkpoint",
"defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות", "defaultCheckpointRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של checkpoint להורדות, ייבוא והעברות",
"defaultUnetRoot": "תיקיית שורש ברירת מחדל של Diffusion Model",
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
"defaultEmbeddingRoot": "תיקיית שורש ברירת מחדל של Embedding", "defaultEmbeddingRoot": "תיקיית שורש ברירת מחדל של Embedding",
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"noDefault": "אין ברירת מחדל" "noDefault": "אין ברירת מחדל"
}, },
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "תגיות העדיפות עודכנו.",
"saveError": "עדכון תגיות העדיפות נכשל.",
"loadingSuggestions": "טוען הצעות...",
"validation": {
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
"unknown": "תצורת תגיות העדיפות שגויה."
}
},
"downloadPathTemplates": { "downloadPathTemplates": {
"title": "תבניות נתיב הורדה", "title": "תבניות נתיב הורדה",
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.", "help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
@@ -320,8 +356,8 @@
"byFirstTag": "לפי תגית ראשונה", "byFirstTag": "לפי תגית ראשונה",
"baseModelFirstTag": "מודל בסיס + תגית ראשונה", "baseModelFirstTag": "מודל בסיס + תגית ראשונה",
"baseModelAuthor": "מודל בסיס + יוצר", "baseModelAuthor": "מודל בסיס + יוצר",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"authorFirstTag": "יוצר + תגית ראשונה", "authorFirstTag": "יוצר + תגית ראשונה",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"customTemplate": "תבנית מותאמת אישית" "customTemplate": "תבנית מותאמת אישית"
}, },
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})", "customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
@@ -409,26 +445,6 @@
"proxyPassword": "סיסמה (אופציונלי)", "proxyPassword": "סיסמה (אופציונלי)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)" "proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "תגיות העדיפות עודכנו.",
"saveError": "עדכון תגיות העדיפות נכשל.",
"loadingSuggestions": "טוען הצעות...",
"validation": {
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
"unknown": "תצורת תגיות העדיפות שגויה."
}
} }
}, },
"loras": { "loras": {
@@ -443,7 +459,10 @@
"dateAsc": "הישן ביותר", "dateAsc": "הישן ביותר",
"size": "גודל קובץ", "size": "גודל קובץ",
"sizeDesc": "הגדול ביותר", "sizeDesc": "הגדול ביותר",
"sizeAsc": "הקטן ביותר" "sizeAsc": "הקטן ביותר",
"usage": "מספר שימושים",
"usageDesc": "הכי הרבה",
"usageAsc": "הכי פחות"
}, },
"refresh": { "refresh": {
"title": "רענן רשימת מודלים", "title": "רענן רשימת מודלים",
@@ -518,6 +537,7 @@
"replacePreview": "החלף תצוגה מקדימה", "replacePreview": "החלף תצוגה מקדימה",
"setContentRating": "הגדר דירוג תוכן", "setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
"deleteModel": "מחק מודל", "deleteModel": "מחק מודל",
"shareRecipe": "שתף מתכון", "shareRecipe": "שתף מתכון",
@@ -588,10 +608,26 @@
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA" "selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
} }
}, },
"sort": {
"title": "מיון מתכונים לפי...",
"name": "שם",
"nameAsc": "א - ת",
"nameDesc": "ת - א",
"date": "תאריך",
"dateDesc": "הכי חדש",
"dateAsc": "הכי ישן",
"lorasCount": "מספר LoRAs",
"lorasCountDesc": "הכי הרבה",
"lorasCountAsc": "הכי פחות"
},
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים" "title": "רענן רשימת מתכונים"
}, },
"filteredByLora": "מסונן לפי LoRA" "filteredByLora": "מסונן לפי LoRA",
"favorites": {
"title": "הצג מועדפים בלבד",
"action": "מועדפים"
}
}, },
"duplicates": { "duplicates": {
"found": "נמצאו {count} קבוצות כפולות", "found": "נמצאו {count} קבוצות כפולות",
@@ -617,11 +653,25 @@
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}" "prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
},
"repair": {
"starting": "מתקן מטא-דאטה של מתכון...",
"success": "מטא-דאטה של מתכון תוקן בהצלחה",
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
"failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "מודלי Checkpoint" "title": "מודלי Checkpoint",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "העבר לתיקיית {otherType}"
}
}, },
"embeddings": { "embeddings": {
"title": "מודלי Embedding" "title": "מודלי Embedding"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה." "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "מיקום הקובץ נפתח בהצלחה", "success": "מיקום הקובץ נפתח בהצלחה",
"failed": "פתיחת מיקום הקובץ נכשלה" "failed": "פתיחת מיקום הקובץ נכשלה",
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}"
}, },
"metadata": { "metadata": {
"version": "גרסה", "version": "גרסה",
@@ -871,11 +924,13 @@
"addPresetParameter": "הוסף פרמטר קבוע מראש...", "addPresetParameter": "הוסף פרמטר קבוע מראש...",
"strengthMin": "חוזק מינימלי", "strengthMin": "חוזק מינימלי",
"strengthMax": "חוזק מקסימלי", "strengthMax": "חוזק מקסימלי",
"strengthRange": "טווח עוצמה",
"strength": "חוזק", "strength": "חוזק",
"clipStrength": "עוצמת CLIP", "clipStrength": "עוצמת CLIP",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "ערך", "valuePlaceholder": "ערך",
"add": "הוסף" "add": "הוסף",
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "מילות טריגר", "label": "מילות טריגר",
@@ -914,6 +969,13 @@
"recipes": "מתכונים", "recipes": "מתכונים",
"versions": "גרסאות" "versions": "גרסאות"
}, },
"navigation": {
"label": "ניווט מודלים",
"previousWithShortcut": "המודל הקודם (←)",
"nextWithShortcut": "המודל הבא (→)",
"noPrevious": "אין מודל קודם זמין",
"noNext": "אין מודל נוסף זמין"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "האימות הושלם. כל הקבצים אושרו ככפולים.", "verificationCompleteSuccess": "האימות הושלם. כל הקבצים אושרו ככפולים.",
"verificationFailed": "אימות ה-hashes נכשל: {message}", "verificationFailed": "אימות ה-hashes נכשל: {message}",
"noTagsToAdd": "אין תגיות להוספה", "noTagsToAdd": "אין תגיות להוספה",
"bulkTagsUpdating": "מעדכן תגיות עבור {count} מודלים...",
"tagsAddedSuccessfully": "נוספו בהצלחה {tagCount} תגית(ות) ל-{count} {type}(ים)", "tagsAddedSuccessfully": "נוספו בהצלחה {tagCount} תגית(ות) ל-{count} {type}(ים)",
"tagsReplacedSuccessfully": "הוחלפו בהצלחה תגיות עבור {count} {type}(ים) ב-{tagCount} תגית(ות)", "tagsReplacedSuccessfully": "הוחלפו בהצלחה תגיות עבור {count} {type}(ים) ב-{tagCount} תגית(ות)",
"tagsAddFailed": "הוספת תגיות ל-{count} מודל(ים) נכשלה", "tagsAddFailed": "הוספת תגיות ל-{count} מודל(ים) נכשלה",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "טעינת שורשי LoRA נכשלה: {message}", "loraRootsFailed": "טעינת שורשי LoRA נכשלה: {message}",
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}", "checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}", "embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count} מיפוי{plural})", "mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count} מיפוי{plural})",
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו", "mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה", "metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
"metadataRefreshFailed": "רענון המטא-דאטה נכשל: {message}", "metadataRefreshFailed": "רענון המטא-דאטה נכשל: {message}",
"metadataUpdateComplete": "עדכון המטא-דאטה הושלם", "metadataUpdateComplete": "עדכון המטא-דאטה הושלם",
"operationCancelled": "הפעולה בוטלה על ידי המשתמש",
"operationCancelledPartial": "הפעולה בוטלה. {success} פריטים עובדו.",
"metadataFetchFailed": "אחזור המטא-דאטה נכשל: {message}", "metadataFetchFailed": "אחזור המטא-דאטה נכשל: {message}",
"bulkMetadataCompleteAll": "רועננו בהצלחה כל {count} ה-{type}s", "bulkMetadataCompleteAll": "רועננו בהצלחה כל {count} ה-{type}s",
"bulkMetadataCompletePartial": "רועננו {success} מתוך {total} {type}s", "bulkMetadataCompletePartial": "רועננו {success} מתוך {total} {type}s",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "העברות שנכשלו:\n{failures}", "bulkMoveFailures": "העברות שנכשלו:\n{failures}",
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s", "bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!", "exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}" "exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "アップデート", "update": "アップデート",
"updateAvailable": "アップデートがあります" "updateAvailable": "アップデートがあります"
},
"usage": {
"timesUsed": "使用回数"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "レシピデータの修復",
"loading": "レシピデータを修復中...",
"success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "作成者", "creator": "作成者",
"title": "レシピタイトル", "title": "レシピタイトル",
"loraName": "LoRAファイル名", "loraName": "LoRAファイル名",
"loraModel": "LoRAモデル名" "loraModel": "LoRAモデル名",
"prompt": "プロンプト"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "ライセンス", "license": "ライセンス",
"noCreditRequired": "クレジット不要", "noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可", "allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし",
"clearAll": "すべてのフィルタをクリア" "clearAll": "すべてのフィルタをクリア"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "設定フォルダーを開く", "label": "設定フォルダーを開く",
"tooltip": "settings.json を含むフォルダーを開きます", "tooltip": "settings.json を含むフォルダーを開きます",
"success": "settings.json フォルダーを開きました", "success": "settings.json フォルダーを開きました",
"failed": "settings.json フォルダーを開けませんでした" "failed": "settings.json フォルダーを開けませんでした",
"copied": "設定パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "設定パス: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "コンテンツフィルタリング", "contentFiltering": "コンテンツフィルタリング",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定", "defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
"defaultCheckpointRoot": "デフォルトCheckpointルート", "defaultCheckpointRoot": "デフォルトCheckpointルート",
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定", "defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
"defaultUnetRoot": "デフォルトDiffusion Modelルート",
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
"defaultEmbeddingRoot": "デフォルトEmbeddingルート", "defaultEmbeddingRoot": "デフォルトEmbeddingルート",
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定", "defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
"noDefault": "デフォルトなし" "noDefault": "デフォルトなし"
@@ -443,7 +459,10 @@
"dateAsc": "古い順", "dateAsc": "古い順",
"size": "ファイルサイズ", "size": "ファイルサイズ",
"sizeDesc": "大きい順", "sizeDesc": "大きい順",
"sizeAsc": "小さい順" "sizeAsc": "小さい順",
"usage": "使用回数",
"usageDesc": "多い",
"usageAsc": "少ない"
}, },
"refresh": { "refresh": {
"title": "モデルリストを更新", "title": "モデルリストを更新",
@@ -518,6 +537,7 @@
"replacePreview": "プレビューを置換", "replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定", "setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
"deleteModel": "モデルを削除", "deleteModel": "モデルを削除",
"shareRecipe": "レシピを共有", "shareRecipe": "レシピを共有",
@@ -588,10 +608,26 @@
"selectLoraRoot": "LoRAルートディレクトリを選択してください" "selectLoraRoot": "LoRAルートディレクトリを選択してください"
} }
}, },
"sort": {
"title": "レシピの並び替え...",
"name": "名前",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "日付",
"dateDesc": "新しい順",
"dateAsc": "古い順",
"lorasCount": "LoRA数",
"lorasCountDesc": "多い順",
"lorasCountAsc": "少ない順"
},
"refresh": { "refresh": {
"title": "レシピリストを更新" "title": "レシピリストを更新"
}, },
"filteredByLora": "LoRAでフィルタ済み" "filteredByLora": "LoRAでフィルタ済み",
"favorites": {
"title": "お気に入りのみ表示",
"action": "お気に入り"
}
}, },
"duplicates": { "duplicates": {
"found": "{count} 個の重複グループが見つかりました", "found": "{count} 個の重複グループが見つかりました",
@@ -617,11 +653,25 @@
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"getInfoFailed": "不足LoRAの情報取得に失敗しました", "getInfoFailed": "不足LoRAの情報取得に失敗しました",
"prepareError": "ダウンロード用LoRAの準備中にエラー{message}" "prepareError": "ダウンロード用LoRAの準備中にエラー{message}"
},
"repair": {
"starting": "レシピのメタデータを修復中...",
"success": "レシピのメタデータが正常に修復されました",
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Checkpointモデル" "title": "Checkpointモデル",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "{otherType} フォルダに移動"
}
}, },
"embeddings": { "embeddings": {
"title": "Embeddingモデル" "title": "Embeddingモデル"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。" "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "ファイルの場所を正常に開きました", "success": "ファイルの場所を正常に開きました",
"failed": "ファイルの場所を開くのに失敗しました" "failed": "ファイルの場所を開くのに失敗しました",
"copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}"
}, },
"metadata": { "metadata": {
"version": "バージョン", "version": "バージョン",
@@ -871,11 +924,13 @@
"addPresetParameter": "プリセットパラメータを追加...", "addPresetParameter": "プリセットパラメータを追加...",
"strengthMin": "強度最小", "strengthMin": "強度最小",
"strengthMax": "強度最大", "strengthMax": "強度最大",
"strengthRange": "強度範囲",
"strength": "強度", "strength": "強度",
"clipStrength": "クリップ強度", "clipStrength": "クリップ強度",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "値", "valuePlaceholder": "値",
"add": "追加" "add": "追加",
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
}, },
"triggerWords": { "triggerWords": {
"label": "トリガーワード", "label": "トリガーワード",
@@ -914,6 +969,13 @@
"recipes": "レシピ", "recipes": "レシピ",
"versions": "バージョン" "versions": "バージョン"
}, },
"navigation": {
"label": "モデルナビゲーション",
"previousWithShortcut": "前のモデル(←)",
"nextWithShortcut": "次のモデル(→)",
"noPrevious": "前のモデルがありません",
"noNext": "次のモデルがありません"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。", "verificationCompleteSuccess": "検証完了。すべてのファイルが重複であることが確認されました。",
"verificationFailed": "ハッシュの検証に失敗しました:{message}", "verificationFailed": "ハッシュの検証に失敗しました:{message}",
"noTagsToAdd": "追加するタグがありません", "noTagsToAdd": "追加するタグがありません",
"bulkTagsUpdating": "{count} 個のモデルのタグを更新しています...",
"tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました", "tagsAddedSuccessfully": "{count} {type} に {tagCount} 個のタグを追加しました",
"tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました", "tagsReplacedSuccessfully": "{count} {type} のタグを {tagCount} 個に置換しました",
"tagsAddFailed": "{count} モデルへのタグ追加に失敗しました", "tagsAddFailed": "{count} モデルへのタグ追加に失敗しました",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "LoRAルートの読み込みに失敗しました{message}", "loraRootsFailed": "LoRAルートの読み込みに失敗しました{message}",
"checkpointRootsFailed": "checkpointルートの読み込みに失敗しました{message}", "checkpointRootsFailed": "checkpointルートの読み込みに失敗しました{message}",
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました{message}",
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました{message}", "embeddingRootsFailed": "embeddingルートの読み込みに失敗しました{message}",
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング{plural}", "mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング{plural}",
"mappingsCleared": "ベースモデルパスマッピングがクリアされました", "mappingsCleared": "ベースモデルパスマッピングがクリアされました",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "メタデータが正常に更新されました", "metadataRefreshed": "メタデータが正常に更新されました",
"metadataRefreshFailed": "メタデータの更新に失敗しました:{message}", "metadataRefreshFailed": "メタデータの更新に失敗しました:{message}",
"metadataUpdateComplete": "メタデータ更新完了", "metadataUpdateComplete": "メタデータ更新完了",
"operationCancelled": "ユーザーによって操作がキャンセルされました",
"operationCancelledPartial": "操作がキャンセルされました。{success} 個の項目が処理されました。",
"metadataFetchFailed": "メタデータの取得に失敗しました:{message}", "metadataFetchFailed": "メタデータの取得に失敗しました:{message}",
"bulkMetadataCompleteAll": "{count} {type}すべてが正常に更新されました", "bulkMetadataCompleteAll": "{count} {type}すべてが正常に更新されました",
"bulkMetadataCompletePartial": "{total} {type}のうち {success} が更新されました", "bulkMetadataCompletePartial": "{total} {type}のうち {success} が更新されました",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "失敗した移動:\n{failures}", "bulkMoveFailures": "失敗した移動:\n{failures}",
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました", "bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!", "exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}" "exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "업데이트", "update": "업데이트",
"updateAvailable": "업데이트 가능" "updateAvailable": "업데이트 가능"
},
"usage": {
"timesUsed": "사용 횟수"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "레시피 데이터 복구",
"loading": "레시피 데이터 복구 중...",
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "제작자", "creator": "제작자",
"title": "레시피 제목", "title": "레시피 제목",
"loraName": "LoRA 파일명", "loraName": "LoRA 파일명",
"loraModel": "LoRA 모델명" "loraModel": "LoRA 모델명",
"prompt": "프롬프트"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "라이선스", "license": "라이선스",
"noCreditRequired": "크레딧 표기 없음", "noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용", "allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음",
"clearAll": "모든 필터 지우기" "clearAll": "모든 필터 지우기"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "설정 폴더 열기", "label": "설정 폴더 열기",
"tooltip": "settings.json이 있는 폴더를 엽니다", "tooltip": "settings.json이 있는 폴더를 엽니다",
"success": "settings.json 폴더를 열었습니다", "success": "settings.json 폴더를 열었습니다",
"failed": "settings.json 폴더를 열지 못했습니다" "failed": "settings.json 폴더를 열지 못했습니다",
"copied": "설정 경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "설정 경로: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "콘텐츠 필터링", "contentFiltering": "콘텐츠 필터링",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다", "defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
"defaultCheckpointRoot": "기본 Checkpoint 루트", "defaultCheckpointRoot": "기본 Checkpoint 루트",
"defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다", "defaultCheckpointRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Checkpoint 루트 디렉토리를 설정합니다",
"defaultUnetRoot": "기본 Diffusion Model 루트",
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
"defaultEmbeddingRoot": "기본 Embedding 루트", "defaultEmbeddingRoot": "기본 Embedding 루트",
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다", "defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
"noDefault": "기본값 없음" "noDefault": "기본값 없음"
@@ -443,7 +459,10 @@
"dateAsc": "오래된순", "dateAsc": "오래된순",
"size": "파일 크기", "size": "파일 크기",
"sizeDesc": "큰 순서", "sizeDesc": "큰 순서",
"sizeAsc": "작은 순서" "sizeAsc": "작은 순서",
"usage": "사용 횟수",
"usageDesc": "많은 순",
"usageAsc": "적은 순"
}, },
"refresh": { "refresh": {
"title": "모델 목록 새로고침", "title": "모델 목록 새로고침",
@@ -518,6 +537,7 @@
"replacePreview": "미리보기 교체", "replacePreview": "미리보기 교체",
"setContentRating": "콘텐츠 등급 설정", "setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
"deleteModel": "모델 삭제", "deleteModel": "모델 삭제",
"shareRecipe": "레시피 공유", "shareRecipe": "레시피 공유",
@@ -588,10 +608,26 @@
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요" "selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
} }
}, },
"sort": {
"title": "레시피 정렬...",
"name": "이름",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "날짜",
"dateDesc": "최신순",
"dateAsc": "오래된순",
"lorasCount": "LoRA 수",
"lorasCountDesc": "많은순",
"lorasCountAsc": "적은순"
},
"refresh": { "refresh": {
"title": "레시피 목록 새로고침" "title": "레시피 목록 새로고침"
}, },
"filteredByLora": "LoRA로 필터링됨" "filteredByLora": "LoRA로 필터링됨",
"favorites": {
"title": "즐겨찾기만 표시",
"action": "즐겨찾기"
}
}, },
"duplicates": { "duplicates": {
"found": "{count}개의 중복 그룹 발견", "found": "{count}개의 중복 그룹 발견",
@@ -617,11 +653,25 @@
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"prepareError": "LoRA 다운로드 준비 중 오류: {message}" "prepareError": "LoRA 다운로드 준비 중 오류: {message}"
},
"repair": {
"starting": "레시피 메타데이터 복구 중...",
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
"failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Checkpoint 모델" "title": "Checkpoint 모델",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "{otherType} 폴더로 이동"
}
}, },
"embeddings": { "embeddings": {
"title": "Embedding 모델" "title": "Embedding 모델"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다." "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "파일 위치가 성공적으로 열렸습니다", "success": "파일 위치가 성공적으로 열렸습니다",
"failed": "파일 위치 열기에 실패했습니다" "failed": "파일 위치 열기에 실패했습니다",
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}"
}, },
"metadata": { "metadata": {
"version": "버전", "version": "버전",
@@ -871,11 +924,13 @@
"addPresetParameter": "프리셋 매개변수 추가...", "addPresetParameter": "프리셋 매개변수 추가...",
"strengthMin": "최소 강도", "strengthMin": "최소 강도",
"strengthMax": "최대 강도", "strengthMax": "최대 강도",
"strengthRange": "강도 범위",
"strength": "강도", "strength": "강도",
"clipStrength": "클립 강도", "clipStrength": "클립 강도",
"clipSkip": "클립 스킵", "clipSkip": "클립 스킵",
"valuePlaceholder": "값", "valuePlaceholder": "값",
"add": "추가" "add": "추가",
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요"
}, },
"triggerWords": { "triggerWords": {
"label": "트리거 단어", "label": "트리거 단어",
@@ -914,6 +969,13 @@
"recipes": "레시피", "recipes": "레시피",
"versions": "버전" "versions": "버전"
}, },
"navigation": {
"label": "모델 탐색",
"previousWithShortcut": "이전 모델(←)",
"nextWithShortcut": "다음 모델(→)",
"noPrevious": "이전 모델이 없습니다",
"noNext": "다음 모델이 없습니다"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.", "verificationCompleteSuccess": "검증 완료. 모든 파일이 중복임을 확인했습니다.",
"verificationFailed": "해시 검증 실패: {message}", "verificationFailed": "해시 검증 실패: {message}",
"noTagsToAdd": "추가할 태그가 없습니다", "noTagsToAdd": "추가할 태그가 없습니다",
"bulkTagsUpdating": "{count}개 모델의 태그를 업데이트 중입니다...",
"tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다", "tagsAddedSuccessfully": "{count}개의 {type}에 {tagCount}개의 태그가 성공적으로 추가되었습니다",
"tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다", "tagsReplacedSuccessfully": "{count}개의 {type}의 태그가 {tagCount}개의 태그로 성공적으로 교체되었습니다",
"tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다", "tagsAddFailed": "{count}개의 모델에 태그 추가에 실패했습니다",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "LoRA 루트 로딩 실패: {message}", "loraRootsFailed": "LoRA 루트 로딩 실패: {message}",
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}", "checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}", "embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)", "mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다", "mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다", "metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
"metadataRefreshFailed": "메타데이터 새로고침 실패: {message}", "metadataRefreshFailed": "메타데이터 새로고침 실패: {message}",
"metadataUpdateComplete": "메타데이터 업데이트 완료", "metadataUpdateComplete": "메타데이터 업데이트 완료",
"operationCancelled": "사용자에 의해 작업이 취소되었습니다",
"operationCancelledPartial": "작업이 취소되었습니다. {success}개 항목이 처리되었습니다.",
"metadataFetchFailed": "메타데이터 가져오기 실패: {message}", "metadataFetchFailed": "메타데이터 가져오기 실패: {message}",
"bulkMetadataCompleteAll": "모든 {count}개 {type}이(가) 성공적으로 새로고침되었습니다", "bulkMetadataCompleteAll": "모든 {count}개 {type}이(가) 성공적으로 새로고침되었습니다",
"bulkMetadataCompletePartial": "{total}개 중 {success}개 {type}이(가) 새로고침되었습니다", "bulkMetadataCompletePartial": "{total}개 중 {success}개 {type}이(가) 새로고침되었습니다",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "실패한 이동:\n{failures}", "bulkMoveFailures": "실패한 이동:\n{failures}",
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다", "bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!", "exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}" "exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "Обновление", "update": "Обновление",
"updateAvailable": "Доступно обновление" "updateAvailable": "Доступно обновление"
},
"usage": {
"timesUsed": "Количество использований"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "Восстановить данные рецептов",
"loading": "Восстановление данных рецептов...",
"success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "Автор", "creator": "Автор",
"title": "Название рецепта", "title": "Название рецепта",
"loraName": "Имя файла LoRA", "loraName": "Имя файла LoRA",
"loraModel": "Название модели LoRA" "loraModel": "Название модели LoRA",
"prompt": "Запрос"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "Лицензия", "license": "Лицензия",
"noCreditRequired": "Без указания авторства", "noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена", "allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов",
"clearAll": "Очистить все фильтры" "clearAll": "Очистить все фильтры"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "Открыть папку настроек", "label": "Открыть папку настроек",
"tooltip": "Открыть папку, содержащую settings.json", "tooltip": "Открыть папку, содержащую settings.json",
"success": "Папка settings.json открыта", "success": "Папка settings.json открыта",
"failed": "Не удалось открыть папку settings.json" "failed": "Не удалось открыть папку settings.json",
"copied": "Путь настроек скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь настроек: {{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "Фильтрация контента", "contentFiltering": "Фильтрация контента",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений", "defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
"defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию", "defaultCheckpointRoot": "Корневая папка Checkpoint по умолчанию",
"defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений", "defaultCheckpointRootHelp": "Установить корневую папку checkpoint по умолчанию для загрузок, импорта и перемещений",
"defaultUnetRoot": "Корневая папка Diffusion Model по умолчанию",
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
"defaultEmbeddingRoot": "Корневая папка Embedding по умолчанию", "defaultEmbeddingRoot": "Корневая папка Embedding по умолчанию",
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений", "defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
"noDefault": "Не задано" "noDefault": "Не задано"
@@ -443,7 +459,10 @@
"dateAsc": "Старейшим", "dateAsc": "Старейшим",
"size": "Размеру файла", "size": "Размеру файла",
"sizeDesc": "Наибольшим", "sizeDesc": "Наибольшим",
"sizeAsc": "Наименьшим" "sizeAsc": "Наименьшим",
"usage": "Число использований",
"usageDesc": "Больше",
"usageAsc": "Меньше"
}, },
"refresh": { "refresh": {
"title": "Обновить список моделей", "title": "Обновить список моделей",
@@ -518,6 +537,7 @@
"replacePreview": "Заменить превью", "replacePreview": "Заменить превью",
"setContentRating": "Установить рейтинг контента", "setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
"deleteModel": "Удалить модель", "deleteModel": "Удалить модель",
"shareRecipe": "Поделиться рецептом", "shareRecipe": "Поделиться рецептом",
@@ -588,10 +608,26 @@
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA" "selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
} }
}, },
"sort": {
"title": "Сортировка рецептов...",
"name": "Имя",
"nameAsc": "А - Я",
"nameDesc": "Я - А",
"date": "Дата",
"dateDesc": "Сначала новые",
"dateAsc": "Сначала старые",
"lorasCount": "Кол-во LoRA",
"lorasCountDesc": "Больше всего",
"lorasCountAsc": "Меньше всего"
},
"refresh": { "refresh": {
"title": "Обновить список рецептов" "title": "Обновить список рецептов"
}, },
"filteredByLora": "Фильтр по LoRA" "filteredByLora": "Фильтр по LoRA",
"favorites": {
"title": "Только избранные",
"action": "Избранное"
}
}, },
"duplicates": { "duplicates": {
"found": "Найдено {count} групп дубликатов", "found": "Найдено {count} групп дубликатов",
@@ -617,11 +653,25 @@
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}" "prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
},
"repair": {
"starting": "Восстановление метаданных рецепта...",
"success": "Метаданные рецепта успешно восстановлены",
"skipped": "Рецепт уже последней версии, восстановление не требуется",
"failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Модели Checkpoint" "title": "Модели Checkpoint",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "Переместить в папку {otherType}"
}
}, },
"embeddings": { "embeddings": {
"title": "Модели Embedding" "title": "Модели Embedding"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения." "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "Расположение файла успешно открыто", "success": "Расположение файла успешно открыто",
"failed": "Не удалось открыть расположение файла" "failed": "Не удалось открыть расположение файла",
"copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}"
}, },
"metadata": { "metadata": {
"version": "Версия", "version": "Версия",
@@ -871,11 +924,13 @@
"addPresetParameter": "Добавить предустановленный параметр...", "addPresetParameter": "Добавить предустановленный параметр...",
"strengthMin": "Мин. сила", "strengthMin": "Мин. сила",
"strengthMax": "Макс. сила", "strengthMax": "Макс. сила",
"strengthRange": "Диапазон силы",
"strength": "Сила", "strength": "Сила",
"clipStrength": "Сила клипа", "clipStrength": "Сила клипа",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "Значение", "valuePlaceholder": "Значение",
"add": "Добавить" "add": "Добавить",
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "Триггерные слова", "label": "Триггерные слова",
@@ -914,6 +969,13 @@
"recipes": "Рецепты", "recipes": "Рецепты",
"versions": "Версии" "versions": "Версии"
}, },
"navigation": {
"label": "Навигация по моделям",
"previousWithShortcut": "Предыдущая модель (←)",
"nextWithShortcut": "Следующая модель (→)",
"noPrevious": "Предыдущая модель отсутствует",
"noNext": "Следующая модель отсутствует"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.", "verificationCompleteSuccess": "Проверка завершена. Все файлы подтверждены как дубликаты.",
"verificationFailed": "Не удалось проверить хеши: {message}", "verificationFailed": "Не удалось проверить хеши: {message}",
"noTagsToAdd": "Нет тегов для добавления", "noTagsToAdd": "Нет тегов для добавления",
"bulkTagsUpdating": "Обновление тегов для {count} модел(ей)...",
"tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)", "tagsAddedSuccessfully": "Успешно добавлено {tagCount} тег(ов) к {count} {type}(ам)",
"tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)", "tagsReplacedSuccessfully": "Успешно заменены теги для {count} {type}(ов) на {tagCount} тег(ов)",
"tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)", "tagsAddFailed": "Не удалось добавить теги к {count} модель(ям)",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "Не удалось загрузить корни LoRA: {message}", "loraRootsFailed": "Не удалось загрузить корни LoRA: {message}",
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}", "checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}", "embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count} сопоставлени{plural})", "mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count} сопоставлени{plural})",
"mappingsCleared": "Сопоставления путей базовых моделей очищены", "mappingsCleared": "Сопоставления путей базовых моделей очищены",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Метаданные успешно обновлены", "metadataRefreshed": "Метаданные успешно обновлены",
"metadataRefreshFailed": "Не удалось обновить метаданные: {message}", "metadataRefreshFailed": "Не удалось обновить метаданные: {message}",
"metadataUpdateComplete": "Обновление метаданных завершено", "metadataUpdateComplete": "Обновление метаданных завершено",
"operationCancelled": "Операция отменена пользователем",
"operationCancelledPartial": "Операция отменена. Обработано {success} элементов.",
"metadataFetchFailed": "Не удалось получить метаданные: {message}", "metadataFetchFailed": "Не удалось получить метаданные: {message}",
"bulkMetadataCompleteAll": "Успешно обновлены все {count} {type}s", "bulkMetadataCompleteAll": "Успешно обновлены все {count} {type}s",
"bulkMetadataCompletePartial": "Обновлено {success} из {total} {type}s", "bulkMetadataCompletePartial": "Обновлено {success} из {total} {type}s",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Неудачные перемещения:\n{failures}", "bulkMoveFailures": "Неудачные перемещения:\n{failures}",
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s", "bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!", "exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}" "exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "更新", "update": "更新",
"updateAvailable": "有可用更新" "updateAvailable": "有可用更新"
},
"usage": {
"timesUsed": "使用次数"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}", "success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata", "none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "修复配方数据",
"loading": "正在修复配方数据...",
"success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "创作者", "creator": "创作者",
"title": "配方标题", "title": "配方标题",
"loraName": "LoRA 文件名", "loraName": "LoRA 文件名",
"loraModel": "LoRA 模型名称" "loraModel": "LoRA 模型名称",
"prompt": "提示词"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "许可证", "license": "许可证",
"noCreditRequired": "无需署名", "noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售", "allowSellingGeneratedContent": "允许销售",
"noTags": "无标签",
"clearAll": "清除所有筛选" "clearAll": "清除所有筛选"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "打开设置文件夹", "label": "打开设置文件夹",
"tooltip": "打开包含 settings.json 的文件夹", "tooltip": "打开包含 settings.json 的文件夹",
"success": "已打开 settings.json 文件夹", "success": "已打开 settings.json 文件夹",
"failed": "无法打开 settings.json 文件夹" "failed": "无法打开 settings.json 文件夹",
"copied": "设置路径已复制到剪贴板:{{path}}",
"clipboardFallback": "设置路径:{{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "内容过滤", "contentFiltering": "内容过滤",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录", "defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
"defaultCheckpointRoot": "默认 Checkpoint 根目录", "defaultCheckpointRoot": "默认 Checkpoint 根目录",
"defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录", "defaultCheckpointRootHelp": "设置下载、导入和移动时的默认 Checkpoint 根目录",
"defaultUnetRoot": "默认 Diffusion Model 根目录",
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
"defaultEmbeddingRoot": "默认 Embedding 根目录", "defaultEmbeddingRoot": "默认 Embedding 根目录",
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录", "defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
"noDefault": "无默认" "noDefault": "无默认"
@@ -443,7 +459,10 @@
"dateAsc": "最旧", "dateAsc": "最旧",
"size": "文件大小", "size": "文件大小",
"sizeDesc": "最大", "sizeDesc": "最大",
"sizeAsc": "最小" "sizeAsc": "最小",
"usage": "使用次数",
"usageDesc": "最多",
"usageAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "刷新模型列表", "title": "刷新模型列表",
@@ -518,6 +537,7 @@
"replacePreview": "替换预览", "replacePreview": "替换预览",
"setContentRating": "设置内容评级", "setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"deleteModel": "删除模型", "deleteModel": "删除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
@@ -588,10 +608,26 @@
"selectLoraRoot": "请选择 LoRA 根目录" "selectLoraRoot": "请选择 LoRA 根目录"
} }
}, },
"sort": {
"title": "配方排序...",
"name": "名称",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "时间",
"dateDesc": "最新",
"dateAsc": "最早",
"lorasCount": "LoRA 数量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
},
"refresh": { "refresh": {
"title": "刷新配方列表" "title": "刷新配方列表"
}, },
"filteredByLora": "按 LoRA 筛选" "filteredByLora": "按 LoRA 筛选",
"favorites": {
"title": "仅显示收藏",
"action": "收藏"
}
}, },
"duplicates": { "duplicates": {
"found": "发现 {count} 个重复组", "found": "发现 {count} 个重复组",
@@ -617,11 +653,25 @@
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"getInfoFailed": "获取缺失 LoRA 信息失败", "getInfoFailed": "获取缺失 LoRA 信息失败",
"prepareError": "准备下载 LoRA 时出错:{message}" "prepareError": "准备下载 LoRA 时出错:{message}"
},
"repair": {
"starting": "正在修复配方元数据...",
"success": "配方元数据修复成功",
"skipped": "配方已是最新版本,无需修复",
"failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Checkpoint 模型" "title": "Checkpoint 模型",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹"
}
}, },
"embeddings": { "embeddings": {
"title": "Embedding 模型" "title": "Embedding 模型"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "仅在树形视图中可使用递归搜索", "recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。" "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "文件位置已成功打开", "success": "文件位置已成功打开",
"failed": "打开文件位置失败" "failed": "打开文件位置失败",
"copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}"
}, },
"metadata": { "metadata": {
"version": "版本", "version": "版本",
@@ -871,11 +924,13 @@
"addPresetParameter": "添加预设参数...", "addPresetParameter": "添加预设参数...",
"strengthMin": "最小强度", "strengthMin": "最小强度",
"strengthMax": "最大强度", "strengthMax": "最大强度",
"strengthRange": "强度范围",
"strength": "强度", "strength": "强度",
"clipStrength": "Clip 强度", "clipStrength": "Clip 强度",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "数值", "valuePlaceholder": "数值",
"add": "添加" "add": "添加",
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "触发词", "label": "触发词",
@@ -914,6 +969,13 @@
"recipes": "配方", "recipes": "配方",
"versions": "版本" "versions": "版本"
}, },
"navigation": {
"label": "模型导航",
"previousWithShortcut": "上一个模型(←)",
"nextWithShortcut": "下一个模型(→)",
"noPrevious": "没有上一个模型",
"noNext": "没有下一个模型"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "验证完成。所有文件均为重复项。", "verificationCompleteSuccess": "验证完成。所有文件均为重复项。",
"verificationFailed": "验证哈希失败:{message}", "verificationFailed": "验证哈希失败:{message}",
"noTagsToAdd": "没有可添加的标签", "noTagsToAdd": "没有可添加的标签",
"bulkTagsUpdating": "正在更新 {count} 个模型的标签...",
"tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签", "tagsAddedSuccessfully": "已成功为 {count} 个 {type} 添加 {tagCount} 个标签",
"tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签", "tagsReplacedSuccessfully": "已成功为 {count} 个 {type} 替换为 {tagCount} 个标签",
"tagsAddFailed": "为 {count} 个模型添加标签失败", "tagsAddFailed": "为 {count} 个模型添加标签失败",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "加载 LoRA 根目录失败:{message}", "loraRootsFailed": "加载 LoRA 根目录失败:{message}",
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}", "checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}", "embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射{plural}", "mappingsUpdated": "基础模型路径映射已更新({count} 条映射{plural}",
"mappingsCleared": "基础模型路径映射已清除", "mappingsCleared": "基础模型路径映射已清除",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "元数据刷新成功", "metadataRefreshed": "元数据刷新成功",
"metadataRefreshFailed": "刷新元数据失败:{message}", "metadataRefreshFailed": "刷新元数据失败:{message}",
"metadataUpdateComplete": "元数据更新完成", "metadataUpdateComplete": "元数据更新完成",
"operationCancelled": "操作已由用户取消",
"operationCancelledPartial": "操作已取消。已处理 {success} 个项目。",
"metadataFetchFailed": "获取元数据失败:{message}", "metadataFetchFailed": "获取元数据失败:{message}",
"bulkMetadataCompleteAll": "全部 {count} 个 {type} 元数据刷新成功", "bulkMetadataCompleteAll": "全部 {count} 个 {type} 元数据刷新成功",
"bulkMetadataCompletePartial": "已刷新 {success}/{total} 个 {type} 元数据", "bulkMetadataCompletePartial": "已刷新 {success}/{total} 个 {type} 元数据",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "移动失败:\n{failures}", "bulkMoveFailures": "移动失败:\n{failures}",
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}", "bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
"exampleImagesDownloadSuccess": "示例图片下载成功!", "exampleImagesDownloadSuccess": "示例图片下载成功!",
"exampleImagesDownloadFailed": "示例图片下载失败:{message}" "exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1471,4 +1538,4 @@
"learnMore": "浏览器插件教程" "learnMore": "浏览器插件教程"
} }
} }
} }

View File

@@ -131,6 +131,9 @@
"badges": { "badges": {
"update": "更新", "update": "更新",
"updateAvailable": "有可用更新" "updateAvailable": "有可用更新"
},
"usage": {
"timesUsed": "使用次數"
} }
}, },
"globalContextMenu": { "globalContextMenu": {
@@ -154,11 +157,18 @@
"error": "清理範例圖片資料夾失敗:{message}" "error": "清理範例圖片資料夾失敗:{message}"
}, },
"fetchMissingLicenses": { "fetchMissingLicenses": {
"label": "Refresh license metadata", "label": "重新整理授權中繼資料",
"loading": "Refreshing license metadata for {typePlural}...", "loading": "正在重新整理 {typePlural} 的授權中繼資料...",
"success": "Updated license metadata for {count} {typePlural}", "success": "已更新 {count} {typePlural} 的授權中繼資料",
"none": "All {typePlural} already have license metadata", "none": "所有 {typePlural} 已具備授權中繼資料",
"error": "Failed to refresh license metadata for {typePlural}: {message}" "error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
},
"repairRecipes": {
"label": "修復配方資料",
"loading": "正在修復配方資料...",
"success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}"
} }
}, },
"header": { "header": {
@@ -188,7 +198,8 @@
"creator": "創作者", "creator": "創作者",
"title": "配方標題", "title": "配方標題",
"loraName": "LoRA 檔案名稱", "loraName": "LoRA 檔案名稱",
"loraModel": "LoRA 模型名稱" "loraModel": "LoRA 模型名稱",
"prompt": "提示詞"
} }
}, },
"filter": { "filter": {
@@ -199,6 +210,7 @@
"license": "授權", "license": "授權",
"noCreditRequired": "無需署名", "noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售", "allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤",
"clearAll": "清除所有篩選" "clearAll": "清除所有篩選"
}, },
"theme": { "theme": {
@@ -221,7 +233,9 @@
"label": "開啟設定資料夾", "label": "開啟設定資料夾",
"tooltip": "開啟包含 settings.json 的資料夾", "tooltip": "開啟包含 settings.json 的資料夾",
"success": "已開啟 settings.json 資料夾", "success": "已開啟 settings.json 資料夾",
"failed": "無法開啟 settings.json 資料夾" "failed": "無法開啟 settings.json 資料夾",
"copied": "設定路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "設定路徑:{{path}}"
}, },
"sections": { "sections": {
"contentFiltering": "內容過濾", "contentFiltering": "內容過濾",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄", "defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
"defaultCheckpointRoot": "預設 Checkpoint 根目錄", "defaultCheckpointRoot": "預設 Checkpoint 根目錄",
"defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄", "defaultCheckpointRootHelp": "設定下載、匯入和移動時的預設 Checkpoint 根目錄",
"defaultUnetRoot": "預設 Diffusion Model 根目錄",
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
"defaultEmbeddingRoot": "預設 Embedding 根目錄", "defaultEmbeddingRoot": "預設 Embedding 根目錄",
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄", "defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
"noDefault": "未設定預設" "noDefault": "未設定預設"
@@ -443,7 +459,10 @@
"dateAsc": "最舊", "dateAsc": "最舊",
"size": "檔案大小", "size": "檔案大小",
"sizeDesc": "最大", "sizeDesc": "最大",
"sizeAsc": "最小" "sizeAsc": "最小",
"usage": "使用次數",
"usageDesc": "最多",
"usageAsc": "最少"
}, },
"refresh": { "refresh": {
"title": "重新整理模型列表", "title": "重新整理模型列表",
@@ -518,6 +537,7 @@
"replacePreview": "更換預覽圖", "replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級", "setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"excludeModel": "排除模型", "excludeModel": "排除模型",
"deleteModel": "刪除模型", "deleteModel": "刪除模型",
"shareRecipe": "分享配方", "shareRecipe": "分享配方",
@@ -588,10 +608,26 @@
"selectLoraRoot": "請選擇 LoRA 根目錄" "selectLoraRoot": "請選擇 LoRA 根目錄"
} }
}, },
"sort": {
"title": "配方排序...",
"name": "名稱",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "時間",
"dateDesc": "最新",
"dateAsc": "最舊",
"lorasCount": "LoRA 數量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
},
"refresh": { "refresh": {
"title": "重新整理配方列表" "title": "重新整理配方列表"
}, },
"filteredByLora": "已依 LoRA 篩選" "filteredByLora": "已依 LoRA 篩選",
"favorites": {
"title": "僅顯示收藏",
"action": "收藏"
}
}, },
"duplicates": { "duplicates": {
"found": "發現 {count} 組重複項", "found": "發現 {count} 組重複項",
@@ -617,11 +653,25 @@
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"getInfoFailed": "取得缺少 LoRA 資訊失敗", "getInfoFailed": "取得缺少 LoRA 資訊失敗",
"prepareError": "準備下載 LoRA 時發生錯誤:{message}" "prepareError": "準備下載 LoRA 時發生錯誤:{message}"
},
"repair": {
"starting": "正在修復配方元數據...",
"success": "配方元數據修復成功",
"skipped": "配方已是最新版本,無需修復",
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
} }
} }
}, },
"checkpoints": { "checkpoints": {
"title": "Checkpoint 模型" "title": "Checkpoint 模型",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾"
}
}, },
"embeddings": { "embeddings": {
"title": "Embedding 模型" "title": "Embedding 模型"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。" "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -848,7 +899,9 @@
}, },
"openFileLocation": { "openFileLocation": {
"success": "檔案位置已成功開啟", "success": "檔案位置已成功開啟",
"failed": "開啟檔案位置失敗" "failed": "開啟檔案位置失敗",
"copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}"
}, },
"metadata": { "metadata": {
"version": "版本", "version": "版本",
@@ -871,11 +924,13 @@
"addPresetParameter": "新增預設參數...", "addPresetParameter": "新增預設參數...",
"strengthMin": "最小強度", "strengthMin": "最小強度",
"strengthMax": "最大強度", "strengthMax": "最大強度",
"strengthRange": "強度範圍",
"strength": "強度", "strength": "強度",
"clipStrength": "Clip 強度", "clipStrength": "Clip 強度",
"clipSkip": "Clip Skip", "clipSkip": "Clip Skip",
"valuePlaceholder": "數值", "valuePlaceholder": "數值",
"add": "新增" "add": "新增",
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
}, },
"triggerWords": { "triggerWords": {
"label": "觸發詞", "label": "觸發詞",
@@ -914,6 +969,13 @@
"recipes": "配方", "recipes": "配方",
"versions": "版本" "versions": "版本"
}, },
"navigation": {
"label": "模型導覽",
"previousWithShortcut": "上一個模型(←)",
"nextWithShortcut": "下一個模型(→)",
"noPrevious": "沒有上一個模型",
"noNext": "沒有下一個模型"
},
"license": { "license": {
"noImageSell": "No selling generated content", "noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation", "noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。", "verificationCompleteSuccess": "驗證完成。所有檔案均確認為重複項。",
"verificationFailed": "驗證雜湊失敗:{message}", "verificationFailed": "驗證雜湊失敗:{message}",
"noTagsToAdd": "沒有可新增的標籤", "noTagsToAdd": "沒有可新增的標籤",
"bulkTagsUpdating": "正在更新 {count} 個模型的標籤...",
"tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}", "tagsAddedSuccessfully": "已成功將 {tagCount} 個標籤新增到 {count} 個 {type}",
"tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤", "tagsReplacedSuccessfully": "已成功以 {tagCount} 個標籤取代 {count} 個 {type} 的標籤",
"tagsAddFailed": "新增標籤到 {count} 個模型失敗", "tagsAddFailed": "新增標籤到 {count} 個模型失敗",
@@ -1330,6 +1393,7 @@
"settings": { "settings": {
"loraRootsFailed": "載入 LoRA 根目錄失敗:{message}", "loraRootsFailed": "載入 LoRA 根目錄失敗:{message}",
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}", "checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}", "embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)", "mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
"mappingsCleared": "基礎模型路徑對應已清除", "mappingsCleared": "基礎模型路徑對應已清除",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "metadata 已成功刷新", "metadataRefreshed": "metadata 已成功刷新",
"metadataRefreshFailed": "刷新 metadata 失敗:{message}", "metadataRefreshFailed": "刷新 metadata 失敗:{message}",
"metadataUpdateComplete": "metadata 更新完成", "metadataUpdateComplete": "metadata 更新完成",
"operationCancelled": "操作已由用戶取消",
"operationCancelledPartial": "操作已取消。已處理 {success} 個項目。",
"metadataFetchFailed": "取得 metadata 失敗:{message}", "metadataFetchFailed": "取得 metadata 失敗:{message}",
"bulkMetadataCompleteAll": "已成功刷新全部 {count} 個 {type}", "bulkMetadataCompleteAll": "已成功刷新全部 {count} 個 {type}",
"bulkMetadataCompletePartial": "已刷新 {success} / {total} 個 {type}", "bulkMetadataCompletePartial": "已刷新 {success} / {total} 個 {type}",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "移動失敗:\n{failures}", "bulkMoveFailures": "移動失敗:\n{failures}",
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}", "bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
"exampleImagesDownloadSuccess": "範例圖片下載成功!", "exampleImagesDownloadSuccess": "範例圖片下載成功!",
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}" "exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -1,13 +1,15 @@
import os import os
import platform import platform
import threading
from pathlib import Path from pathlib import Path
import folder_paths # type: ignore import folder_paths # type: ignore
from typing import Any, Dict, Iterable, List, Mapping, Optional, Set from typing import Any, Dict, Iterable, List, Mapping, Optional, Set, Tuple
import logging import logging
import json import json
import urllib.parse import urllib.parse
import time
from .utils.settings_paths import ensure_settings_file, load_settings_template from .utils.settings_paths import ensure_settings_file, get_settings_dir, load_settings_template
# Use an environment variable to control standalone mode # Use an environment variable to control standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0" standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
@@ -80,6 +82,8 @@ class Config:
self._path_mappings: Dict[str, str] = {} self._path_mappings: Dict[str, str] = {}
# Normalized preview root directories used to validate preview access # Normalized preview root directories used to validate preview access
self._preview_root_paths: Set[Path] = set() self._preview_root_paths: Set[Path] = set()
# Fingerprint of the symlink layout from the last successful scan
self._cached_fingerprint: Optional[Dict[str, object]] = None
self.loras_roots = self._init_lora_paths() self.loras_roots = self._init_lora_paths()
self.checkpoints_roots = None self.checkpoints_roots = None
self.unet_roots = None self.unet_roots = None
@@ -87,8 +91,7 @@ class Config:
self.base_models_roots = self._init_checkpoint_paths() self.base_models_roots = self._init_checkpoint_paths()
self.embeddings_roots = self._init_embedding_paths() self.embeddings_roots = self._init_embedding_paths()
# Scan symbolic links during initialization # Scan symbolic links during initialization
self._scan_symbolic_links() self._initialize_symlink_mappings()
self._rebuild_preview_roots()
if not standalone_mode: if not standalone_mode:
# Save the paths to settings.json when running in ComfyUI mode # Save the paths to settings.json when running in ComfyUI mode
@@ -220,45 +223,217 @@ class Config:
logger.error(f"Error checking link status for {path}: {e}") logger.error(f"Error checking link status for {path}: {e}")
return False return False
def _normalize_path(self, path: str) -> str:
return os.path.normpath(path).replace(os.sep, '/')
def _get_symlink_cache_path(self) -> Path:
cache_dir = Path(get_settings_dir(create=True)) / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "symlink_map.json"
def _symlink_roots(self) -> List[str]:
roots: List[str] = []
roots.extend(self.loras_roots or [])
roots.extend(self.base_models_roots or [])
roots.extend(self.embeddings_roots or [])
return roots
def _build_symlink_fingerprint(self) -> Dict[str, object]:
roots = [self._normalize_path(path) for path in self._symlink_roots() if path]
unique_roots = sorted(set(roots))
# Fingerprint now only contains the root paths to avoid sensitivity to folder content changes.
return {"roots": unique_roots}
def _initialize_symlink_mappings(self) -> None:
start = time.perf_counter()
cache_loaded = self._load_persisted_cache_into_mappings()
if cache_loaded:
logger.info(
"Symlink mappings restored from cache in %.2f ms",
(time.perf_counter() - start) * 1000,
)
self._rebuild_preview_roots()
# Only rescan if target roots have changed.
# This is stable across file additions/deletions.
current_fingerprint = self._build_symlink_fingerprint()
cached_fingerprint = self._cached_fingerprint
if cached_fingerprint and current_fingerprint == cached_fingerprint:
return
logger.info("Symlink root paths changed; rescanning symbolic links")
self.rebuild_symlink_cache()
logger.info(
"Symlink mappings rebuilt and cached in %.2f ms",
(time.perf_counter() - start) * 1000,
)
def rebuild_symlink_cache(self) -> None:
"""Force a fresh scan of all symbolic links and update the persistent cache."""
self._scan_symbolic_links()
self._save_symlink_cache()
self._rebuild_preview_roots()
def _load_persisted_cache_into_mappings(self) -> bool:
"""Load the symlink cache and store its fingerprint for comparison."""
cache_path = self._get_symlink_cache_path()
if not cache_path.exists():
return False
try:
with cache_path.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception as exc:
logger.info("Failed to load symlink cache %s: %s", cache_path, exc)
return False
if not isinstance(payload, dict):
return False
cached_mappings = payload.get("path_mappings")
if not isinstance(cached_mappings, Mapping):
return False
# Store the cached fingerprint for comparison during initialization
self._cached_fingerprint = payload.get("fingerprint")
normalized_mappings: Dict[str, str] = {}
for target, link in cached_mappings.items():
if not isinstance(target, str) or not isinstance(link, str):
continue
normalized_mappings[self._normalize_path(target)] = self._normalize_path(link)
self._path_mappings = normalized_mappings
logger.info("Symlink cache loaded with %d mappings", len(self._path_mappings))
return True
def _save_symlink_cache(self) -> None:
cache_path = self._get_symlink_cache_path()
payload = {
"fingerprint": self._build_symlink_fingerprint(),
"path_mappings": self._path_mappings,
}
try:
with cache_path.open("w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
logger.debug("Symlink cache saved to %s with %d mappings", cache_path, len(self._path_mappings))
except Exception as exc:
logger.info("Failed to write symlink cache %s: %s", cache_path, exc)
def _scan_symbolic_links(self): def _scan_symbolic_links(self):
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories""" """Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
for root in self.loras_roots: start = time.perf_counter()
self._scan_directory_links(root)
for root in self.base_models_roots: # Reset mappings before rescanning to avoid stale entries
self._scan_directory_links(root) self._path_mappings.clear()
self._seed_root_symlink_mappings()
for root in self.embeddings_roots: visited_dirs: Set[str] = set()
self._scan_directory_links(root) for root in self._symlink_roots():
self._scan_directory_links(root, visited_dirs)
logger.debug(
"Symlink scan finished in %.2f ms with %d mappings",
(time.perf_counter() - start) * 1000,
len(self._path_mappings),
)
def _scan_directory_links(self, root: str): def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
"""Recursively scan symbolic links in a directory""" """Iteratively scan directory symlinks to avoid deep recursion."""
try: try:
with os.scandir(root) as it: # Note: We only use realpath for the initial root if it's not already resolved
for entry in it: # to ensure we have a valid entry point.
if self._is_link(entry.path): root_real = self._normalize_path(os.path.realpath(root))
target_path = os.path.realpath(entry.path) except OSError:
if os.path.isdir(target_path): root_real = self._normalize_path(root)
self.add_path_mapping(entry.path, target_path)
self._scan_directory_links(target_path) if root_real in visited_dirs:
elif entry.is_dir(follow_symlinks=False): return
self._scan_directory_links(entry.path)
except Exception as e: visited_dirs.add(root_real)
logger.error(f"Error scanning links in {root}: {e}") # Stack entries: (display_path, real_resolved_path)
stack: List[Tuple[str, str]] = [(root, root_real)]
while stack:
current_display, current_real = stack.pop()
try:
with os.scandir(current_display) as it:
for entry in it:
try:
# 1. High speed detection using dirent data (is_symlink)
is_link = entry.is_symlink()
# On Windows, is_symlink handles reparse points
if is_link:
# Only resolve realpath when we actually find a link
target_path = os.path.realpath(entry.path)
if not os.path.isdir(target_path):
continue
normalized_target = self._normalize_path(target_path)
self.add_path_mapping(entry.path, target_path)
if normalized_target in visited_dirs:
continue
visited_dirs.add(normalized_target)
stack.append((target_path, normalized_target))
continue
# 2. Process normal directories
if not entry.is_dir(follow_symlinks=False):
continue
# For normal directories, we avoid realpath() call by
# incrementally building the real path relative to current_real.
# This is safe because 'entry' is NOT a symlink.
entry_real = self._normalize_path(os.path.join(current_real, entry.name))
if entry_real in visited_dirs:
continue
visited_dirs.add(entry_real)
stack.append((entry.path, entry_real))
except Exception as inner_exc:
logger.debug(
"Error processing directory entry %s: %s", entry.path, inner_exc
)
except Exception as e:
logger.error(f"Error scanning links in {current_display}: {e}")
def add_path_mapping(self, link_path: str, target_path: str): def add_path_mapping(self, link_path: str, target_path: str):
"""Add a symbolic link path mapping """Add a symbolic link path mapping
target_path: actual target path target_path: actual target path
link_path: symbolic link path link_path: symbolic link path
""" """
normalized_link = os.path.normpath(link_path).replace(os.sep, '/') normalized_link = self._normalize_path(link_path)
normalized_target = os.path.normpath(target_path).replace(os.sep, '/') normalized_target = self._normalize_path(target_path)
# Keep the original mapping: target path -> link path # Keep the original mapping: target path -> link path
self._path_mappings[normalized_target] = normalized_link self._path_mappings[normalized_target] = normalized_link
logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}") logger.info(f"Added path mapping: {normalized_target} -> {normalized_link}")
self._preview_root_paths.update(self._expand_preview_root(normalized_target)) self._preview_root_paths.update(self._expand_preview_root(normalized_target))
self._preview_root_paths.update(self._expand_preview_root(normalized_link)) self._preview_root_paths.update(self._expand_preview_root(normalized_link))
def _seed_root_symlink_mappings(self) -> None:
"""Ensure symlinked root folders are recorded before deep scanning."""
for root in self._symlink_roots():
if not root:
continue
try:
if not self._is_link(root):
continue
target_path = os.path.realpath(root)
if not os.path.isdir(target_path):
continue
self.add_path_mapping(root, target_path)
except Exception as exc:
logger.debug("Skipping root symlink %s: %s", root, exc)
def _expand_preview_root(self, path: str) -> Set[Path]: def _expand_preview_root(self, path: str) -> Set[Path]:
"""Return normalized ``Path`` objects representing a preview root.""" """Return normalized ``Path`` objects representing a preview root."""
@@ -321,7 +496,11 @@ class Config:
normalized_path = os.path.normpath(path).replace(os.sep, '/') normalized_path = os.path.normpath(path).replace(os.sep, '/')
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path in self._path_mappings.items(): for target_path, link_path in self._path_mappings.items():
if normalized_path.startswith(target_path): # Match whole path components to avoid prefix collisions (e.g., /a/b vs /a/bc)
if normalized_path == target_path:
return link_path
if normalized_path.startswith(target_path + '/'):
# If the path starts with the target path, replace with link path # If the path starts with the target path, replace with link path
mapped_path = normalized_path.replace(target_path, link_path, 1) mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path return mapped_path
@@ -331,10 +510,14 @@ class Config:
"""Map a symbolic link path back to the actual path""" """Map a symbolic link path back to the actual path"""
normalized_link = os.path.normpath(link_path).replace(os.sep, '/') normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
# Check if the path is contained in any mapped target path # Check if the path is contained in any mapped target path
for target_path, link_path in self._path_mappings.items(): for target_path, link_path_mapped in self._path_mappings.items():
if normalized_link.startswith(target_path): # Match whole path components
# If the path starts with the target path, replace with actual path if normalized_link == link_path_mapped:
mapped_path = normalized_link.replace(target_path, link_path, 1) return target_path
if normalized_link.startswith(link_path_mapped + '/'):
# If the path starts with the link path, replace with actual path
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
return mapped_path return mapped_path
return link_path return link_path
@@ -411,8 +594,7 @@ class Config:
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths) self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths) self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
self._scan_symbolic_links() self._initialize_symlink_mappings()
self._rebuild_preview_roots()
def _init_lora_paths(self) -> List[str]: def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings""" """Initialize and validate LoRA paths from ComfyUI settings"""

View File

@@ -2,6 +2,15 @@ import asyncio
import sys import sys
import os import os
import logging import logging
from .utils.logging_config import setup_logging
# Check if we're in standalone mode
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1" or os.environ.get("HF_HUB_DISABLE_TELEMETRY", "0") == "0"
# Only setup logging prefix if not in standalone mode
if not standalone_mode:
setup_logging()
from server import PromptServer # type: ignore from server import PromptServer # type: ignore
from .config import config from .config import config
@@ -17,12 +26,10 @@ from .services.settings_manager import get_settings_manager
from .utils.example_images_migration import ExampleImagesMigration from .utils.example_images_migration import ExampleImagesMigration
from .services.websocket_manager import ws_manager from .services.websocket_manager import ws_manager
from .services.example_images_cleanup_service import ExampleImagesCleanupService from .services.example_images_cleanup_service import ExampleImagesCleanupService
from .middleware.csp_middleware import relax_csp_for_remote_media
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Check if we're in standalone mode
STANDALONE_MODE = 'nodes' not in sys.modules
HEADER_SIZE_LIMIT = 16384 HEADER_SIZE_LIMIT = 16384
@@ -62,6 +69,23 @@ class LoraManager:
"""Initialize and register all routes using the new refactored architecture""" """Initialize and register all routes using the new refactored architecture"""
app = PromptServer.instance.app app = PromptServer.instance.app
if relax_csp_for_remote_media not in app.middlewares:
# Ensure CSP relaxer executes after ComfyUI's block_external_middleware so it can
# see and extend the restrictive header instead of being overwritten by it.
block_middleware_index = next(
(
idx
for idx, middleware in enumerate(app.middlewares)
if getattr(middleware, "__name__", "") == "block_external_middleware"
),
None,
)
if block_middleware_index is None:
app.middlewares.append(relax_csp_for_remote_media)
else:
app.middlewares.insert(block_middleware_index, relax_csp_for_remote_media)
# Increase allowed header sizes so browsers with large localhost cookie # Increase allowed header sizes so browsers with large localhost cookie
# jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default # jars (multiple UIs on 127.0.0.1) don't trip aiohttp's 8KB default
# limits. Cookies for unrelated apps are still sent to the plugin and # limits. Cookies for unrelated apps are still sent to the plugin and
@@ -140,8 +164,6 @@ class LoraManager:
# Add cleanup # Add cleanup
app.on_shutdown.append(cls._cleanup) app.on_shutdown.append(cls._cleanup)
logger.info(f"LoRA Manager: Set up routes for {len(ModelServiceFactory.get_registered_types())} model types: {', '.join(ModelServiceFactory.get_registered_types())}")
@classmethod @classmethod
async def _initialize_services(cls): async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry""" """Initialize all services using the ServiceRegistry"""

View File

@@ -39,8 +39,39 @@ class MetadataProcessor:
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False): if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
candidate_samplers[node_id] = metadata[SAMPLING][node_id] candidate_samplers[node_id] = metadata[SAMPLING][node_id]
# If we found candidate samplers, apply primary sampler logic to these candidates only # If we found candidate samplers, apply primary sampler logic to these candidates only
if candidate_samplers:
# PRE-PROCESS: Ensure all candidate samplers have their parameters populated
# This is especially important for SamplerCustomAdvanced which needs tracing
prompt = metadata.get("current_prompt")
for node_id in candidate_samplers:
# If a sampler is missing common parameters like steps or denoise,
# try to populate them using tracing before ranking
sampler_info = candidate_samplers[node_id]
params = sampler_info.get("parameters", {})
if prompt and (params.get("steps") is None or params.get("denoise") is None):
# Create a temporary params dict to use the handler
temp_params = {
"steps": params.get("steps"),
"denoise": params.get("denoise"),
"sampler": params.get("sampler_name"),
"scheduler": params.get("scheduler")
}
# Check if it's SamplerCustomAdvanced
if prompt.original_prompt and node_id in prompt.original_prompt:
if prompt.original_prompt[node_id].get("class_type") == "SamplerCustomAdvanced":
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, node_id, temp_params)
# Update the actual parameters with found values
params["steps"] = temp_params.get("steps")
params["denoise"] = temp_params.get("denoise")
if temp_params.get("sampler"):
params["sampler_name"] = temp_params.get("sampler")
if temp_params.get("scheduler"):
params["scheduler"] = temp_params.get("scheduler")
# Collect potential primary samplers based on different criteria # Collect potential primary samplers based on different criteria
custom_advanced_samplers = [] custom_advanced_samplers = []
advanced_add_noise_samplers = [] advanced_add_noise_samplers = []
@@ -49,7 +80,6 @@ class MetadataProcessor:
high_denoise_id = None high_denoise_id = None
# First, check for SamplerCustomAdvanced among candidates # First, check for SamplerCustomAdvanced among candidates
prompt = metadata.get("current_prompt")
if prompt and prompt.original_prompt: if prompt and prompt.original_prompt:
for node_id in candidate_samplers: for node_id in candidate_samplers:
node_info = prompt.original_prompt.get(node_id, {}) node_info = prompt.original_prompt.get(node_id, {})
@@ -77,15 +107,16 @@ class MetadataProcessor:
# Combine all potential primary samplers # Combine all potential primary samplers
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
# Find the most recent potential primary sampler (closest to downstream node) # Find the first potential primary sampler (prefer base sampler over refine)
for i in range(downstream_index - 1, -1, -1): # Use forward search to prioritize the first one in execution order
for i in range(downstream_index):
node_id = execution_order[i] node_id = execution_order[i]
if node_id in potential_samplers: if node_id in potential_samplers:
return node_id, candidate_samplers[node_id] return node_id, candidate_samplers[node_id]
# If no potential sampler found from our criteria, return the most recent sampler # If no potential sampler found from our criteria, return the first sampler
if candidate_samplers: if candidate_samplers:
for i in range(downstream_index - 1, -1, -1): for i in range(downstream_index):
node_id = execution_order[i] node_id = execution_order[i]
if node_id in candidate_samplers: if node_id in candidate_samplers:
return node_id, candidate_samplers[node_id] return node_id, candidate_samplers[node_id]
@@ -176,8 +207,11 @@ class MetadataProcessor:
found_node_id = input_value[0] # Connected node_id found_node_id = input_value[0] # Connected node_id
# If we're looking for a specific node class # If we're looking for a specific node class
if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class: if target_class:
return found_node_id if found_node_id not in prompt.original_prompt:
return None
if prompt.original_prompt[found_node_id].get("class_type") == target_class:
return found_node_id
# If we're not looking for a specific class, update the last valid node # If we're not looking for a specific class, update the last valid node
if not target_class: if not target_class:
@@ -185,11 +219,19 @@ class MetadataProcessor:
# Continue tracing through intermediate nodes # Continue tracing through intermediate nodes
current_node_id = found_node_id current_node_id = found_node_id
# For most conditioning nodes, the input we want to follow is named "conditioning"
if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}): # Check if current source node exists
if current_node_id not in prompt.original_prompt:
return found_node_id if not target_class else None
# Determine which input to follow next on the source node
source_node_inputs = prompt.original_prompt[current_node_id].get("inputs", {})
if input_name in source_node_inputs:
current_input = input_name
elif "conditioning" in source_node_inputs:
current_input = "conditioning" current_input = "conditioning"
else: else:
# If there's no "conditioning" input, return the current node # If there's no suitable input to follow, return the current node
# if we're not looking for a specific target_class # if we're not looking for a specific target_class
return found_node_id if not target_class else None return found_node_id if not target_class else None
else: else:
@@ -202,12 +244,89 @@ class MetadataProcessor:
return last_valid_node if not target_class else None return last_valid_node if not target_class else None
@staticmethod @staticmethod
def find_primary_checkpoint(metadata): def trace_model_path(metadata, prompt, start_node_id):
"""Find the primary checkpoint model in the workflow""" """
if not metadata.get(MODELS): Trace the model connection path upstream to find the checkpoint
"""
if not prompt or not prompt.original_prompt:
return None return None
# In most workflows, there's only one checkpoint, so we can just take the first one current_node_id = start_node_id
depth = 0
max_depth = 50
while depth < max_depth:
# Check if current node is a registered checkpoint in our metadata
# This handles cached nodes correctly because metadata contains info for all nodes in the graph
if current_node_id in metadata.get(MODELS, {}):
if metadata[MODELS][current_node_id].get("type") == "checkpoint":
return current_node_id
if current_node_id not in prompt.original_prompt:
return None
node = prompt.original_prompt[current_node_id]
inputs = node.get("inputs", {})
class_type = node.get("class_type", "")
# Determine which input to follow next
next_input_name = "model"
# Special handling for initial node
if depth == 0:
if class_type == "SamplerCustomAdvanced":
next_input_name = "guider"
# If the specific input doesn't exist, try generic 'model'
if next_input_name not in inputs:
if "model" in inputs:
next_input_name = "model"
elif "basic_pipe" in inputs:
# Handle pipe nodes like FromBasicPipe by following the pipeline
next_input_name = "basic_pipe"
else:
# Dead end - no model input to follow
return None
# Get connected node
input_val = inputs[next_input_name]
if isinstance(input_val, list) and len(input_val) > 0:
current_node_id = input_val[0]
else:
return None
depth += 1
return None
@staticmethod
def find_primary_checkpoint(metadata, downstream_id=None, primary_sampler_id=None):
"""
Find the primary checkpoint model in the workflow
Parameters:
- metadata: The workflow metadata
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
- primary_sampler_id: Optional ID of the primary sampler if already known
"""
if not metadata.get(MODELS):
return None
# Method 1: Topology-based tracing (More accurate for complex workflows)
# First, find the primary sampler if not provided
if not primary_sampler_id:
primary_sampler_id, _ = MetadataProcessor.find_primary_sampler(metadata, downstream_id)
if primary_sampler_id:
prompt = metadata.get("current_prompt")
if prompt:
# Trace back from the sampler to find the checkpoint
checkpoint_id = MetadataProcessor.trace_model_path(metadata, prompt, primary_sampler_id)
if checkpoint_id and checkpoint_id in metadata.get(MODELS, {}):
return metadata[MODELS][checkpoint_id].get("name")
# Method 2: Fallback to the first available checkpoint (Original behavior)
# In most simple workflows, there's only one checkpoint, so we can just take the first one
for node_id, model_info in metadata.get(MODELS, {}).items(): for node_id, model_info in metadata.get(MODELS, {}).items():
if model_info.get("type") == "checkpoint": if model_info.get("type") == "checkpoint":
return model_info.get("name") return model_info.get("name")
@@ -311,7 +430,8 @@ class MetadataProcessor:
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id) primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
# Directly get checkpoint from metadata instead of tracing # Directly get checkpoint from metadata instead of tracing
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata) # Pass primary_sampler_id to avoid redundant calculation
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata, id, primary_sampler_id)
if checkpoint: if checkpoint:
params["checkpoint"] = checkpoint params["checkpoint"] = checkpoint
@@ -445,6 +565,7 @@ class MetadataProcessor:
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {}) scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
params["steps"] = scheduler_params.get("steps") params["steps"] = scheduler_params.get("steps")
params["scheduler"] = scheduler_params.get("scheduler") params["scheduler"] = scheduler_params.get("scheduler")
params["denoise"] = scheduler_params.get("denoise")
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists) # 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
if "sampler" in sampler_inputs: if "sampler" in sampler_inputs:

View File

@@ -0,0 +1,65 @@
"""Middleware helpers for adjusting Content Security Policy headers."""
from typing import Awaitable, Callable, Dict, List
from aiohttp import web
REMOTE_MEDIA_SOURCES = (
"https://image.civitai.com",
"https://img.genur.art",
)
@web.middleware
async def relax_csp_for_remote_media(
request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]]
) -> web.StreamResponse:
"""Allow LoRA Manager media previews to load from trusted remote domains.
When ComfyUI is started with ``--disable-api-nodes`` it injects a restrictive
``Content-Security-Policy`` header that blocks remote images and videos. The
LoRA Manager UI legitimately needs to fetch previews from Civitai and Genur,
so this middleware augments the existing CSP to whitelist those hosts while
preserving all other directives.
"""
response: web.StreamResponse = await handler(request)
header_value = response.headers.get("Content-Security-Policy")
if not header_value:
return response
directive_order: List[str] = []
directives: Dict[str, List[str]] = {}
for raw_directive in header_value.split(";"):
directive = raw_directive.strip()
if not directive:
continue
parts = directive.split()
name, values = parts[0], parts[1:]
if name not in directive_order:
directive_order.append(name)
directives[name] = values
def merge_sources(name: str, sources: List[str], defaults: List[str] | None = None) -> None:
existing = directives.get(name, list(defaults or []))
for source in sources:
if source not in existing:
existing.append(source)
directives[name] = existing
if name not in directive_order:
directive_order.append(name)
merge_sources("img-src", list(REMOTE_MEDIA_SOURCES))
merge_sources("media-src", ["'self'", *REMOTE_MEDIA_SOURCES], defaults=["'self'"])
updated_header = "; ".join(
f"{name} {' '.join(directives[name])}".rstrip() for name in directive_order
)
response.headers["Content-Security-Policy"] = f"{updated_header};"
return response

View File

@@ -1,15 +1,15 @@
import logging import logging
from server import PromptServer # type: ignore
from ..metadata_collector.metadata_processor import MetadataProcessor from ..metadata_collector.metadata_processor import MetadataProcessor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DebugMetadata: class DebugMetadata:
NAME = "Debug Metadata (LoraManager)" NAME = "Debug Metadata (LoraManager)"
CATEGORY = "Lora Manager/utils" CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Debug node to verify metadata_processor functionality" DESCRIPTION = "Debug node to verify metadata_processor functionality"
OUTPUT_NODE = True OUTPUT_NODE = True
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
@@ -25,21 +25,37 @@ class DebugMetadata:
FUNCTION = "process_metadata" FUNCTION = "process_metadata"
def process_metadata(self, images, id): def process_metadata(self, images, id):
"""
Process metadata from the execution context and return it for UI display.
The metadata is returned via the 'ui' key in the return dict, which triggers
node.onExecuted on the frontend to update the JsonDisplayWidget.
Args:
images: Input images (required for execution flow)
id: Node's unique ID (hidden)
Returns:
Dict with 'result' (empty tuple) and 'ui' (metadata dict for widget display)
"""
try: try:
# Get the current execution context's metadata # Get the current execution context's metadata
from ..metadata_collector import get_metadata from ..metadata_collector import get_metadata
metadata = get_metadata() metadata = get_metadata()
# Use the MetadataProcessor to convert it to JSON string # Use the MetadataProcessor to convert it to dict
metadata_json = MetadataProcessor.to_json(metadata, id) metadata_dict = MetadataProcessor.to_dict(metadata, id)
# Send metadata to frontend for display return {
PromptServer.instance.send_sync("metadata_update", { "result": (),
"id": id, # ComfyUI expects ui values to be lists, wrap the dict in a list
"metadata": metadata_json "ui": {"metadata": [metadata_dict]},
}) }
except Exception as e: except Exception as e:
logger.error(f"Error processing metadata: {e}") logger.error(f"Error processing metadata: {e}")
return {
return () "result": (),
"ui": {"metadata": [{"error": str(e)}]},
}

87
py/nodes/lora_pool.py Normal file
View File

@@ -0,0 +1,87 @@
"""
LoRA Pool Node - Defines filter configuration for LoRA selection.
This node provides a visual filter editor that generates a LORA_POOL_CONFIG
object for use by downstream nodes (like LoRA Randomizer).
"""
import logging
logger = logging.getLogger(__name__)
class LoraPoolNode:
"""
A node that defines LoRA filter criteria through a Vue-based widget.
Outputs a LORA_POOL_CONFIG that can be consumed by:
- Frontend: LoRA Randomizer widget reads connected pool's widget value
- Backend: LoRA Randomizer receives config during workflow execution
"""
NAME = "Lora Pool (LoraManager)"
CATEGORY = "Lora Manager/randomizer"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pool_config": ("LORA_POOL_CONFIG", {}),
},
"hidden": {
# Hidden input to pass through unique node ID for frontend
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = ("POOL_CONFIG",)
RETURN_NAMES = ("POOL_CONFIG",)
FUNCTION = "process"
OUTPUT_NODE = False
def process(self, pool_config, unique_id=None):
"""
Pass through the pool configuration filters.
The config is generated entirely by the frontend widget.
This function validates and returns only the filters field.
Args:
pool_config: Dict containing filter criteria from widget
unique_id: Node's unique ID (hidden)
Returns:
Tuple containing the filters dict from pool_config
"""
# Validate required structure
if not isinstance(pool_config, dict):
logger.warning("Invalid pool_config type, using empty config")
pool_config = self._default_config()
# Ensure version field exists
if "version" not in pool_config:
pool_config["version"] = 1
# Extract filters field
filters = pool_config.get("filters", self._default_config()["filters"])
# Log for debugging
logger.debug(f"[LoraPoolNode] Processing filters: {filters}")
return (filters,)
@staticmethod
def _default_config():
"""Return default empty configuration."""
return {
"version": 1,
"filters": {
"baseModels": [],
"tags": {"include": [], "exclude": []},
"folders": {"include": [], "exclude": []},
"favoritesOnly": False,
"license": {"noCreditRequired": False, "allowSelling": False},
},
"preview": {"matchCount": 0, "lastUpdated": 0},
}

187
py/nodes/lora_randomizer.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Lora Randomizer Node - Randomly selects LoRAs from a pool with configurable settings.
This node accepts optional pool_config input to filter available LoRAs, and outputs
a LORA_STACK with randomly selected LoRAs. Returns UI updates with new random LoRAs
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__)
class LoraRandomizerNode:
"""Node that randomly selects LoRAs from a pool"""
NAME = "Lora Randomizer (LoraManager)"
CATEGORY = "Lora Manager/randomizer"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"randomizer_config": ("RANDOMIZER_CONFIG", {}),
"loras": ("LORAS", {}),
},
"optional": {
"pool_config": ("POOL_CONFIG", {}),
},
}
RETURN_TYPES = ("LORA_STACK",)
RETURN_NAMES = ("LORA_STACK",)
FUNCTION = "randomize"
OUTPUT_NODE = False
def _preprocess_loras_input(self, loras):
"""
Preprocess loras input to handle different widget formats.
Args:
loras: Input from widget, either:
- List of LoRA dicts (expected format)
- Dict with '__value__' key containing the list
Returns:
List of LoRA dicts
"""
if isinstance(loras, dict) and "__value__" in loras:
return loras["__value__"]
return loras
async def randomize(self, randomizer_config, loras, pool_config=None):
"""
Randomize LoRAs based on configuration and pool filters.
Args:
randomizer_config: Dict with randomizer settings (count, strength ranges, roll_mode)
loras: List of LoRA dicts from LORAS widget (includes locked state)
pool_config: Optional config from LoRA Pool node for filtering
Returns:
Dictionary with 'result' (LORA_STACK tuple) and 'ui' (for widget display)
"""
from ..services.service_registry import ServiceRegistry
loras = self._preprocess_loras_input(loras)
roll_mode = randomizer_config.get("roll_mode", "always")
logger.debug(f"[LoraRandomizerNode] roll_mode: {roll_mode}")
if roll_mode == "fixed":
ui_loras = loras
else:
scanner = await ServiceRegistry.get_lora_scanner()
ui_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config
)
print("pool config", pool_config)
execution_stack = self._build_execution_stack_from_input(loras)
return {
"result": (execution_stack,),
"ui": {"loras": ui_loras, "last_used": loras},
}
def _build_execution_stack_from_input(self, loras):
"""
Build LORA_STACK tuple from input loras list for execution.
Args:
loras: List of LoRA dicts with name, strength, clipStrength, active
Returns:
List of tuples (lora_path, model_strength, clip_strength)
"""
lora_stack = []
for lora in loras:
if not lora.get("active", False):
continue
# Get file path
lora_path, trigger_words = get_lora_info(lora["name"])
if not lora_path:
logger.warning(
f"[LoraRandomizerNode] Could not find path for LoRA: {lora['name']}"
)
continue
# Normalize path separators
lora_path = lora_path.replace("/", os.sep)
# Extract strengths (convert to float to prevent string subtraction errors)
model_strength = float(lora.get("strength", 1.0))
clip_strength = float(lora.get("clipStrength", model_strength))
lora_stack.append((lora_path, model_strength, clip_strength))
return lora_stack
async def _generate_random_loras_for_ui(
self, scanner, randomizer_config, input_loras, pool_config=None
):
"""
Generate new random loras for UI display.
Args:
scanner: LoraScanner instance
randomizer_config: Dict with randomizer settings
input_loras: Current input loras (for extracting locked loras)
pool_config: Optional pool filters
Returns:
List of LoRA dicts for UI display
"""
from ..services.lora_service import LoraService
# Parse randomizer settings (convert numeric values to float to prevent type errors)
count_mode = randomizer_config.get("count_mode", "range")
count_fixed = int(randomizer_config.get("count_fixed", 5))
count_min = int(randomizer_config.get("count_min", 3))
count_max = int(randomizer_config.get("count_max", 7))
model_strength_min = float(randomizer_config.get("model_strength_min", 0.0))
model_strength_max = float(randomizer_config.get("model_strength_max", 1.0))
use_same_clip_strength = randomizer_config.get("use_same_clip_strength", True)
clip_strength_min = float(randomizer_config.get("clip_strength_min", 0.0))
clip_strength_max = float(randomizer_config.get("clip_strength_max", 1.0))
use_recommended_strength = randomizer_config.get(
"use_recommended_strength", False
)
recommended_strength_scale_min = float(
randomizer_config.get("recommended_strength_scale_min", 0.5)
)
recommended_strength_scale_max = float(
randomizer_config.get("recommended_strength_scale_max", 1.0)
)
# Extract locked LoRAs from input
locked_loras = [lora for lora in input_loras if lora.get("locked", False)]
# Use LoraService to generate random LoRAs
lora_service = LoraService(scanner)
result_loras = await lora_service.get_random_loras(
count=count_fixed,
model_strength_min=model_strength_min,
model_strength_max=model_strength_max,
use_same_clip_strength=use_same_clip_strength,
clip_strength_min=clip_strength_min,
clip_strength_max=clip_strength_max,
locked_loras=locked_loras,
pool_config=pool_config,
count_mode=count_mode,
count_min=count_min,
count_max=count_max,
use_recommended_strength=use_recommended_strength,
recommended_strength_scale_min=recommended_strength_scale_min,
recommended_strength_scale_max=recommended_strength_scale_max,
)
return result_loras

View File

@@ -9,7 +9,7 @@ from ..metadata_collector import get_metadata
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
import piexif import piexif
class SaveImage: class SaveImageLM:
NAME = "Save Image (LoraManager)" NAME = "Save Image (LoraManager)"
CATEGORY = "Lora Manager/utils" CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Save images with embedded generation metadata in compatible format" DESCRIPTION = "Save images with embedded generation metadata in compatible format"

View File

@@ -72,103 +72,81 @@ class TriggerWordToggle:
# Convert to list if it's a JSON string # Convert to list if it's a JSON string
if isinstance(trigger_data, str): if isinstance(trigger_data, str):
trigger_data = json.loads(trigger_data) trigger_data = json.loads(trigger_data)
# Create dictionaries to track active state of words or groups if isinstance(trigger_data, list):
# Also track strength values for each trigger word if group_mode:
active_state = {}
strength_map = {}
for item in trigger_data:
text = item['text']
active = item.get('active', False)
# Extract strength if it's in the format "(word:strength)"
strength_match = re.match(r'\((.+):([\d.]+)\)', text)
if strength_match:
original_word = strength_match.group(1).strip()
strength = float(strength_match.group(2))
active_state[original_word] = active
if allow_strength_adjustment: if allow_strength_adjustment:
strength_map[original_word] = strength parsed_items = [
else: self._parse_trigger_item(item, allow_strength_adjustment)
active_state[text.strip()] = active for item in trigger_data
]
if group_mode: filtered_groups = [
if isinstance(trigger_data, list):
filtered_groups = []
for item in trigger_data:
text = (item.get('text') or "").strip()
if not text:
continue
if item.get('active', False):
filtered_groups.append(text)
if filtered_groups:
filtered_triggers = ', '.join(filtered_groups)
else:
filtered_triggers = ""
else:
# Split by two or more consecutive commas to get groups
groups = re.split(r',{2,}', trigger_words)
# Remove leading/trailing whitespace from each group
groups = [group.strip() for group in groups]
# Process groups: keep those not in toggle_trigger_words or those that are active
filtered_groups = []
for group in groups:
# Check if this group contains any words that are in the active_state
group_words = [word.strip() for word in group.split(',')]
active_group_words = []
for word in group_words:
word_comparison = re.sub(r'\((.+):([\d.]+)\)', r'\1', word).strip()
if word_comparison not in active_state or active_state[word_comparison]:
active_group_words.append(
self._format_word_output(
word_comparison,
strength_map,
allow_strength_adjustment,
)
)
if active_group_words:
filtered_groups.append(', '.join(active_group_words))
if filtered_groups:
filtered_triggers = ', '.join(filtered_groups)
else:
filtered_triggers = ""
else:
# Normal mode: split by commas and treat each word as a separate tag
original_words = [word.strip() for word in trigger_words.split(',')]
# Filter out empty strings
original_words = [word for word in original_words if word]
filtered_words = []
for word in original_words:
# Remove any existing strength formatting for comparison
word_comparison = re.sub(r'\((.+):([\d.]+)\)', r'\1', word).strip()
if word_comparison not in active_state or active_state[word_comparison]:
filtered_words.append(
self._format_word_output( self._format_word_output(
word_comparison, item["text"],
strength_map, item["strength"],
allow_strength_adjustment, allow_strength_adjustment,
) )
) for item in parsed_items
if item["text"] and item["active"]
if filtered_words: ]
filtered_triggers = ', '.join(filtered_words) else:
filtered_groups = [
(item.get('text') or "").strip()
for item in trigger_data
if (item.get('text') or "").strip() and item.get('active', False)
]
filtered_triggers = ', '.join(filtered_groups) if filtered_groups else ""
else: else:
filtered_triggers = "" parsed_items = [
self._parse_trigger_item(item, allow_strength_adjustment)
for item in trigger_data
]
filtered_words = [
self._format_word_output(
item["text"],
item["strength"],
allow_strength_adjustment,
)
for item in parsed_items
if item["text"] and item["active"]
]
filtered_triggers = ', '.join(filtered_words) if filtered_words else ""
else:
# Fallback to original message parsing if data is not in the expected list format
if group_mode:
groups = re.split(r',{2,}', trigger_words)
groups = [group.strip() for group in groups if group.strip()]
filtered_triggers = ', '.join(groups)
else:
words = [word.strip() for word in trigger_words.split(',') if word.strip()]
filtered_triggers = ', '.join(words)
except Exception as e: except Exception as e:
logger.error(f"Error processing trigger words: {e}") logger.error(f"Error processing trigger words: {e}")
return (filtered_triggers,) return (filtered_triggers,)
def _format_word_output(self, base_word, strength_map, allow_strength_adjustment): def _parse_trigger_item(self, item, allow_strength_adjustment):
if allow_strength_adjustment and base_word in strength_map: text = (item.get('text') or "").strip()
return f"({base_word}:{strength_map[base_word]:.2f})" active = bool(item.get('active', False))
strength = item.get('strength')
strength_match = re.match(r'^\((.+):([\d.]+)\)$', text)
if strength_match:
text = strength_match.group(1).strip()
if strength is None:
try:
strength = float(strength_match.group(2))
except ValueError:
strength = None
return {
"text": text,
"active": active,
"strength": strength if allow_strength_adjustment else None,
}
def _format_word_output(self, base_word, strength, allow_strength_adjustment):
if allow_strength_adjustment and strength is not None:
return f"({base_word}:{strength:.2f})"
return base_word return base_word

View File

@@ -36,6 +36,7 @@ any_type = AnyType("*")
import os import os
import logging import logging
import copy import copy
import sys
import folder_paths import folder_paths
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -98,25 +99,37 @@ def to_diffusers(input_lora):
def nunchaku_load_lora(model, lora_name, lora_strength): def nunchaku_load_lora(model, lora_name, lora_strength):
"""Load a Flux LoRA for Nunchaku model""" """Load a Flux LoRA for Nunchaku model"""
model_wrapper = model.model.diffusion_model
transformer = model_wrapper.model
# Save the transformer temporarily
model_wrapper.model = None
ret_model = copy.deepcopy(model) # copy everything except the model
ret_model_wrapper = ret_model.model.diffusion_model
# Restore the model and set it for the copy
model_wrapper.model = transformer
ret_model_wrapper.model = transformer
# Get full path to the LoRA file. Allow both direct paths and registered LoRA names. # Get full path to the LoRA file. Allow both direct paths and registered LoRA names.
lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name) lora_path = lora_name if os.path.isfile(lora_name) else folder_paths.get_full_path("loras", lora_name)
if not lora_path or not os.path.isfile(lora_path): if not lora_path or not os.path.isfile(lora_path):
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name) logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
return model return model
ret_model_wrapper.loras.append((lora_path, lora_strength)) model_wrapper = model.model.diffusion_model
# Try to find copy_with_ctx in the same module as ComfyFluxWrapper
module_name = model_wrapper.__class__.__module__
module = sys.modules.get(module_name)
copy_with_ctx = getattr(module, "copy_with_ctx", None)
if copy_with_ctx is not None:
# New logic using copy_with_ctx from ComfyUI-nunchaku 1.1.0+
ret_model_wrapper, ret_model = copy_with_ctx(model_wrapper)
ret_model_wrapper.loras = [*model_wrapper.loras, (lora_path, lora_strength)]
else:
# Fallback to legacy logic
logger.warning("Please upgrade ComfyUI-nunchaku to 1.1.0 or above for better LoRA support. Falling back to legacy loading logic.")
transformer = model_wrapper.model
# Save the transformer temporarily
model_wrapper.model = None
ret_model = copy.deepcopy(model) # copy everything except the model
ret_model_wrapper = ret_model.model.diffusion_model
# Restore the model and set it for the copy
model_wrapper.model = transformer
ret_model_wrapper.model = transformer
ret_model_wrapper.loras.append((lora_path, lora_strength))
# Convert the LoRA to diffusers format # Convert the LoRA to diffusers format
sd = to_diffusers(lora_path) sd = to_diffusers(lora_path)

View File

@@ -5,7 +5,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class WanVideoLoraSelect: class WanVideoLoraSelectLM:
NAME = "WanVideo Lora Select (LoraManager)" NAME = "WanVideo Lora Select (LoraManager)"
CATEGORY = "Lora Manager/stackers" CATEGORY = "Lora Manager/stackers"

View File

@@ -37,7 +37,8 @@ class RecipeMetadataParser(ABC):
""" """
pass pass
async def populate_lora_from_civitai(self, lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]], @staticmethod
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]: recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
""" """
Populate a lora entry with information from Civitai API response Populate a lora entry with information from Civitai API response
@@ -148,8 +149,9 @@ class RecipeMetadataParser(ABC):
logger.error(f"Error populating lora from Civitai info: {e}") logger.error(f"Error populating lora from Civitai info: {e}")
return lora_entry return lora_entry
async def populate_checkpoint_from_civitai(self, checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]: @staticmethod
async def populate_checkpoint_from_civitai(checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
""" """
Populate checkpoint information from Civitai API response Populate checkpoint information from Civitai API response
@@ -187,6 +189,7 @@ class RecipeMetadataParser(ABC):
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '') checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0)) checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
checkpoint['id'] = civitai_data.get('id', 0)
if 'files' in civitai_data: if 'files' in civitai_data:
model_file = next( model_file = next(

216
py/recipes/enrichment.py Normal file
View File

@@ -0,0 +1,216 @@
import logging
import json
import re
import os
from typing import Any, Dict, Optional
from .merger import GenParamsMerger
from .base import RecipeMetadataParser
from ..services.metadata_service import get_default_metadata_provider
logger = logging.getLogger(__name__)
class RecipeEnricher:
"""Service to enrich recipe metadata from multiple sources (Civitai, Embedded, User)."""
@staticmethod
async def enrich_recipe(
recipe: Dict[str, Any],
civitai_client: Any,
request_params: Optional[Dict[str, Any]] = None
) -> bool:
"""
Enrich a recipe dictionary in-place with metadata from Civitai and embedded params.
Args:
recipe: The recipe dictionary to enrich. Must have 'gen_params' initialized.
civitai_client: Authenticated Civitai client instance.
request_params: (Optional) Parameters from a user request (e.g. import).
Returns:
bool: True if the recipe was modified, False otherwise.
"""
updated = False
gen_params = recipe.get("gen_params", {})
# 1. Fetch Civitai Info if available
civitai_meta = None
model_version_id = None
source_url = recipe.get("source_url") or recipe.get("source_path", "")
# Check if it's a Civitai image URL
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
if image_id_match:
image_id = image_id_match.group(1)
try:
image_info = await civitai_client.get_image_info(image_id)
if image_info:
# Handle nested meta often found in Civitai API responses
raw_meta = image_info.get("meta")
if isinstance(raw_meta, dict):
if "meta" in raw_meta and isinstance(raw_meta["meta"], dict):
civitai_meta = raw_meta["meta"]
else:
civitai_meta = raw_meta
model_version_id = image_info.get("modelVersionId")
# If not at top level, check resources in meta
if not model_version_id and civitai_meta:
resources = civitai_meta.get("civitaiResources", [])
for res in resources:
if res.get("type") == "checkpoint":
model_version_id = res.get("modelVersionId")
break
except Exception as e:
logger.warning(f"Failed to fetch Civitai image info: {e}")
# 2. Merge Parameters
# Priority: request_params > civitai_meta > embedded (existing gen_params)
new_gen_params = GenParamsMerger.merge(
request_params=request_params,
civitai_meta=civitai_meta,
embedded_metadata=gen_params
)
if new_gen_params != gen_params:
recipe["gen_params"] = new_gen_params
updated = True
# 3. Checkpoint Enrichment
# If we have a checkpoint entry, or we can find one
# Use 'id' (from Civitai version) as a marker that it's been enriched
checkpoint_entry = recipe.get("checkpoint")
has_full_checkpoint = checkpoint_entry and checkpoint_entry.get("name") and checkpoint_entry.get("id")
if not has_full_checkpoint:
# Helper to look up values in priority order
def start_lookup(keys):
for source in [request_params, civitai_meta, gen_params]:
if source:
if isinstance(keys, list):
for k in keys:
if k in source: return source[k]
else:
if keys in source: return source[keys]
return None
target_version_id = model_version_id or start_lookup("modelVersionId")
# Also check existing checkpoint entry
if not target_version_id and checkpoint_entry:
target_version_id = checkpoint_entry.get("modelVersionId") or checkpoint_entry.get("id")
# Check for version ID in resources (which might be a string in gen_params)
if not target_version_id:
# Look in all sources for "Civitai resources"
resources_val = start_lookup(["Civitai resources", "civitai_resources", "resources"])
if resources_val:
target_version_id = RecipeEnricher._extract_version_id_from_resources({"Civitai resources": resources_val})
target_hash = start_lookup(["Model hash", "checkpoint_hash", "hashes"])
if not target_hash and checkpoint_entry:
target_hash = checkpoint_entry.get("hash") or checkpoint_entry.get("model_hash")
# Look for 'Model' which sometimes is the hash or name
model_val = start_lookup("Model")
# Look for Checkpoint name fallback
checkpoint_val = checkpoint_entry.get("name") if checkpoint_entry else None
if not checkpoint_val:
checkpoint_val = start_lookup(["Checkpoint", "checkpoint"])
checkpoint_updated = await RecipeEnricher._resolve_and_populate_checkpoint(
recipe, target_version_id, target_hash, model_val, checkpoint_val
)
if checkpoint_updated:
updated = True
else:
# Checkpoint exists, no need to sync to gen_params anymore.
pass
# base_model resolution moved to _resolve_and_populate_checkpoint to support strict formatting
return updated
@staticmethod
def _extract_version_id_from_resources(gen_params: Dict[str, Any]) -> Optional[Any]:
"""Try to find modelVersionId in Civitai resources parameter."""
civitai_resources_raw = gen_params.get("Civitai resources")
if not civitai_resources_raw:
return None
resources_list = None
if isinstance(civitai_resources_raw, str):
try:
resources_list = json.loads(civitai_resources_raw)
except Exception:
pass
elif isinstance(civitai_resources_raw, list):
resources_list = civitai_resources_raw
if isinstance(resources_list, list):
for res in resources_list:
if res.get("type") == "checkpoint":
return res.get("modelVersionId")
return None
@staticmethod
async def _resolve_and_populate_checkpoint(
recipe: Dict[str, Any],
target_version_id: Optional[Any],
target_hash: Optional[str],
model_val: Optional[str],
checkpoint_val: Optional[str]
) -> bool:
"""Find checkpoint metadata and populate it in the recipe."""
metadata_provider = await get_default_metadata_provider()
civitai_info = None
if target_version_id:
civitai_info = await metadata_provider.get_model_version_info(str(target_version_id))
elif target_hash:
civitai_info = await metadata_provider.get_model_by_hash(target_hash)
else:
# Look for 'Model' which sometimes is the hash or name
if model_val and len(model_val) == 10: # Likely a short hash
civitai_info = await metadata_provider.get_model_by_hash(model_val)
if civitai_info and not (isinstance(civitai_info, tuple) and civitai_info[1] == "Model not found"):
# If we already have a partial checkpoint, use it as base
existing_cp = recipe.get("checkpoint")
if existing_cp is None:
existing_cp = {}
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
# 1. First, resolve base_model using full data before we format it away
current_base_model = recipe.get("base_model")
resolved_base_model = checkpoint_data.get("baseModel")
if resolved_base_model:
# Update if empty OR if it matches our generic prefix but is less specific
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
if is_generic and resolved_base_model != current_base_model:
recipe["base_model"] = resolved_base_model
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName
formatted_checkpoint = {
"type": "checkpoint",
"modelId": checkpoint_data.get("modelId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name']
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name']
}
# Remove None values
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
return True
else:
# Fallback to name extraction if we don't already have one
existing_cp = recipe.get("checkpoint")
if not existing_cp or not existing_cp.get("modelName"):
cp_name = checkpoint_val
if cp_name:
recipe["checkpoint"] = {
"type": "checkpoint",
"modelName": cp_name
}
return True
return False

98
py/recipes/merger.py Normal file
View File

@@ -0,0 +1,98 @@
from typing import Any, Dict, Optional
import logging
logger = logging.getLogger(__name__)
class GenParamsMerger:
"""Utility to merge generation parameters from multiple sources with priority."""
BLACKLISTED_KEYS = {
"id", "url", "userId", "username", "createdAt", "updatedAt", "hash", "meta",
"draft", "extra", "width", "height", "process", "quantity", "workflow",
"baseModel", "resources", "disablePoi", "aspectRatio", "Created Date",
"experimental", "civitaiResources", "civitai_resources", "Civitai resources",
"modelVersionId", "modelId", "hashes", "Model", "Model hash", "checkpoint_hash",
"checkpoint", "checksum", "model_checksum"
}
NORMALIZATION_MAPPING = {
# Civitai specific
"cfgScale": "cfg_scale",
"clipSkip": "clip_skip",
"negativePrompt": "negative_prompt",
# Case variations
"Sampler": "sampler",
"Steps": "steps",
"Seed": "seed",
"Size": "size",
"Prompt": "prompt",
"Negative prompt": "negative_prompt",
"Cfg scale": "cfg_scale",
"Clip skip": "clip_skip",
"Denoising strength": "denoising_strength",
}
@staticmethod
def merge(
request_params: Optional[Dict[str, Any]] = None,
civitai_meta: Optional[Dict[str, Any]] = None,
embedded_metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Merge generation parameters from three sources.
Priority: request_params > civitai_meta > embedded_metadata
Args:
request_params: Params provided directly in the import request
civitai_meta: Params from Civitai Image API 'meta' field
embedded_metadata: Params extracted from image EXIF/embedded metadata
Returns:
Merged parameters dictionary
"""
result = {}
# 1. Start with embedded metadata (lowest priority)
if embedded_metadata:
# If it's a full recipe metadata, we use its gen_params
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
GenParamsMerger._update_normalized(result, embedded_metadata["gen_params"])
else:
# Otherwise assume the dict itself contains gen_params
GenParamsMerger._update_normalized(result, embedded_metadata)
# 2. Layer Civitai meta (medium priority)
if civitai_meta:
GenParamsMerger._update_normalized(result, civitai_meta)
# 3. Layer request params (highest priority)
if request_params:
GenParamsMerger._update_normalized(result, request_params)
# Filter out blacklisted keys and also the original camelCase keys if they were normalized
final_result = {}
for k, v in result.items():
if k in GenParamsMerger.BLACKLISTED_KEYS:
continue
if k in GenParamsMerger.NORMALIZATION_MAPPING:
continue
final_result[k] = v
return final_result
@staticmethod
def _update_normalized(target: Dict[str, Any], source: Dict[str, Any]) -> None:
"""Update target dict with normalized keys from source."""
for k, v in source.items():
normalized_key = GenParamsMerger.NORMALIZATION_MAPPING.get(k, k)
target[normalized_key] = v
# Also keep the original key for now if it's not the same,
# so we can filter at the end or avoid losing it if it wasn't supposed to be renamed?
# Actually, if we rename it, we should probably NOT keep both in 'target'
# because we want to filter them out at the end anyway.
if normalized_key != k:
# If we are overwriting an existing snake_case key with a camelCase one's value,
# that's fine because of the priority order of calls to _update_normalized.
pass
target[k] = v

View File

@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
# Find all LoraLoader nodes # Find all LoraLoader nodes
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'} lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
if not lora_nodes:
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
# Process each LoraLoader node # Process each LoraLoader node
for node_id, node in lora_nodes.items(): for node_id, node in lora_nodes.items():
if 'inputs' not in node or 'lora_name' not in node['inputs']: if 'inputs' not in node or 'lora_name' not in node['inputs']:

View File

@@ -120,7 +120,7 @@ class BaseModelRoutes(ABC):
self.service = service self.service = service
self.model_type = service.model_type self.model_type = service.model_type
self.model_file_service = ModelFileService(service.scanner, service.model_type) self.model_file_service = ModelFileService(service.scanner, service.model_type)
self.model_move_service = ModelMoveService(service.scanner) self.model_move_service = ModelMoveService(service.scanner, service.model_type)
self.model_lifecycle_service = ModelLifecycleService( self.model_lifecycle_service = ModelLifecycleService(
scanner=service.scanner, scanner=service.scanner,
metadata_manager=MetadataManager, metadata_manager=MetadataManager,
@@ -270,7 +270,7 @@ class BaseModelRoutes(ABC):
def _ensure_move_service(self) -> ModelMoveService: def _ensure_move_service(self) -> ModelMoveService:
if self.model_move_service is None: if self.model_move_service is None:
service = self._ensure_service() service = self._ensure_service()
self.model_move_service = ModelMoveService(service.scanner) self.model_move_service = ModelMoveService(service.scanner, service.model_type)
return self.model_move_service return self.model_move_service
def _ensure_lifecycle_service(self) -> ModelLifecycleService: def _ensure_lifecycle_service(self) -> ModelLifecycleService:

View File

@@ -79,26 +79,8 @@ class BaseRecipeRoutes:
return return
app.on_startup.append(self.attach_dependencies) app.on_startup.append(self.attach_dependencies)
app.on_startup.append(self.prewarm_cache)
self._startup_hooks_registered = True self._startup_hooks_registered = True
async def prewarm_cache(self, app: web.Application | None = None) -> None:
"""Pre-load recipe and LoRA caches on startup."""
try:
await self.attach_dependencies(app)
if self.lora_scanner is not None:
await self.lora_scanner.get_cached_data()
hash_index = getattr(self.lora_scanner, "_hash_index", None)
if hash_index is not None and hasattr(hash_index, "_hash_to_path"):
_ = len(hash_index._hash_to_path)
if self.recipe_scanner is not None:
await self.recipe_scanner.get_cached_data(force_refresh=True)
except Exception as exc:
logger.error("Error pre-warming recipe cache: %s", exc, exc_info=True)
def to_route_mapping(self) -> Mapping[str, Callable]: def to_route_mapping(self) -> Mapping[str, Callable]:
"""Return a mapping of handler name to coroutine for registrar binding.""" """Return a mapping of handler name to coroutine for registrar binding."""

View File

@@ -29,6 +29,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"), RouteDefinition("POST", "/api/lm/delete-example-image", "delete_example_image"),
RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"), RouteDefinition("POST", "/api/lm/force-download-example-images", "force_download_example_images"),
RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"), RouteDefinition("POST", "/api/lm/cleanup-example-image-folders", "cleanup_example_image_folders"),
RouteDefinition("POST", "/api/lm/example-images/set-nsfw-level", "set_example_image_nsfw_level"),
) )

View File

@@ -113,6 +113,9 @@ class ExampleImagesManagementHandler:
async def delete_example_image(self, request: web.Request) -> web.StreamResponse: async def delete_example_image(self, request: web.Request) -> web.StreamResponse:
return await self._processor.delete_custom_image(request) return await self._processor.delete_custom_image(request)
async def set_example_image_nsfw_level(self, request: web.Request) -> web.StreamResponse:
return await self._processor.set_example_image_nsfw_level(request)
async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse: async def cleanup_example_image_folders(self, request: web.Request) -> web.StreamResponse:
result = await self._cleanup_service.cleanup_example_image_folders() result = await self._cleanup_service.cleanup_example_image_folders()
@@ -160,6 +163,7 @@ class ExampleImagesHandlerSet:
"force_download_example_images": self.download.force_download_example_images, "force_download_example_images": self.download.force_download_example_images,
"import_example_images": self.management.import_example_images, "import_example_images": self.management.import_example_images,
"delete_example_image": self.management.delete_example_image, "delete_example_image": self.management.delete_example_image,
"set_example_image_nsfw_level": self.management.set_example_image_nsfw_level,
"cleanup_example_image_folders": self.management.cleanup_example_image_folders, "cleanup_example_image_folders": self.management.cleanup_example_image_folders,
"open_example_images_folder": self.files.open_example_images_folder, "open_example_images_folder": self.files.open_example_images_folder,
"get_example_image_files": self.files.get_example_image_files, "get_example_image_files": self.files.get_example_image_files,

View File

@@ -43,12 +43,55 @@ from ...utils.usage_stats import UsageStats
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _is_wsl() -> bool:
"""Check if running in WSL environment."""
try:
with open("/proc/version", "r") as f:
version_info = f.read().lower()
return "microsoft" in version_info or "wsl" in version_info
except (OSError, IOError):
return False
def _is_docker() -> bool:
"""Check if running in Docker container."""
dockerenv_exists = os.path.exists("/.dockerenv")
if dockerenv_exists:
return True
try:
with open("/proc/1/cgroup", "r") as f:
cgroup_content = f.read()
return (
"docker" in cgroup_content.lower()
or "kubepods" in cgroup_content.lower()
)
except (OSError, IOError):
return False
def _wsl_to_windows_path(wsl_path: str) -> str | None:
"""Convert WSL path to Windows path using wslpath."""
try:
result = subprocess.run(
["wslpath", "-w", wsl_path],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return None
class PromptServerProtocol(Protocol): class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the handlers.""" """Subset of PromptServer used by the handlers."""
instance: "PromptServerProtocol" instance: "PromptServerProtocol"
def send_sync(self, event: str, payload: dict) -> None: # pragma: no cover - protocol def send_sync(
self, event: str, payload: dict
) -> None: # pragma: no cover - protocol
... ...
@@ -63,7 +106,9 @@ class UsageStatsFactory(Protocol):
class MetadataProviderProtocol(Protocol): class MetadataProviderProtocol(Protocol):
async def get_model_versions(self, model_id: int) -> dict | None: # pragma: no cover - protocol async def get_model_versions(
self, model_id: int
) -> dict | None: # pragma: no cover - protocol
... ...
@@ -109,7 +154,11 @@ class NodeRegistry:
raw_widget_names: list | None = node.get("widget_names") raw_widget_names: list | None = node.get("widget_names")
if not isinstance(raw_widget_names, list): if not isinstance(raw_widget_names, list):
capability_widget_names = capabilities.get("widget_names") capability_widget_names = capabilities.get("widget_names")
raw_widget_names = capability_widget_names if isinstance(capability_widget_names, list) else None raw_widget_names = (
capability_widget_names
if isinstance(capability_widget_names, list)
else None
)
widget_names: list[str] = [] widget_names: list[str] = []
if isinstance(raw_widget_names, list): if isinstance(raw_widget_names, list):
@@ -175,6 +224,7 @@ class SettingsHandler:
"civitai_api_key", "civitai_api_key",
"default_lora_root", "default_lora_root",
"default_checkpoint_root", "default_checkpoint_root",
"default_unet_root",
"default_embedding_root", "default_embedding_root",
"base_model_path_mappings", "base_model_path_mappings",
"download_path_templates", "download_path_templates",
@@ -205,14 +255,25 @@ class SettingsHandler:
"auto_organize_exclusions", "auto_organize_exclusions",
) )
_PROXY_KEYS = {"proxy_enabled", "proxy_host", "proxy_port", "proxy_username", "proxy_password", "proxy_type"} _PROXY_KEYS = {
"proxy_enabled",
"proxy_host",
"proxy_port",
"proxy_username",
"proxy_password",
"proxy_type",
}
def __init__( def __init__(
self, self,
*, *,
settings_service=None, settings_service=None,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers, metadata_provider_updater: Callable[
downloader_factory: Callable[[], Awaitable[DownloaderProtocol]] = get_downloader, [], Awaitable[None]
] = update_metadata_providers,
downloader_factory: Callable[
[], Awaitable[DownloaderProtocol]
] = get_downloader,
) -> None: ) -> None:
self._settings = settings_service or get_settings_manager() self._settings = settings_service or get_settings_manager()
self._metadata_provider_updater = metadata_provider_updater self._metadata_provider_updater = metadata_provider_updater
@@ -248,11 +309,13 @@ class SettingsHandler:
response_data["settings_file"] = settings_file response_data["settings_file"] = settings_file
messages_getter = getattr(self._settings, "get_startup_messages", None) messages_getter = getattr(self._settings, "get_startup_messages", None)
messages = list(messages_getter()) if callable(messages_getter) else [] messages = list(messages_getter()) if callable(messages_getter) else []
return web.json_response({ return web.json_response(
"success": True, {
"settings": response_data, "success": True,
"messages": messages, "settings": response_data,
}) "messages": messages,
}
)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting settings: %s", exc, exc_info=True) logger.error("Error getting settings: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -271,8 +334,12 @@ class SettingsHandler:
try: try:
data = await request.json() data = await request.json()
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error parsing activate library request: %s", exc, exc_info=True) logger.error(
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400) "Error parsing activate library request: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": "Invalid JSON payload"}, status=400
)
library_name = data.get("library") or data.get("library_name") library_name = data.get("library") or data.get("library_name")
if not isinstance(library_name, str) or not library_name.strip(): if not isinstance(library_name, str) or not library_name.strip():
@@ -297,7 +364,9 @@ class SettingsHandler:
logger.debug("Attempted to activate unknown library '%s'", library_name) logger.debug("Attempted to activate unknown library '%s'", library_name)
return web.json_response({"success": False, "error": str(exc)}, status=404) return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error activating library '%s': %s", library_name, exc, exc_info=True) logger.error(
"Error activating library '%s': %s", library_name, exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def update_settings(self, request: web.Request) -> web.Response: async def update_settings(self, request: web.Request) -> web.Response:
@@ -312,9 +381,14 @@ class SettingsHandler:
if key == "example_images_path" and value: if key == "example_images_path" and value:
validation_error = self._validate_example_images_path(value) validation_error = self._validate_example_images_path(value)
if validation_error: if validation_error:
return web.json_response({"success": False, "error": validation_error}) return web.json_response(
{"success": False, "error": validation_error}
)
if value == "__DELETE__" and key in ("proxy_username", "proxy_password"): if value == "__DELETE__" and key in (
"proxy_username",
"proxy_password",
):
self._settings.delete(key) self._settings.delete(key)
else: else:
self._settings.set(key, value) self._settings.set(key, value)
@@ -356,7 +430,9 @@ class UsageStatsHandler:
data = await request.json() data = await request.json()
prompt_id = data.get("prompt_id") prompt_id = data.get("prompt_id")
if not prompt_id: if not prompt_id:
return web.json_response({"success": False, "error": "Missing prompt_id"}, status=400) return web.json_response(
{"success": False, "error": "Missing prompt_id"}, status=400
)
usage_stats = self._usage_stats_factory() usage_stats = self._usage_stats_factory()
await usage_stats.process_execution(prompt_id) await usage_stats.process_execution(prompt_id)
return web.json_response({"success": True}) return web.json_response({"success": True})
@@ -387,18 +463,24 @@ class LoraCodeHandler:
mode = data.get("mode", "append") mode = data.get("mode", "append")
if not lora_code: if not lora_code:
return web.json_response({"success": False, "error": "Missing lora_code parameter"}, status=400) return web.json_response(
{"success": False, "error": "Missing lora_code parameter"},
status=400,
)
results = [] results = []
if node_ids is None: if node_ids is None:
try: try:
self._prompt_server.instance.send_sync( self._prompt_server.instance.send_sync(
"lora_code_update", {"id": -1, "lora_code": lora_code, "mode": mode} "lora_code_update",
{"id": -1, "lora_code": lora_code, "mode": mode},
) )
results.append({"node_id": "broadcast", "success": True}) results.append({"node_id": "broadcast", "success": True})
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error broadcasting lora code: %s", exc) logger.error("Error broadcasting lora code: %s", exc)
results.append({"node_id": "broadcast", "success": False, "error": str(exc)}) results.append(
{"node_id": "broadcast", "success": False, "error": str(exc)}
)
else: else:
for entry in node_ids: for entry in node_ids:
node_identifier = entry node_identifier = entry
@@ -471,11 +553,19 @@ class TrainedWordsHandler:
try: try:
file_path = request.query.get("file_path") file_path = request.query.get("file_path")
if not file_path: if not file_path:
return web.json_response({"success": False, "error": "Missing file_path parameter"}, status=400) return web.json_response(
{"success": False, "error": "Missing file_path parameter"},
status=400,
)
if not os.path.exists(file_path): if not os.path.exists(file_path):
return web.json_response({"success": False, "error": "File not found"}, status=404) return web.json_response(
{"success": False, "error": "File not found"}, status=404
)
if not file_path.endswith(".safetensors"): if not file_path.endswith(".safetensors"):
return web.json_response({"success": False, "error": "File must be a safetensors file"}, status=400) return web.json_response(
{"success": False, "error": "File must be a safetensors file"},
status=400,
)
trained_words, class_tokens = await extract_trained_words(file_path) trained_words, class_tokens = await extract_trained_words(file_path)
return web.json_response( return web.json_response(
@@ -495,10 +585,15 @@ class ModelExampleFilesHandler:
try: try:
model_path = request.query.get("model_path") model_path = request.query.get("model_path")
if not model_path: if not model_path:
return web.json_response({"success": False, "error": "Missing model_path parameter"}, status=400) return web.json_response(
{"success": False, "error": "Missing model_path parameter"},
status=400,
)
model_dir = os.path.dirname(model_path) model_dir = os.path.dirname(model_path)
if not os.path.exists(model_dir): if not os.path.exists(model_dir):
return web.json_response({"success": False, "error": "Model directory not found"}, status=404) return web.json_response(
{"success": False, "error": "Model directory not found"}, status=404
)
base_name = os.path.splitext(os.path.basename(model_path))[0] base_name = os.path.splitext(os.path.basename(model_path))[0]
files = [] files = []
@@ -510,7 +605,10 @@ class ModelExampleFilesHandler:
if not os.path.isfile(file_full_path): if not os.path.isfile(file_full_path):
continue continue
file_ext = os.path.splitext(file)[1].lower() file_ext = os.path.splitext(file)[1].lower()
if file_ext not in SUPPORTED_MEDIA_EXTENSIONS["images"] and file_ext not in SUPPORTED_MEDIA_EXTENSIONS["videos"]: if (
file_ext not in SUPPORTED_MEDIA_EXTENSIONS["images"]
and file_ext not in SUPPORTED_MEDIA_EXTENSIONS["videos"]
):
continue continue
try: try:
index = int(file[len(pattern) :].split(".")[0]) index = int(file[len(pattern) :].split(".")[0])
@@ -545,7 +643,13 @@ class ServiceRegistryAdapter:
class ModelLibraryHandler: class ModelLibraryHandler:
def __init__(self, service_registry: ServiceRegistryAdapter, metadata_provider_factory: Callable[[], Awaitable[MetadataProviderProtocol | None]]) -> None: def __init__(
self,
service_registry: ServiceRegistryAdapter,
metadata_provider_factory: Callable[
[], Awaitable[MetadataProviderProtocol | None]
],
) -> None:
self._service_registry = service_registry self._service_registry = service_registry
self._metadata_provider_factory = metadata_provider_factory self._metadata_provider_factory = metadata_provider_factory
@@ -554,11 +658,17 @@ class ModelLibraryHandler:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
model_version_id_str = request.query.get("modelVersionId") model_version_id_str = request.query.get("modelVersionId")
if not model_id_str: if not model_id_str:
return web.json_response({"success": False, "error": "Missing required parameter: modelId"}, status=400) return web.json_response(
{"success": False, "error": "Missing required parameter: modelId"},
status=400,
)
try: try:
model_id = int(model_id_str) model_id = int(model_id_str)
except ValueError: except ValueError:
return web.json_response({"success": False, "error": "Parameter modelId must be an integer"}, status=400) return web.json_response(
{"success": False, "error": "Parameter modelId must be an integer"},
status=400,
)
lora_scanner = await self._service_registry.get_lora_scanner() lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner() checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
@@ -568,29 +678,55 @@ class ModelLibraryHandler:
try: try:
model_version_id = int(model_version_id_str) model_version_id = int(model_version_id_str)
except ValueError: except ValueError:
return web.json_response({"success": False, "error": "Parameter modelVersionId must be an integer"}, status=400) return web.json_response(
{
"success": False,
"error": "Parameter modelVersionId must be an integer",
},
status=400,
)
exists = False exists = False
model_type = None model_type = None
if await lora_scanner.check_model_version_exists(model_version_id): if await lora_scanner.check_model_version_exists(model_version_id):
exists = True exists = True
model_type = "lora" model_type = "lora"
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_version_id): elif (
checkpoint_scanner
and await checkpoint_scanner.check_model_version_exists(
model_version_id
)
):
exists = True exists = True
model_type = "checkpoint" model_type = "checkpoint"
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_version_id): elif (
embedding_scanner
and await embedding_scanner.check_model_version_exists(
model_version_id
)
):
exists = True exists = True
model_type = "embedding" model_type = "embedding"
return web.json_response({"success": True, "exists": exists, "modelType": model_type if exists else None}) return web.json_response(
{
"success": True,
"exists": exists,
"modelType": model_type if exists else None,
}
)
lora_versions = await lora_scanner.get_model_versions_by_id(model_id) lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
checkpoint_versions = [] checkpoint_versions = []
embedding_versions = [] embedding_versions = []
if not lora_versions and checkpoint_scanner: if not lora_versions and checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id) checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
model_id
)
if not lora_versions and not checkpoint_versions and embedding_scanner: if not lora_versions and not checkpoint_versions and embedding_scanner:
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id) embedding_versions = await embedding_scanner.get_model_versions_by_id(
model_id
)
model_type = None model_type = None
versions = [] versions = []
@@ -604,7 +740,9 @@ class ModelLibraryHandler:
model_type = "embedding" model_type = "embedding"
versions = embedding_versions versions = embedding_versions
return web.json_response({"success": True, "modelType": model_type, "versions": versions}) return web.json_response(
{"success": True, "modelType": model_type, "versions": versions}
)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to check model existence: %s", exc, exc_info=True) logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -613,22 +751,35 @@ class ModelLibraryHandler:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
if not model_id_str: if not model_id_str:
return web.json_response({"success": False, "error": "Missing required parameter: modelId"}, status=400) return web.json_response(
{"success": False, "error": "Missing required parameter: modelId"},
status=400,
)
try: try:
model_id = int(model_id_str) model_id = int(model_id_str)
except ValueError: except ValueError:
return web.json_response({"success": False, "error": "Parameter modelId must be an integer"}, status=400) return web.json_response(
{"success": False, "error": "Parameter modelId must be an integer"},
status=400,
)
metadata_provider = await self._metadata_provider_factory() metadata_provider = await self._metadata_provider_factory()
if not metadata_provider: if not metadata_provider:
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503) return web.json_response(
{"success": False, "error": "Metadata provider not available"},
status=503,
)
try: try:
response = await metadata_provider.get_model_versions(model_id) response = await metadata_provider.get_model_versions(model_id)
except ResourceNotFoundError: except ResourceNotFoundError:
return web.json_response({"success": False, "error": "Model not found"}, status=404) return web.json_response(
{"success": False, "error": "Model not found"}, status=404
)
if not response or not response.get("modelVersions"): if not response or not response.get("modelVersions"):
return web.json_response({"success": False, "error": "Model not found"}, status=404) return web.json_response(
{"success": False, "error": "Model not found"}, status=404
)
versions = response.get("modelVersions", []) versions = response.get("modelVersions", [])
model_name = response.get("name", "") model_name = response.get("name", "")
@@ -646,10 +797,22 @@ class ModelLibraryHandler:
scanner = await self._service_registry.get_embedding_scanner() scanner = await self._service_registry.get_embedding_scanner()
normalized_type = "embedding" normalized_type = "embedding"
else: else:
return web.json_response({"success": False, "error": f'Model type "{model_type}" is not supported'}, status=400) return web.json_response(
{
"success": False,
"error": f'Model type "{model_type}" is not supported',
},
status=400,
)
if not scanner: if not scanner:
return web.json_response({"success": False, "error": f'Scanner for type "{normalized_type}" is not available'}, status=503) return web.json_response(
{
"success": False,
"error": f'Scanner for type "{normalized_type}" is not available',
},
status=503,
)
local_versions = await scanner.get_model_versions_by_id(model_id) local_versions = await scanner.get_model_versions_by_id(model_id)
local_version_ids = {version["versionId"] for version in local_versions} local_version_ids = {version["versionId"] for version in local_versions}
@@ -661,7 +824,9 @@ class ModelLibraryHandler:
{ {
"id": version_id, "id": version_id,
"name": version.get("name", ""), "name": version.get("name", ""),
"thumbnailUrl": version.get("images")[0]["url"] if version.get("images") else None, "thumbnailUrl": version.get("images")[0]["url"]
if version.get("images")
else None,
"inLibrary": version_id in local_version_ids, "inLibrary": version_id in local_version_ids,
} }
) )
@@ -683,19 +848,34 @@ class ModelLibraryHandler:
try: try:
username = request.query.get("username") username = request.query.get("username")
if not username: if not username:
return web.json_response({"success": False, "error": "Missing required parameter: username"}, status=400) return web.json_response(
{"success": False, "error": "Missing required parameter: username"},
status=400,
)
metadata_provider = await self._metadata_provider_factory() metadata_provider = await self._metadata_provider_factory()
if not metadata_provider: if not metadata_provider:
return web.json_response({"success": False, "error": "Metadata provider not available"}, status=503) return web.json_response(
{"success": False, "error": "Metadata provider not available"},
status=503,
)
try: try:
models = await metadata_provider.get_user_models(username) models = await metadata_provider.get_user_models(username)
except NotImplementedError: except NotImplementedError:
return web.json_response({"success": False, "error": "Metadata provider does not support user model queries"}, status=501) return web.json_response(
{
"success": False,
"error": "Metadata provider does not support user model queries",
},
status=501,
)
if models is None: if models is None:
return web.json_response({"success": False, "error": "Failed to fetch user models"}, status=502) return web.json_response(
{"success": False, "error": "Failed to fetch user models"},
status=502,
)
if not isinstance(models, list): if not isinstance(models, list):
models = [] models = []
@@ -704,7 +884,9 @@ class ModelLibraryHandler:
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner() checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner() embedding_scanner = await self._service_registry.get_embedding_scanner()
normalized_allowed_types = {model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES} normalized_allowed_types = {
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
}
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES} lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
type_scanner_map: Dict[str, object | None] = { type_scanner_map: Dict[str, object | None] = {
@@ -724,7 +906,13 @@ class ModelLibraryHandler:
scanner = type_scanner_map.get(model_type) scanner = type_scanner_map.get(model_type)
if scanner is None: if scanner is None:
return web.json_response({"success": False, "error": f'Scanner for type "{model_type}" is not available'}, status=503) return web.json_response(
{
"success": False,
"error": f'Scanner for type "{model_type}" is not available',
},
status=503,
)
tags_value = model.get("tags") tags_value = model.get("tags")
tags = tags_value if isinstance(tags_value, list) else [] tags = tags_value if isinstance(tags_value, list) else []
@@ -759,7 +947,9 @@ class ModelLibraryHandler:
rewritten_url, _ = rewrite_preview_url(raw_url, media_type) rewritten_url, _ = rewrite_preview_url(raw_url, media_type)
thumbnail_url = rewritten_url thumbnail_url = rewritten_url
in_library = await scanner.check_model_version_exists(version_id_int) in_library = await scanner.check_model_version_exists(
version_id_int
)
versions.append( versions.append(
{ {
@@ -775,7 +965,9 @@ class ModelLibraryHandler:
} }
) )
return web.json_response({"success": True, "username": username, "versions": versions}) return web.json_response(
{"success": True, "username": username, "versions": versions}
)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get Civitai user models: %s", exc, exc_info=True) logger.error("Failed to get Civitai user models: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -785,9 +977,13 @@ class MetadataArchiveHandler:
def __init__( def __init__(
self, self,
*, *,
metadata_archive_manager_factory: Callable[[], Awaitable[MetadataArchiveManagerProtocol]] = get_metadata_archive_manager, metadata_archive_manager_factory: Callable[
[], Awaitable[MetadataArchiveManagerProtocol]
] = get_metadata_archive_manager,
settings_service=None, settings_service=None,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers, metadata_provider_updater: Callable[
[], Awaitable[None]
] = update_metadata_providers,
) -> None: ) -> None:
self._metadata_archive_manager_factory = metadata_archive_manager_factory self._metadata_archive_manager_factory = metadata_archive_manager_factory
self._settings = settings_service or get_settings_manager() self._settings = settings_service or get_settings_manager()
@@ -799,18 +995,37 @@ class MetadataArchiveHandler:
download_id = request.query.get("download_id") download_id = request.query.get("download_id")
def progress_callback(stage: str, message: str) -> None: def progress_callback(stage: str, message: str) -> None:
data = {"stage": stage, "message": message, "type": "metadata_archive_download"} data = {
"stage": stage,
"message": message,
"type": "metadata_archive_download",
}
if download_id: if download_id:
asyncio.create_task(ws_manager.broadcast_download_progress(download_id, data)) asyncio.create_task(
ws_manager.broadcast_download_progress(download_id, data)
)
else: else:
asyncio.create_task(ws_manager.broadcast(data)) asyncio.create_task(ws_manager.broadcast(data))
success = await archive_manager.download_and_extract_database(progress_callback) success = await archive_manager.download_and_extract_database(
progress_callback
)
if success: if success:
self._settings.set("enable_metadata_archive_db", True) self._settings.set("enable_metadata_archive_db", True)
await self._metadata_provider_updater() await self._metadata_provider_updater()
return web.json_response({"success": True, "message": "Metadata archive database downloaded and extracted successfully"}) return web.json_response(
return web.json_response({"success": False, "error": "Failed to download and extract metadata archive database"}, status=500) {
"success": True,
"message": "Metadata archive database downloaded and extracted successfully",
}
)
return web.json_response(
{
"success": False,
"error": "Failed to download and extract metadata archive database",
},
status=500,
)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error downloading metadata archive: %s", exc, exc_info=True) logger.error("Error downloading metadata archive: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -822,8 +1037,19 @@ class MetadataArchiveHandler:
if success: if success:
self._settings.set("enable_metadata_archive_db", False) self._settings.set("enable_metadata_archive_db", False)
await self._metadata_provider_updater() await self._metadata_provider_updater()
return web.json_response({"success": True, "message": "Metadata archive database removed successfully"}) return web.json_response(
return web.json_response({"success": False, "error": "Failed to remove metadata archive database"}, status=500) {
"success": True,
"message": "Metadata archive database removed successfully",
}
)
return web.json_response(
{
"success": False,
"error": "Failed to remove metadata archive database",
},
status=500,
)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error removing metadata archive: %s", exc, exc_info=True) logger.error("Error removing metadata archive: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -844,39 +1070,136 @@ class MetadataArchiveHandler:
"isAvailable": is_available, "isAvailable": is_available,
"isEnabled": is_enabled, "isEnabled": is_enabled,
"databaseSize": db_size, "databaseSize": db_size,
"databasePath": archive_manager.get_database_path() if is_available else None, "databasePath": archive_manager.get_database_path()
if is_available
else None,
} }
) )
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting metadata archive status: %s", exc, exc_info=True) logger.error(
"Error getting metadata archive status: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
class FileSystemHandler: class FileSystemHandler:
def __init__(self, settings_service=None) -> None:
self._settings = settings_service or get_settings_manager()
async def open_file_location(self, request: web.Request) -> web.Response: async def open_file_location(self, request: web.Request) -> web.Response:
try: try:
data = await request.json() data = await request.json()
file_path = data.get("file_path") file_path = data.get("file_path")
if not file_path: if not file_path:
return web.json_response({"success": False, "error": "Missing file_path parameter"}, status=400) return web.json_response(
{"success": False, "error": "Missing file_path parameter"},
status=400,
)
file_path = os.path.abspath(file_path) file_path = os.path.abspath(file_path)
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
return web.json_response({"success": False, "error": "File does not exist"}, status=404) return web.json_response(
{"success": False, "error": "File does not exist"}, status=404
)
if os.name == "nt": if os.name == "nt":
subprocess.Popen(["explorer", "/select,", file_path]) subprocess.Popen(["explorer", "/select,", file_path])
elif os.name == "posix": elif os.name == "posix":
if sys.platform == "darwin": if _is_docker():
return web.json_response(
{
"success": True,
"message": "Running in Docker: Path available for copying",
"path": file_path,
"mode": "clipboard",
}
)
elif _is_wsl():
windows_path = _wsl_to_windows_path(file_path)
if windows_path:
subprocess.Popen(["explorer.exe", "/select,", windows_path])
else:
logger.error(
"Failed to convert WSL path to Windows path: %s", file_path
)
return web.json_response(
{
"success": False,
"error": "Failed to open file location: path conversion error",
},
status=500,
)
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", file_path]) subprocess.Popen(["open", "-R", file_path])
else: else:
folder = os.path.dirname(file_path) folder = os.path.dirname(file_path)
subprocess.Popen(["xdg-open", folder]) subprocess.Popen(["xdg-open", folder])
return web.json_response({"success": True, "message": f"Opened folder and selected file: {file_path}"}) return web.json_response(
{
"success": True,
"message": f"Opened folder and selected file: {file_path}",
}
)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open file location: %s", exc, exc_info=True) logger.error("Failed to open file location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def open_settings_location(self, request: web.Request) -> web.Response:
try:
settings_file = getattr(self._settings, "settings_file", None)
if not settings_file:
return web.json_response(
{"success": False, "error": "Settings file not found"}, status=404
)
settings_file = os.path.abspath(settings_file)
if not os.path.isfile(settings_file):
return web.json_response(
{"success": False, "error": "Settings file does not exist"},
status=404,
)
if os.name == "nt":
subprocess.Popen(["explorer", "/select,", settings_file])
elif os.name == "posix":
if _is_docker():
return web.json_response(
{
"success": True,
"message": "Running in Docker: Path available for copying",
"path": settings_file,
"mode": "clipboard",
}
)
elif _is_wsl():
windows_path = _wsl_to_windows_path(settings_file)
if windows_path:
subprocess.Popen(["explorer.exe", "/select,", windows_path])
else:
logger.error(
"Failed to convert WSL path to Windows path: %s",
settings_file,
)
return web.json_response(
{
"success": False,
"error": "Failed to open settings location: path conversion error",
},
status=500,
)
elif sys.platform == "darwin":
subprocess.Popen(["open", "-R", settings_file])
else:
folder = os.path.dirname(settings_file)
subprocess.Popen(["xdg-open", folder])
return web.json_response(
{"success": True, "message": f"Opened settings folder: {settings_file}"}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to open settings location: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class NodeRegistryHandler: class NodeRegistryHandler:
def __init__( def __init__(
@@ -895,21 +1218,44 @@ class NodeRegistryHandler:
data = await request.json() data = await request.json()
nodes = data.get("nodes", []) nodes = data.get("nodes", [])
if not isinstance(nodes, list): if not isinstance(nodes, list):
return web.json_response({"success": False, "error": "nodes must be a list"}, status=400) return web.json_response(
{"success": False, "error": "nodes must be a list"}, status=400
)
for index, node in enumerate(nodes): for index, node in enumerate(nodes):
if not isinstance(node, dict): if not isinstance(node, dict):
return web.json_response({"success": False, "error": f"Node {index} must be an object"}, status=400) return web.json_response(
{"success": False, "error": f"Node {index} must be an object"},
status=400,
)
node_id = node.get("node_id") node_id = node.get("node_id")
if node_id is None: if node_id is None:
return web.json_response({"success": False, "error": f"Node {index} missing node_id parameter"}, status=400) return web.json_response(
{
"success": False,
"error": f"Node {index} missing node_id parameter",
},
status=400,
)
graph_id = node.get("graph_id") graph_id = node.get("graph_id")
if graph_id is None: if graph_id is None:
return web.json_response({"success": False, "error": f"Node {index} missing graph_id parameter"}, status=400) return web.json_response(
{
"success": False,
"error": f"Node {index} missing graph_id parameter",
},
status=400,
)
graph_name = node.get("graph_name") graph_name = node.get("graph_name")
try: try:
node["node_id"] = int(node_id) node["node_id"] = int(node_id)
except (TypeError, ValueError): except (TypeError, ValueError):
return web.json_response({"success": False, "error": f"Node {index} node_id must be an integer"}, status=400) return web.json_response(
{
"success": False,
"error": f"Node {index} node_id must be an integer",
},
status=400,
)
node["graph_id"] = str(graph_id) node["graph_id"] = str(graph_id)
if graph_name is None: if graph_name is None:
node["graph_name"] = None node["graph_name"] = None
@@ -919,7 +1265,12 @@ class NodeRegistryHandler:
node["graph_name"] = str(graph_name) node["graph_name"] = str(graph_name)
await self._node_registry.register_nodes(nodes) await self._node_registry.register_nodes(nodes)
return web.json_response({"success": True, "message": f"{len(nodes)} nodes registered successfully"}) return web.json_response(
{
"success": True,
"message": f"{len(nodes)} nodes registered successfully",
}
)
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to register nodes: %s", exc, exc_info=True) logger.error("Failed to register nodes: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -967,7 +1318,10 @@ class NodeRegistryHandler:
return web.json_response({"success": True, "data": registry_info}) return web.json_response({"success": True, "data": registry_info})
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error("Failed to get registry: %s", exc, exc_info=True) logger.error("Failed to get registry: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": "Internal Error", "message": str(exc)}, status=500) return web.json_response(
{"success": False, "error": "Internal Error", "message": str(exc)},
status=500,
)
async def update_node_widget(self, request: web.Request) -> web.Response: async def update_node_widget(self, request: web.Request) -> web.Response:
try: try:
@@ -977,10 +1331,15 @@ class NodeRegistryHandler:
node_ids = data.get("node_ids") node_ids = data.get("node_ids")
if not isinstance(widget_name, str) or not widget_name: if not isinstance(widget_name, str) or not widget_name:
return web.json_response({"success": False, "error": "Missing widget_name parameter"}, status=400) return web.json_response(
{"success": False, "error": "Missing widget_name parameter"},
status=400,
)
if not isinstance(value, str) or not value: if not isinstance(value, str) or not value:
return web.json_response({"success": False, "error": "Missing value parameter"}, status=400) return web.json_response(
{"success": False, "error": "Missing value parameter"}, status=400
)
if not isinstance(node_ids, list) or not node_ids: if not isinstance(node_ids, list) or not node_ids:
return web.json_response( return web.json_response(
@@ -1080,7 +1439,9 @@ class MiscHandlerSet:
self.metadata_archive = metadata_archive self.metadata_archive = metadata_archive
self.filesystem = filesystem self.filesystem = filesystem
def to_route_mapping(self) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]: def to_route_mapping(
self,
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
return { return {
"health_check": self.health.health_check, "health_check": self.health.health_check,
"get_settings": self.settings.get_settings, "get_settings": self.settings.get_settings,
@@ -1103,6 +1464,7 @@ class MiscHandlerSet:
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status, "get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,
"get_model_versions_status": self.model_library.get_model_versions_status, "get_model_versions_status": self.model_library.get_model_versions_status,
"open_file_location": self.filesystem.open_file_location, "open_file_location": self.filesystem.open_file_location,
"open_settings_location": self.filesystem.open_settings_location,
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import json
import logging import logging
import os import os
import re import re
import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
@@ -23,6 +24,11 @@ from ...services.recipes import (
RecipeValidationError, RecipeValidationError,
) )
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager
Logger = logging.Logger Logger = logging.Logger
EnsureDependenciesCallable = Callable[[], Awaitable[None]] EnsureDependenciesCallable = Callable[[], Awaitable[None]]
@@ -55,16 +61,26 @@ class RecipeHandlerSet:
"delete_recipe": self.management.delete_recipe, "delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_roots": self.query.get_roots,
"get_folders": self.query.get_folders,
"get_folder_tree": self.query.get_folder_tree,
"get_unified_folder_tree": self.query.get_unified_folder_tree,
"share_recipe": self.sharing.share_recipe, "share_recipe": self.sharing.share_recipe,
"download_shared_recipe": self.sharing.download_shared_recipe, "download_shared_recipe": self.sharing.download_shared_recipe,
"get_recipe_syntax": self.query.get_recipe_syntax, "get_recipe_syntax": self.query.get_recipe_syntax,
"update_recipe": self.management.update_recipe, "update_recipe": self.management.update_recipe,
"reconnect_lora": self.management.reconnect_lora, "reconnect_lora": self.management.reconnect_lora,
"find_duplicates": self.query.find_duplicates, "find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
"bulk_delete": self.management.bulk_delete, "bulk_delete": self.management.bulk_delete,
"save_recipe_from_widget": self.management.save_recipe_from_widget, "save_recipe_from_widget": self.management.save_recipe_from_widget,
"get_recipes_for_lora": self.query.get_recipes_for_lora, "get_recipes_for_lora": self.query.get_recipes_for_lora,
"scan_recipes": self.query.scan_recipes, "scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe,
"repair_recipes": self.management.repair_recipes,
"cancel_repair": self.management.cancel_repair,
"repair_recipe": self.management.repair_recipe,
"get_repair_progress": self.management.get_repair_progress,
} }
@@ -148,12 +164,15 @@ class RecipeListingHandler:
page_size = int(request.query.get("page_size", "20")) page_size = int(request.query.get("page_size", "20"))
sort_by = request.query.get("sort_by", "date") sort_by = request.query.get("sort_by", "date")
search = request.query.get("search") search = request.query.get("search")
folder = request.query.get("folder")
recursive = request.query.get("recursive", "true").lower() == "true"
search_options = { search_options = {
"title": request.query.get("search_title", "true").lower() == "true", "title": request.query.get("search_title", "true").lower() == "true",
"tags": request.query.get("search_tags", "true").lower() == "true", "tags": request.query.get("search_tags", "true").lower() == "true",
"lora_name": request.query.get("search_lora_name", "true").lower() == "true", "lora_name": request.query.get("search_lora_name", "true").lower() == "true",
"lora_model": request.query.get("search_lora_model", "true").lower() == "true", "lora_model": request.query.get("search_lora_model", "true").lower() == "true",
"prompt": request.query.get("search_prompt", "true").lower() == "true",
} }
filters: Dict[str, Any] = {} filters: Dict[str, Any] = {}
@@ -161,6 +180,9 @@ class RecipeListingHandler:
if base_models: if base_models:
filters["base_model"] = base_models.split(",") filters["base_model"] = base_models.split(",")
if request.query.get("favorite", "false").lower() == "true":
filters["favorite"] = True
tag_filters: Dict[str, str] = {} tag_filters: Dict[str, str] = {}
legacy_tags = request.query.get("tags") legacy_tags = request.query.get("tags")
if legacy_tags: if legacy_tags:
@@ -192,6 +214,8 @@ class RecipeListingHandler:
filters=filters, filters=filters,
search_options=search_options, search_options=search_options,
lora_hash=lora_hash, lora_hash=lora_hash,
folder=folder,
recursive=recursive,
) )
for item in result.get("items", []): for item in result.get("items", []):
@@ -298,6 +322,58 @@ class RecipeQueryHandler:
self._logger.error("Error retrieving base models: %s", exc, exc_info=True) self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_roots(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
return web.json_response({"success": True, "roots": roots})
except Exception as exc:
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_folders(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
folders = await recipe_scanner.get_folders()
return web.json_response({"success": True, "folders": folders})
except Exception as exc:
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc:
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc:
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipes_for_lora(self, request: web.Request) -> web.Response: async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -410,6 +486,7 @@ class RecipeManagementHandler:
analysis_service: RecipeAnalysisService, analysis_service: RecipeAnalysisService,
downloader_factory, downloader_factory,
civitai_client_getter: CivitaiClientGetter, civitai_client_getter: CivitaiClientGetter,
ws_manager=default_ws_manager,
) -> None: ) -> None:
self._ensure_dependencies_ready = ensure_dependencies_ready self._ensure_dependencies_ready = ensure_dependencies_ready
self._recipe_scanner_getter = recipe_scanner_getter self._recipe_scanner_getter = recipe_scanner_getter
@@ -418,6 +495,7 @@ class RecipeManagementHandler:
self._analysis_service = analysis_service self._analysis_service = analysis_service
self._downloader_factory = downloader_factory self._downloader_factory = downloader_factory
self._civitai_client_getter = civitai_client_getter self._civitai_client_getter = civitai_client_getter
self._ws_manager = ws_manager
async def save_recipe(self, request: web.Request) -> web.Response: async def save_recipe(self, request: web.Request) -> web.Response:
try: try:
@@ -436,6 +514,7 @@ class RecipeManagementHandler:
name=payload["name"], name=payload["name"],
tags=payload["tags"], tags=payload["tags"],
metadata=payload["metadata"], metadata=payload["metadata"],
extension=payload.get("extension"),
) )
return web.json_response(result.payload, status=result.status) return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc: except RecipeValidationError as exc:
@@ -444,17 +523,101 @@ class RecipeManagementHandler:
self._logger.error("Error saving recipe: %s", exc, exc_info=True) self._logger.error("Error saving recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def repair_recipes(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
# Check if already running
if self._ws_manager.is_recipe_repair_running():
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
recipe_scanner.reset_cancellation()
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_repair_progress(data)
# Run in background to avoid timeout
async def run_repair():
try:
await recipe_scanner.repair_all_recipes(
progress_callback=progress_callback
)
except Exception as e:
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
await self._ws_manager.broadcast_recipe_repair_progress({
"status": "error",
"error": str(e)
})
finally:
# Keep the final status for a while so the UI can see it
await asyncio.sleep(5)
# Don't cleanup if it was cancelled, let the UI see the cancelled state for a bit?
# Actually cleanup_recipe_repair_progress is fine as long as we waited enough.
self._ws_manager.cleanup_recipe_repair_progress()
asyncio.create_task(run_repair())
return web.json_response({"success": True, "message": "Recipe repair started"})
except Exception as exc:
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def cancel_repair(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
recipe_scanner.cancel_task()
return web.json_response({"success": True, "message": "Cancellation requested"})
except Exception as exc:
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def repair_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_repair_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_repair_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
except Exception as exc:
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def import_remote_recipe(self, request: web.Request) -> web.Response: async def import_remote_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable") raise RuntimeError("Recipe scanner unavailable")
# 1. Parse Parameters
params = request.rel_url.query params = request.rel_url.query
image_url = params.get("image_url") image_url = params.get("image_url")
name = params.get("name") name = params.get("name")
resources_raw = params.get("resources") resources_raw = params.get("resources")
if not image_url: if not image_url:
raise RecipeValidationError("Missing required field: image_url") raise RecipeValidationError("Missing required field: image_url")
if not name: if not name:
@@ -463,27 +626,93 @@ class RecipeManagementHandler:
raise RecipeValidationError("Missing required field: resources") raise RecipeValidationError("Missing required field: resources")
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw) checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
gen_params = self._parse_gen_params(params.get("gen_params")) gen_params_request = self._parse_gen_params(params.get("gen_params"))
# 2. Initial Metadata Construction
metadata: Dict[str, Any] = { metadata: Dict[str, Any] = {
"base_model": params.get("base_model", "") or "", "base_model": params.get("base_model", "") or "",
"loras": lora_entries, "loras": lora_entries,
"gen_params": gen_params_request or {},
"source_url": image_url
} }
source_path = params.get("source_path") source_path = params.get("source_path")
if source_path: if source_path:
metadata["source_path"] = source_path metadata["source_path"] = source_path
if gen_params is not None:
metadata["gen_params"] = gen_params # Checkpoint handling
if checkpoint_entry: if checkpoint_entry:
metadata["checkpoint"] = checkpoint_entry metadata["checkpoint"] = checkpoint_entry
gen_params_ref = metadata.setdefault("gen_params", {}) # Ensure checkpoint is also in gen_params for consistency if needed by enricher?
if "checkpoint" not in gen_params_ref: # Actually enricher looks at metadata['checkpoint'], so this is fine.
gen_params_ref["checkpoint"] = checkpoint_entry
base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry) # Try to resolve base model from checkpoint if not explicitly provided
if base_model_from_metadata: if not metadata["base_model"]:
metadata["base_model"] = base_model_from_metadata base_model_from_metadata = await self._resolve_base_model_from_checkpoint(checkpoint_entry)
if base_model_from_metadata:
metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags")) tags = self._parse_tags(params.get("tags"))
image_bytes = await self._download_image_bytes(image_url)
# 3. Download Image
image_bytes, extension, civitai_meta_from_download = await self._download_remote_media(image_url)
# 4. Extract Embedded Metadata
# Note: We still extract this here because Enricher currently expects 'gen_params' to already be populated
# with embedded data if we want it to merge it.
# However, logic in Enricher merges: request > civitai > embedded.
# So we should gather embedded params and put them into the recipe's gen_params (as initial state)
# OR pass them to enricher to handle?
# The interface of Enricher.enrich_recipe takes `recipe` (with gen_params) and `request_params`.
# So let's extract embedded and put it into recipe['gen_params'] but careful not to overwrite request params.
# Actually, `GenParamsMerger` which `Enricher` uses handles 3 layers.
# But `Enricher` interface is: recipe['gen_params'] (as embedded) + request_params + civitai (fetched internally).
# Wait, `Enricher` fetches Civitai info internally based on URL.
# `civitai_meta_from_download` is returned by `_download_remote_media` which might be useful if URL didn't have ID.
# Let's extract embedded metadata first
embedded_gen_params = {}
try:
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
if raw_embedded:
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
if parser:
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
if parsed_embedded and "gen_params" in parsed_embedded:
embedded_gen_params = parsed_embedded["gen_params"]
else:
embedded_gen_params = {"raw_metadata": raw_embedded}
finally:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
# Pre-populate gen_params with embedded data so Enricher treats it as the "base" layer
if embedded_gen_params:
# Merge embedded into existing gen_params (which currently only has request params if any)
# But wait, we want request params to override everything.
# So we should set recipe['gen_params'] = embedded, and pass request params to enricher.
metadata["gen_params"] = embedded_gen_params
# 5. Enrich with unified logic
# This will fetch Civitai info (if URL matches) and merge: request > civitai > embedded
civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe(
recipe=metadata,
civitai_client=civitai_client,
request_params=gen_params_request # Pass explicit request params here to override
)
# If we got civitai_meta from download but Enricher didn't fetch it (e.g. not a civitai URL or failed),
# we might want to manually merge it?
# But usually `import_remote_recipe` is used with Civitai URLs.
# For now, relying on Enricher's internal fetch is consistent with repair.
result = await self._persistence_service.save_recipe( result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
@@ -492,6 +721,7 @@ class RecipeManagementHandler:
name=name, name=name,
tags=tags, tags=tags,
metadata=metadata, metadata=metadata,
extension=extension,
) )
return web.json_response(result.payload, status=result.status) return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc: except RecipeValidationError as exc:
@@ -541,6 +771,64 @@ class RecipeManagementHandler:
self._logger.error("Error updating recipe: %s", exc, exc_info=True) self._logger.error("Error updating recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def move_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
recipe_id = data.get("recipe_id")
target_path = data.get("target_path")
if not recipe_id or not target_path:
return web.json_response(
{"success": False, "error": "recipe_id and target_path are required"}, status=400
)
result = await self._persistence_service.move_recipe(
recipe_scanner=recipe_scanner,
recipe_id=str(recipe_id),
target_path=str(target_path),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_recipes_bulk(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
recipe_ids = data.get("recipe_ids") or []
target_path = data.get("target_path")
if not recipe_ids or not target_path:
return web.json_response(
{"success": False, "error": "recipe_ids and target_path are required"}, status=400
)
result = await self._persistence_service.move_recipes_bulk(
recipe_scanner=recipe_scanner,
recipe_ids=recipe_ids,
target_path=str(target_path),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reconnect_lora(self, request: web.Request) -> web.Response: async def reconnect_lora(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -622,6 +910,7 @@ class RecipeManagementHandler:
name: Optional[str] = None name: Optional[str] = None
tags: list[str] = [] tags: list[str] = []
metadata: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None
extension: Optional[str] = None
while True: while True:
field = await reader.next() field = await reader.next()
@@ -652,6 +941,8 @@ class RecipeManagementHandler:
metadata = json.loads(metadata_text) metadata = json.loads(metadata_text)
except Exception: except Exception:
metadata = {} metadata = {}
elif field.name == "extension":
extension = await field.text()
return { return {
"image_bytes": image_bytes, "image_bytes": image_bytes,
@@ -659,6 +950,7 @@ class RecipeManagementHandler:
"name": name, "name": name,
"tags": tags, "tags": tags,
"metadata": metadata, "metadata": metadata,
"extension": extension,
} }
def _parse_tags(self, tag_text: Optional[str]) -> list[str]: def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
@@ -729,7 +1021,7 @@ class RecipeManagementHandler:
"exclude": False, "exclude": False,
} }
async def _download_image_bytes(self, image_url: str) -> bytes: async def _download_remote_media(self, image_url: str) -> tuple[bytes, str]:
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
downloader = await self._downloader_factory() downloader = await self._downloader_factory()
temp_path = None temp_path = None
@@ -744,15 +1036,31 @@ class RecipeManagementHandler:
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai") raise RecipeDownloadError("Failed to fetch image information from Civitai")
download_url = image_info.get("url")
if not download_url: media_url = image_info.get("url")
if not media_url:
raise RecipeDownloadError("No image URL found in Civitai response") raise RecipeDownloadError("No image URL found in Civitai response")
# Use optimized preview URLs if possible
media_type = image_info.get("type")
rewritten_url, _ = rewrite_preview_url(media_url, media_type=media_type)
if rewritten_url:
download_url = rewritten_url
else:
download_url = media_url
success, result = await downloader.download_file(download_url, temp_path, use_auth=False) success, result = await downloader.download_file(download_url, temp_path, use_auth=False)
if not success: if not success:
raise RecipeDownloadError(f"Failed to download image: {result}") raise RecipeDownloadError(f"Failed to download image: {result}")
# Extract extension from URL
url_path = download_url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower()
if not extension:
extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj: with open(temp_path, "rb") as file_obj:
return file_obj.read() return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
except RecipeDownloadError: except RecipeDownloadError:
raise raise
except RecipeValidationError: except RecipeValidationError:
@@ -766,6 +1074,7 @@ class RecipeManagementHandler:
except FileNotFoundError: except FileNotFoundError:
pass pass
def _safe_int(self, value: Any) -> int: def _safe_int(self, value: Any) -> int:
try: try:
return int(value) return int(value)

View File

@@ -12,14 +12,15 @@ from ..utils.utils import get_lora_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class LoraRoutes(BaseModelRoutes): class LoraRoutes(BaseModelRoutes):
"""LoRA-specific route controller""" """LoRA-specific route controller"""
def __init__(self): def __init__(self):
"""Initialize LoRA routes with LoRA service""" """Initialize LoRA routes with LoRA service"""
super().__init__() super().__init__()
self.template_name = "loras.html" self.template_name = "loras.html"
async def initialize_services(self): async def initialize_services(self):
"""Initialize services from ServiceRegistry""" """Initialize services from ServiceRegistry"""
lora_scanner = await ServiceRegistry.get_lora_scanner() lora_scanner = await ServiceRegistry.get_lora_scanner()
@@ -29,207 +30,276 @@ class LoraRoutes(BaseModelRoutes):
# Attach service dependencies # Attach service dependencies
self.attach_service(self.service) self.attach_service(self.service)
def setup_routes(self, app: web.Application): def setup_routes(self, app: web.Application):
"""Setup LoRA routes""" """Setup LoRA routes"""
# Schedule service initialization on app startup # Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services()) app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'loras' prefix (includes page route) # Setup common routes with 'loras' prefix (includes page route)
super().setup_routes(app, 'loras') super().setup_routes(app, "loras")
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str): def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup LoRA-specific routes""" """Setup LoRA-specific routes"""
# LoRA-specific query routes # LoRA-specific query routes
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/letter-counts', prefix, self.get_letter_counts) registrar.add_prefixed_route(
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/get-trigger-words', prefix, self.get_lora_trigger_words) "GET", "/api/lm/{prefix}/letter-counts", prefix, self.get_letter_counts
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/usage-tips-by-path', prefix, self.get_lora_usage_tips_by_path) )
registrar.add_prefixed_route(
"GET",
"/api/lm/{prefix}/get-trigger-words",
prefix,
self.get_lora_trigger_words,
)
registrar.add_prefixed_route(
"GET",
"/api/lm/{prefix}/usage-tips-by-path",
prefix,
self.get_lora_usage_tips_by_path,
)
# Randomizer routes
registrar.add_prefixed_route(
"POST", "/api/lm/{prefix}/random-sample", prefix, self.get_random_loras
)
# ComfyUI integration # ComfyUI integration
registrar.add_prefixed_route('POST', '/api/lm/{prefix}/get_trigger_words', prefix, self.get_trigger_words) registrar.add_prefixed_route(
"POST", "/api/lm/{prefix}/get_trigger_words", prefix, self.get_trigger_words
)
def _parse_specific_params(self, request: web.Request) -> Dict: def _parse_specific_params(self, request: web.Request) -> Dict:
"""Parse LoRA-specific parameters""" """Parse LoRA-specific parameters"""
params = {} params = {}
# LoRA-specific parameters # LoRA-specific parameters
if 'first_letter' in request.query: if "first_letter" in request.query:
params['first_letter'] = request.query.get('first_letter') params["first_letter"] = request.query.get("first_letter")
# Handle fuzzy search parameter name variation # Handle fuzzy search parameter name variation
if request.query.get('fuzzy') == 'true': if request.query.get("fuzzy") == "true":
params['fuzzy_search'] = True params["fuzzy_search"] = True
# Handle additional filter parameters for LoRAs # Handle additional filter parameters for LoRAs
if 'lora_hash' in request.query: if "lora_hash" in request.query:
if not params.get('hash_filters'): if not params.get("hash_filters"):
params['hash_filters'] = {} params["hash_filters"] = {}
params['hash_filters']['single_hash'] = request.query['lora_hash'].lower() params["hash_filters"]["single_hash"] = request.query["lora_hash"].lower()
elif 'lora_hashes' in request.query: elif "lora_hashes" in request.query:
if not params.get('hash_filters'): if not params.get("hash_filters"):
params['hash_filters'] = {} params["hash_filters"] = {}
params['hash_filters']['multiple_hashes'] = [h.lower() for h in request.query['lora_hashes'].split(',')] params["hash_filters"]["multiple_hashes"] = [
h.lower() for h in request.query["lora_hashes"].split(",")
]
return params return params
def _validate_civitai_model_type(self, model_type: str) -> bool: def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for LoRA""" """Validate CivitAI model type for LoRA"""
from ..utils.constants import VALID_LORA_TYPES from ..utils.constants import VALID_LORA_TYPES
return model_type.lower() in VALID_LORA_TYPES return model_type.lower() in VALID_LORA_TYPES
def _get_expected_model_types(self) -> str: def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages""" """Get expected model types string for error messages"""
return "LORA, LoCon, or DORA" return "LORA, LoCon, or DORA"
# LoRA-specific route handlers # LoRA-specific route handlers
async def get_letter_counts(self, request: web.Request) -> web.Response: async def get_letter_counts(self, request: web.Request) -> web.Response:
"""Get count of LoRAs for each letter of the alphabet""" """Get count of LoRAs for each letter of the alphabet"""
try: try:
letter_counts = await self.service.get_letter_counts() letter_counts = await self.service.get_letter_counts()
return web.json_response({ return web.json_response({"success": True, "letter_counts": letter_counts})
'success': True,
'letter_counts': letter_counts
})
except Exception as e: except Exception as e:
logger.error(f"Error getting letter counts: {e}") logger.error(f"Error getting letter counts: {e}")
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
'success': False,
'error': str(e)
}, status=500)
async def get_lora_notes(self, request: web.Request) -> web.Response: async def get_lora_notes(self, request: web.Request) -> web.Response:
"""Get notes for a specific LoRA file""" """Get notes for a specific LoRA file"""
try: try:
lora_name = request.query.get('name') lora_name = request.query.get("name")
if not lora_name: if not lora_name:
return web.Response(text='Lora file name is required', status=400) return web.Response(text="Lora file name is required", status=400)
notes = await self.service.get_lora_notes(lora_name) notes = await self.service.get_lora_notes(lora_name)
if notes is not None: if notes is not None:
return web.json_response({ return web.json_response({"success": True, "notes": notes})
'success': True,
'notes': notes
})
else: else:
return web.json_response({ return web.json_response(
'success': False, {"success": False, "error": "LoRA not found in cache"}, status=404
'error': 'LoRA not found in cache' )
}, status=404)
except Exception as e: except Exception as e:
logger.error(f"Error getting lora notes: {e}", exc_info=True) logger.error(f"Error getting lora notes: {e}", exc_info=True)
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
'success': False,
'error': str(e)
}, status=500)
async def get_lora_trigger_words(self, request: web.Request) -> web.Response: async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for a specific LoRA file""" """Get trigger words for a specific LoRA file"""
try: try:
lora_name = request.query.get('name') lora_name = request.query.get("name")
if not lora_name: if not lora_name:
return web.Response(text='Lora file name is required', status=400) return web.Response(text="Lora file name is required", status=400)
trigger_words = await self.service.get_lora_trigger_words(lora_name) trigger_words = await self.service.get_lora_trigger_words(lora_name)
return web.json_response({ return web.json_response({"success": True, "trigger_words": trigger_words})
'success': True,
'trigger_words': trigger_words
})
except Exception as e: except Exception as e:
logger.error(f"Error getting lora trigger words: {e}", exc_info=True) logger.error(f"Error getting lora trigger words: {e}", exc_info=True)
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
'success': False,
'error': str(e)
}, status=500)
async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response: async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response:
"""Get usage tips for a LoRA by its relative path""" """Get usage tips for a LoRA by its relative path"""
try: try:
relative_path = request.query.get('relative_path') relative_path = request.query.get("relative_path")
if not relative_path: if not relative_path:
return web.Response(text='Relative path is required', status=400) return web.Response(text="Relative path is required", status=400)
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(relative_path) usage_tips = await self.service.get_lora_usage_tips_by_relative_path(
return web.json_response({ relative_path
'success': True, )
'usage_tips': usage_tips or '' return web.json_response({"success": True, "usage_tips": usage_tips or ""})
})
except Exception as e: except Exception as e:
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True) logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
'success': False,
'error': str(e)
}, status=500)
async def get_lora_preview_url(self, request: web.Request) -> web.Response: async def get_lora_preview_url(self, request: web.Request) -> web.Response:
"""Get the static preview URL for a LoRA file""" """Get the static preview URL for a LoRA file"""
try: try:
lora_name = request.query.get('name') lora_name = request.query.get("name")
if not lora_name: if not lora_name:
return web.Response(text='Lora file name is required', status=400) return web.Response(text="Lora file name is required", status=400)
preview_url = await self.service.get_lora_preview_url(lora_name) preview_url = await self.service.get_lora_preview_url(lora_name)
if preview_url: if preview_url:
return web.json_response({ return web.json_response({"success": True, "preview_url": preview_url})
'success': True,
'preview_url': preview_url
})
else: else:
return web.json_response({ return web.json_response(
'success': False, {
'error': 'No preview URL found for the specified lora' "success": False,
}, status=404) "error": "No preview URL found for the specified lora",
},
status=404,
)
except Exception as e: except Exception as e:
logger.error(f"Error getting lora preview URL: {e}", exc_info=True) logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
'success': False,
'error': str(e)
}, status=500)
async def get_lora_civitai_url(self, request: web.Request) -> web.Response: async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
"""Get the Civitai URL for a LoRA file""" """Get the Civitai URL for a LoRA file"""
try: try:
lora_name = request.query.get('name') lora_name = request.query.get("name")
if not lora_name: if not lora_name:
return web.Response(text='Lora file name is required', status=400) return web.Response(text="Lora file name is required", status=400)
result = await self.service.get_lora_civitai_url(lora_name) result = await self.service.get_lora_civitai_url(lora_name)
if result['civitai_url']: if result["civitai_url"]:
return web.json_response({ return web.json_response({"success": True, **result})
'success': True,
**result
})
else: else:
return web.json_response({ return web.json_response(
'success': False, {
'error': 'No Civitai data found for the specified lora' "success": False,
}, status=404) "error": "No Civitai data found for the specified lora",
},
status=404,
)
except Exception as e: except Exception as e:
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True) logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
'success': False,
'error': str(e) async def get_random_loras(self, request: web.Request) -> web.Response:
}, status=500) """Get random LoRAs based on filters and strength ranges"""
try:
json_data = await request.json()
# Parse parameters
count = json_data.get("count", 5)
count_min = json_data.get("count_min")
count_max = json_data.get("count_max")
model_strength_min = float(json_data.get("model_strength_min", 0.0))
model_strength_max = float(json_data.get("model_strength_max", 1.0))
use_same_clip_strength = json_data.get("use_same_clip_strength", True)
clip_strength_min = float(json_data.get("clip_strength_min", 0.0))
clip_strength_max = float(json_data.get("clip_strength_max", 1.0))
locked_loras = json_data.get("locked_loras", [])
pool_config = json_data.get("pool_config")
use_recommended_strength = json_data.get("use_recommended_strength", False)
recommended_strength_scale_min = float(
json_data.get("recommended_strength_scale_min", 0.5)
)
recommended_strength_scale_max = float(
json_data.get("recommended_strength_scale_max", 1.0)
)
# Determine target count
if count_min is not None and count_max is not None:
import random
target_count = random.randint(count_min, count_max)
else:
target_count = count
# Validate parameters
if target_count < 1 or target_count > 100:
return web.json_response(
{"success": False, "error": "Count must be between 1 and 100"},
status=400,
)
if model_strength_min < -10 or model_strength_max > 10:
return web.json_response(
{
"success": False,
"error": "Model strength must be between -10 and 10",
},
status=400,
)
# Get random LoRAs from service
result_loras = await self.service.get_random_loras(
count=target_count,
model_strength_min=model_strength_min,
model_strength_max=model_strength_max,
use_same_clip_strength=use_same_clip_strength,
clip_strength_min=clip_strength_min,
clip_strength_max=clip_strength_max,
locked_loras=locked_loras,
pool_config=pool_config,
use_recommended_strength=use_recommended_strength,
recommended_strength_scale_min=recommended_strength_scale_min,
recommended_strength_scale_max=recommended_strength_scale_max,
)
return web.json_response(
{"success": True, "loras": result_loras, "count": len(result_loras)}
)
except ValueError as e:
logger.error(f"Invalid parameter for random LoRAs: {e}")
return web.json_response({"success": False, "error": str(e)}, status=400)
except Exception as e:
logger.error(f"Error getting random LoRAs: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_trigger_words(self, request: web.Request) -> web.Response: async def get_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for specified LoRA models""" """Get trigger words for specified LoRA models"""
try: try:
json_data = await request.json() json_data = await request.json()
lora_names = json_data.get("lora_names", []) lora_names = json_data.get("lora_names", [])
node_ids = json_data.get("node_ids", []) node_ids = json_data.get("node_ids", [])
all_trigger_words = [] all_trigger_words = []
for lora_name in lora_names: for lora_name in lora_names:
_, trigger_words = get_lora_info(lora_name) _, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words) all_trigger_words.extend(trigger_words)
# Format the trigger words # Format the trigger words
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else "" trigger_words_text = (
",, ".join(all_trigger_words) if all_trigger_words else ""
)
# Send update to all connected trigger word toggle nodes # Send update to all connected trigger word toggle nodes
for entry in node_ids: for entry in node_ids:
node_identifier = entry node_identifier = entry
@@ -243,21 +313,15 @@ class LoraRoutes(BaseModelRoutes):
except (TypeError, ValueError): except (TypeError, ValueError):
parsed_node_id = node_identifier parsed_node_id = node_identifier
payload = { payload = {"id": parsed_node_id, "message": trigger_words_text}
"id": parsed_node_id,
"message": trigger_words_text
}
if graph_identifier is not None: if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier) payload["graph_id"] = str(graph_identifier)
PromptServer.instance.send_sync("trigger_word_update", payload) PromptServer.instance.send_sync("trigger_word_update", payload)
return web.json_response({"success": True}) return web.json_response({"success": True})
except Exception as e: except Exception as e:
logger.error(f"Error getting trigger words: {e}") logger.error(f"Error getting trigger words: {e}")
return web.json_response({ return web.json_response({"success": False, "error": str(e)}, status=500)
"success": False,
"error": str(e)
}, status=500)

View File

@@ -41,6 +41,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"), RouteDefinition("POST", "/api/lm/remove-metadata-archive", "remove_metadata_archive"),
RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"), RouteDefinition("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_status"),
RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"), RouteDefinition("GET", "/api/lm/model-versions-status", "get_model_versions_status"),
RouteDefinition("POST", "/api/lm/settings/open-location", "open_settings_location"),
) )

View File

@@ -107,7 +107,7 @@ class MiscRoutes:
settings_service=self._settings, settings_service=self._settings,
metadata_provider_updater=self._metadata_provider_updater, metadata_provider_updater=self._metadata_provider_updater,
) )
filesystem = FileSystemHandler() filesystem = FileSystemHandler(settings_service=self._settings)
node_registry_handler = NodeRegistryHandler( node_registry_handler = NodeRegistryHandler(
node_registry=self._node_registry, node_registry=self._node_registry,
prompt_server=self._prompt_server, prompt_server=self._prompt_server,

View File

@@ -68,6 +68,7 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"), RouteDefinition("GET", "/api/lm/pause-download", "pause_download_get"),
RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"), RouteDefinition("GET", "/api/lm/resume-download", "resume_download_get"),
RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"), RouteDefinition("GET", "/api/lm/download-progress/{download_id}", "get_download_progress"),
RouteDefinition("POST", "/api/lm/{prefix}/cancel-task", "cancel_task"),
RouteDefinition("GET", "/{prefix}", "handle_models_page"), RouteDefinition("GET", "/{prefix}", "handle_models_page"),
) )

View File

@@ -27,16 +27,26 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"), RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"), RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"), RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
) )

View File

@@ -3,10 +3,12 @@ import asyncio
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
import logging import logging
import os import os
import time
from ..utils.constants import VALID_LORA_TYPES from ..utils.constants import VALID_LORA_TYPES
from ..utils.models import BaseModelMetadata from ..utils.models import BaseModelMetadata
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from ..utils.usage_stats import UsageStats
from .model_query import ( from .model_query import (
FilterCriteria, FilterCriteria,
ModelCacheRepository, ModelCacheRepository,
@@ -23,9 +25,10 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING: if TYPE_CHECKING:
from .model_update_service import ModelUpdateService from .model_update_service import ModelUpdateService
class BaseModelService(ABC): class BaseModelService(ABC):
"""Base service class for all model types""" """Base service class for all model types"""
def __init__( def __init__(
self, self,
model_type: str, model_type: str,
@@ -58,13 +61,15 @@ class BaseModelService(ABC):
self.filter_set = filter_set or ModelFilterSet(self.settings) self.filter_set = filter_set or ModelFilterSet(self.settings)
self.search_strategy = search_strategy or SearchStrategy() self.search_strategy = search_strategy or SearchStrategy()
self.update_service = update_service self.update_service = update_service
async def get_paginated_data( async def get_paginated_data(
self, self,
page: int, page: int,
page_size: int, page_size: int,
sort_by: str = 'name', sort_by: str = "name",
folder: str = None, folder: str = None,
folder_include: list = None,
folder_exclude: list = None,
search: str = None, search: str = None,
fuzzy_search: bool = False, fuzzy_search: bool = False,
base_models: list = None, base_models: list = None,
@@ -79,16 +84,26 @@ class BaseModelService(ABC):
**kwargs, **kwargs,
) -> Dict: ) -> Dict:
"""Get paginated and filtered model data""" """Get paginated and filtered model data"""
overall_start = time.perf_counter()
sort_params = self.cache_repository.parse_sort(sort_by) sort_params = self.cache_repository.parse_sort(sort_by)
sorted_data = await self.cache_repository.fetch_sorted(sort_params) t0 = time.perf_counter()
if sort_params.key == "usage":
sorted_data = await self._fetch_with_usage_sort(sort_params)
else:
sorted_data = await self.cache_repository.fetch_sorted(sort_params)
fetch_duration = time.perf_counter() - t0
initial_count = len(sorted_data)
t1 = time.perf_counter()
if hash_filters: if hash_filters:
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters) filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
else: else:
filtered_data = await self._apply_common_filters( filtered_data = await self._apply_common_filters(
sorted_data, sorted_data,
folder=folder, folder=folder,
folder_include=folder_include,
folder_exclude=folder_exclude,
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
@@ -108,57 +123,119 @@ class BaseModelService(ABC):
# Apply license-based filters # Apply license-based filters
if credit_required is not None: if credit_required is not None:
filtered_data = await self._apply_credit_required_filter(filtered_data, credit_required) filtered_data = await self._apply_credit_required_filter(
filtered_data, credit_required
)
if allow_selling_generated_content is not None: if allow_selling_generated_content is not None:
filtered_data = await self._apply_allow_selling_filter(filtered_data, allow_selling_generated_content) filtered_data = await self._apply_allow_selling_filter(
filtered_data, allow_selling_generated_content
)
filter_duration = time.perf_counter() - t1
post_filter_count = len(filtered_data)
annotated_for_filter: Optional[List[Dict]] = None annotated_for_filter: Optional[List[Dict]] = None
t2 = time.perf_counter()
if update_available_only: if update_available_only:
annotated_for_filter = await self._annotate_update_flags(filtered_data) annotated_for_filter = await self._annotate_update_flags(filtered_data)
filtered_data = [ filtered_data = [
item for item in annotated_for_filter item for item in annotated_for_filter if item.get("update_available")
if item.get('update_available')
] ]
update_filter_duration = time.perf_counter() - t2
final_count = len(filtered_data)
t3 = time.perf_counter()
paginated = self._paginate(filtered_data, page, page_size) paginated = self._paginate(filtered_data, page, page_size)
pagination_duration = time.perf_counter() - t3
t4 = time.perf_counter()
if update_available_only: if update_available_only:
# Items already include update flags thanks to the pre-filter annotation. # Items already include update flags thanks to the pre-filter annotation.
paginated['items'] = list(paginated['items']) paginated["items"] = list(paginated["items"])
else: else:
paginated['items'] = await self._annotate_update_flags( paginated["items"] = await self._annotate_update_flags(
paginated['items'], paginated["items"],
) )
annotate_duration = time.perf_counter() - t4
overall_duration = time.perf_counter() - overall_start
logger.debug(
"%s.get_paginated_data took %.3fs (fetch: %.3fs, filter: %.3fs, update_filter: %.3fs, pagination: %.3fs, annotate: %.3fs). "
"Counts: initial=%d, post_filter=%d, final=%d",
self.__class__.__name__,
overall_duration,
fetch_duration,
filter_duration,
update_filter_duration,
pagination_duration,
annotate_duration,
initial_count,
post_filter_count,
final_count,
)
return paginated return paginated
async def _fetch_with_usage_sort(self, sort_params):
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]: """Fetch data sorted by usage count (desc/asc)."""
cache = await self.cache_repository.get_cache()
raw_items = cache.raw_data or []
# Map model type to usage stats bucket
bucket_map = {
"lora": "loras",
"checkpoint": "checkpoints",
# 'embedding': 'embeddings', # TODO: Enable when embedding usage tracking is implemented
}
bucket_key = bucket_map.get(self.model_type, "")
usage_stats = UsageStats()
stats = await usage_stats.get_stats()
usage_bucket = stats.get(bucket_key, {}) if bucket_key else {}
annotated = []
for item in raw_items:
sha = (item.get("sha256") or "").lower()
usage_info = (
usage_bucket.get(sha, {}) if isinstance(usage_bucket, dict) else {}
)
usage_count = (
usage_info.get("total", 0) if isinstance(usage_info, dict) else 0
)
annotated.append({**item, "usage_count": usage_count})
reverse = sort_params.order == "desc"
annotated.sort(
key=lambda x: (x.get("usage_count", 0), x.get("model_name", "").lower()),
reverse=reverse,
)
return annotated
async def _apply_hash_filters(
self, data: List[Dict], hash_filters: Dict
) -> List[Dict]:
"""Apply hash-based filtering""" """Apply hash-based filtering"""
single_hash = hash_filters.get('single_hash') single_hash = hash_filters.get("single_hash")
multiple_hashes = hash_filters.get('multiple_hashes') multiple_hashes = hash_filters.get("multiple_hashes")
if single_hash: if single_hash:
# Filter by single hash # Filter by single hash
single_hash = single_hash.lower() single_hash = single_hash.lower()
return [ return [
item for item in data item for item in data if item.get("sha256", "").lower() == single_hash
if item.get('sha256', '').lower() == single_hash
] ]
elif multiple_hashes: elif multiple_hashes:
# Filter by multiple hashes # Filter by multiple hashes
hash_set = set(hash.lower() for hash in multiple_hashes) hash_set = set(hash.lower() for hash in multiple_hashes)
return [ return [item for item in data if item.get("sha256", "").lower() in hash_set]
item for item in data
if item.get('sha256', '').lower() in hash_set
]
return data return data
async def _apply_common_filters( async def _apply_common_filters(
self, self,
data: List[Dict], data: List[Dict],
folder: str = None, folder: str = None,
folder_include: list = None,
folder_exclude: list = None,
base_models: list = None, base_models: list = None,
model_types: list = None, model_types: list = None,
tags: Optional[Dict[str, str]] = None, tags: Optional[Dict[str, str]] = None,
@@ -169,6 +246,8 @@ class BaseModelService(ABC):
normalized_options = self.search_strategy.normalize_options(search_options) normalized_options = self.search_strategy.normalize_options(search_options)
criteria = FilterCriteria( criteria = FilterCriteria(
folder=folder, folder=folder,
folder_include=folder_include,
folder_exclude=folder_exclude,
base_models=base_models, base_models=base_models,
model_types=model_types, model_types=model_types,
tags=tags, tags=tags,
@@ -176,7 +255,7 @@ class BaseModelService(ABC):
search_options=normalized_options, search_options=normalized_options,
) )
return self.filter_set.apply(data, criteria) return self.filter_set.apply(data, criteria)
async def _apply_search_filters( async def _apply_search_filters(
self, self,
data: List[Dict], data: List[Dict],
@@ -186,28 +265,34 @@ class BaseModelService(ABC):
) -> List[Dict]: ) -> List[Dict]:
"""Apply search filtering""" """Apply search filtering"""
normalized_options = self.search_strategy.normalize_options(search_options) normalized_options = self.search_strategy.normalize_options(search_options)
return self.search_strategy.apply(data, search, normalized_options, fuzzy_search) return self.search_strategy.apply(
data, search, normalized_options, fuzzy_search
)
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]: async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
"""Apply model-specific filters - to be overridden by subclasses if needed""" """Apply model-specific filters - to be overridden by subclasses if needed"""
return data return data
async def _apply_credit_required_filter(self, data: List[Dict], credit_required: bool) -> List[Dict]: async def _apply_credit_required_filter(
self, data: List[Dict], credit_required: bool
) -> List[Dict]:
"""Apply credit required filtering based on license_flags. """Apply credit required filtering based on license_flags.
Args: Args:
data: List of model data items data: List of model data items
credit_required: credit_required:
- True: Return items where credit is required (allowNoCredit=False) - True: Return items where credit is required (allowNoCredit=False)
- False: Return items where credit is not required (allowNoCredit=True) - False: Return items where credit is not required (allowNoCredit=True)
""" """
filtered_data = [] filtered_data = []
for item in data: for item in data:
license_flags = item.get("license_flags", 127) # Default to all permissions enabled license_flags = item.get(
"license_flags", 127
) # Default to all permissions enabled
# Bit 0 represents allowNoCredit (1 = no credit required, 0 = credit required) # Bit 0 represents allowNoCredit (1 = no credit required, 0 = credit required)
allow_no_credit = bool(license_flags & (1 << 0)) allow_no_credit = bool(license_flags & (1 << 0))
# If credit_required is True, we want items where allowNoCredit is False (credit required) # If credit_required is True, we want items where allowNoCredit is False (credit required)
# If credit_required is False, we want items where allowNoCredit is True (no credit required) # If credit_required is False, we want items where allowNoCredit is True (no credit required)
if credit_required: if credit_required:
@@ -216,26 +301,30 @@ class BaseModelService(ABC):
else: else:
if allow_no_credit: # Credit is not required if allow_no_credit: # Credit is not required
filtered_data.append(item) filtered_data.append(item)
return filtered_data return filtered_data
async def _apply_allow_selling_filter(self, data: List[Dict], allow_selling: bool) -> List[Dict]: async def _apply_allow_selling_filter(
self, data: List[Dict], allow_selling: bool
) -> List[Dict]:
"""Apply allow selling generated content filtering based on license_flags. """Apply allow selling generated content filtering based on license_flags.
Args: Args:
data: List of model data items data: List of model data items
allow_selling: allow_selling:
- True: Return items where selling generated content is allowed (allowCommercialUse contains Image) - True: Return items where selling generated content is allowed (allowCommercialUse contains Image)
- False: Return items where selling generated content is not allowed (allowCommercialUse does not contain Image) - False: Return items where selling generated content is not allowed (allowCommercialUse does not contain Image)
""" """
filtered_data = [] filtered_data = []
for item in data: for item in data:
license_flags = item.get("license_flags", 127) # Default to all permissions enabled license_flags = item.get(
"license_flags", 127
) # Default to all permissions enabled
# Bits 1-4 represent commercial use permissions # Bits 1-4 represent commercial use permissions
# Bit 1 specifically represents Image permission (allowCommercialUse contains Image) # Bit 1 specifically represents Image permission (allowCommercialUse contains Image)
has_image_permission = bool(license_flags & (1 << 1)) has_image_permission = bool(license_flags & (1 << 1))
# If allow_selling is True, we want items where Image permission is granted # If allow_selling is True, we want items where Image permission is granted
# If allow_selling is False, we want items where Image permission is not granted # If allow_selling is False, we want items where Image permission is not granted
if allow_selling: if allow_selling:
@@ -244,7 +333,7 @@ class BaseModelService(ABC):
else: else:
if not has_image_permission: # Selling generated content is not allowed if not has_image_permission: # Selling generated content is not allowed
filtered_data.append(item) filtered_data.append(item)
return filtered_data return filtered_data
async def _annotate_update_flags( async def _annotate_update_flags(
@@ -262,7 +351,7 @@ class BaseModelService(ABC):
if self.update_service is None: if self.update_service is None:
for item in annotated: for item in annotated:
item['update_available'] = False item["update_available"] = False
return annotated return annotated
id_to_items: Dict[int, List[Dict]] = {} id_to_items: Dict[int, List[Dict]] = {}
@@ -270,7 +359,7 @@ class BaseModelService(ABC):
for item in annotated: for item in annotated:
model_id = self._extract_model_id(item) model_id = self._extract_model_id(item)
if model_id is None: if model_id is None:
item['update_available'] = False item["update_available"] = False
continue continue
if model_id not in id_to_items: if model_id not in id_to_items:
id_to_items[model_id] = [] id_to_items[model_id] = []
@@ -346,13 +435,19 @@ class BaseModelService(ABC):
default_flag = bool(resolved.get(model_id, False)) if resolved else False default_flag = bool(resolved.get(model_id, False)) if resolved else False
record = records.get(model_id) if records else None record = records.get(model_id) if records else None
base_highest_versions = ( base_highest_versions = (
self._build_highest_local_versions_by_base(record) if same_base_mode and record else {} self._build_highest_local_versions_by_base(record)
if same_base_mode and record
else {}
) )
for item in items_for_id: for item in items_for_id:
if same_base_mode and record is not None: if same_base_mode and record is not None:
base_model = self._extract_base_model(item) base_model = self._extract_base_model(item)
normalized_base = self._normalize_base_model_name(base_model) normalized_base = self._normalize_base_model_name(base_model)
threshold_version = base_highest_versions.get(normalized_base) if normalized_base else None threshold_version = (
base_highest_versions.get(normalized_base)
if normalized_base
else None
)
if threshold_version is None: if threshold_version is None:
threshold_version = self._extract_version_id(item) threshold_version = self._extract_version_id(item)
flag = record.has_update_for_base( flag = record.has_update_for_base(
@@ -361,17 +456,17 @@ class BaseModelService(ABC):
) )
else: else:
flag = default_flag flag = default_flag
item['update_available'] = flag item["update_available"] = flag
return annotated return annotated
@staticmethod @staticmethod
def _extract_model_id(item: Dict) -> Optional[int]: def _extract_model_id(item: Dict) -> Optional[int]:
civitai = item.get('civitai') if isinstance(item, dict) else None civitai = item.get("civitai") if isinstance(item, dict) else None
if not isinstance(civitai, dict): if not isinstance(civitai, dict):
return None return None
try: try:
value = civitai.get('modelId') value = civitai.get("modelId")
if value is None: if value is None:
return None return None
return int(value) return int(value)
@@ -380,10 +475,10 @@ class BaseModelService(ABC):
@staticmethod @staticmethod
def _extract_version_id(item: Dict) -> Optional[int]: def _extract_version_id(item: Dict) -> Optional[int]:
civitai = item.get('civitai') if isinstance(item, dict) else None civitai = item.get("civitai") if isinstance(item, dict) else None
if not isinstance(civitai, dict): if not isinstance(civitai, dict):
return None return None
value = civitai.get('id') value = civitai.get("id")
if value is None: if value is None:
return None return None
try: try:
@@ -393,7 +488,7 @@ class BaseModelService(ABC):
@staticmethod @staticmethod
def _extract_base_model(item: Dict) -> Optional[str]: def _extract_base_model(item: Dict) -> Optional[str]:
value = item.get('base_model') value = item.get("base_model")
if value is None: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):
@@ -430,7 +525,9 @@ class BaseModelService(ABC):
for version in getattr(record, "versions", []): for version in getattr(record, "versions", []):
if not getattr(version, "is_in_library", False): if not getattr(version, "is_in_library", False):
continue continue
normalized_base = self._normalize_base_model_name(getattr(version, "base_model", None)) normalized_base = self._normalize_base_model_name(
getattr(version, "base_model", None)
)
if normalized_base is None: if normalized_base is None:
continue continue
version_id = getattr(version, "version_id", None) version_id = getattr(version, "version_id", None)
@@ -447,25 +544,25 @@ class BaseModelService(ABC):
total_items = len(data) total_items = len(data)
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
end_idx = min(start_idx + page_size, total_items) end_idx = min(start_idx + page_size, total_items)
return { return {
'items': data[start_idx:end_idx], "items": data[start_idx:end_idx],
'total': total_items, "total": total_items,
'page': page, "page": page,
'page_size': page_size, "page_size": page_size,
'total_pages': (total_items + page_size - 1) // page_size "total_pages": (total_items + page_size - 1) // page_size,
} }
@abstractmethod @abstractmethod
async def format_response(self, model_data: Dict) -> Dict: async def format_response(self, model_data: Dict) -> Dict:
"""Format model data for API response - must be implemented by subclasses""" """Format model data for API response - must be implemented by subclasses"""
pass pass
# Common service methods that delegate to scanner # Common service methods that delegate to scanner
async def get_top_tags(self, limit: int = 20) -> List[Dict]: async def get_top_tags(self, limit: int = 20) -> List[Dict]:
"""Get top tags sorted by frequency""" """Get top tags sorted by frequency"""
return await self.scanner.get_top_tags(limit) return await self.scanner.get_top_tags(limit)
async def get_base_models(self, limit: int = 20) -> List[Dict]: async def get_base_models(self, limit: int = 20) -> List[Dict]:
"""Get base models sorted by frequency""" """Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit) return await self.scanner.get_base_models(limit)
@@ -476,62 +573,85 @@ class BaseModelService(ABC):
type_counts: Dict[str, int] = {} type_counts: Dict[str, int] = {}
for entry in cache.raw_data: for entry in cache.raw_data:
normalized_type = normalize_civitai_model_type(resolve_civitai_model_type(entry)) normalized_type = normalize_civitai_model_type(
resolve_civitai_model_type(entry)
)
if not normalized_type or normalized_type not in VALID_LORA_TYPES: if not normalized_type or normalized_type not in VALID_LORA_TYPES:
continue continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1 type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
sorted_types = sorted( sorted_types = sorted(
[{"type": model_type, "count": count} for model_type, count in type_counts.items()], [
{"type": model_type, "count": count}
for model_type, count in type_counts.items()
],
key=lambda value: value["count"], key=lambda value: value["count"],
reverse=True, reverse=True,
) )
return sorted_types[:limit] return sorted_types[:limit]
def has_hash(self, sha256: str) -> bool: def has_hash(self, sha256: str) -> bool:
"""Check if a model with given hash exists""" """Check if a model with given hash exists"""
return self.scanner.has_hash(sha256) return self.scanner.has_hash(sha256)
def get_path_by_hash(self, sha256: str) -> Optional[str]: def get_path_by_hash(self, sha256: str) -> Optional[str]:
"""Get file path for a model by its hash""" """Get file path for a model by its hash"""
return self.scanner.get_path_by_hash(sha256) return self.scanner.get_path_by_hash(sha256)
def get_hash_by_path(self, file_path: str) -> Optional[str]: def get_hash_by_path(self, file_path: str) -> Optional[str]:
"""Get hash for a model by its file path""" """Get hash for a model by its file path"""
return self.scanner.get_hash_by_path(file_path) return self.scanner.get_hash_by_path(file_path)
async def scan_models(self, force_refresh: bool = False, rebuild_cache: bool = False): async def scan_models(
self, force_refresh: bool = False, rebuild_cache: bool = False
):
"""Trigger model scanning""" """Trigger model scanning"""
return await self.scanner.get_cached_data(force_refresh=force_refresh, rebuild_cache=rebuild_cache) return await self.scanner.get_cached_data(
force_refresh=force_refresh, rebuild_cache=rebuild_cache
)
async def get_model_info_by_name(self, name: str): async def get_model_info_by_name(self, name: str):
"""Get model information by name""" """Get model information by name"""
return await self.scanner.get_model_info_by_name(name) return await self.scanner.get_model_info_by_name(name)
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get model root directories""" """Get model root directories"""
return self.scanner.get_model_roots() return self.scanner.get_model_roots()
def filter_civitai_data(self, data: Dict, minimal: bool = False) -> Dict: def filter_civitai_data(self, data: Dict, minimal: bool = False) -> Dict:
"""Filter relevant fields from CivitAI data""" """Filter relevant fields from CivitAI data"""
if not data: if not data:
return {} return {}
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [ fields = (
"id", "modelId", "name", "createdAt", "updatedAt", ["id", "modelId", "name", "trainedWords"]
"publishedAt", "trainedWords", "baseModel", "description", if minimal
"model", "images", "customImages", "creator" else [
] "id",
"modelId",
"name",
"createdAt",
"updatedAt",
"publishedAt",
"trainedWords",
"baseModel",
"description",
"model",
"images",
"customImages",
"creator",
]
)
return {k: data[k] for k in fields if k in data} return {k: data[k] for k in fields if k in data}
async def get_folder_tree(self, model_root: str) -> Dict: async def get_folder_tree(self, model_root: str) -> Dict:
"""Get hierarchical folder tree for a specific model root""" """Get hierarchical folder tree for a specific model root"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
# Build tree structure from folders # Build tree structure from folders
tree = {} tree = {}
for folder in cache.folders: for folder in cache.folders:
# Check if this folder belongs to the specified model root # Check if this folder belongs to the specified model root
folder_belongs_to_root = False folder_belongs_to_root = False
@@ -539,95 +659,96 @@ class BaseModelService(ABC):
if root == model_root: if root == model_root:
folder_belongs_to_root = True folder_belongs_to_root = True
break break
if not folder_belongs_to_root: if not folder_belongs_to_root:
continue continue
# Split folder path into components # Split folder path into components
parts = folder.split('/') if folder else [] parts = folder.split("/") if folder else []
current_level = tree current_level = tree
for part in parts: for part in parts:
if part not in current_level: if part not in current_level:
current_level[part] = {} current_level[part] = {}
current_level = current_level[part] current_level = current_level[part]
return tree return tree
async def get_unified_folder_tree(self) -> Dict: async def get_unified_folder_tree(self) -> Dict:
"""Get unified folder tree across all model roots""" """Get unified folder tree across all model roots"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
# Build unified tree structure by analyzing all relative paths # Build unified tree structure by analyzing all relative paths
unified_tree = {} unified_tree = {}
# Get all model roots for path normalization # Get all model roots for path normalization
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
for folder in cache.folders: for folder in cache.folders:
if not folder: # Skip empty folders if not folder: # Skip empty folders
continue continue
# Find which root this folder belongs to by checking the actual file paths # Find which root this folder belongs to by checking the actual file paths
# This is a simplified approach - we'll use the folder as-is since it should already be relative # This is a simplified approach - we'll use the folder as-is since it should already be relative
relative_path = folder relative_path = folder
# Split folder path into components # Split folder path into components
parts = relative_path.split('/') parts = relative_path.split("/")
current_level = unified_tree current_level = unified_tree
for part in parts: for part in parts:
if part not in current_level: if part not in current_level:
current_level[part] = {} current_level[part] = {}
current_level = current_level[part] current_level = current_level[part]
return unified_tree return unified_tree
async def get_model_notes(self, model_name: str) -> Optional[str]: async def get_model_notes(self, model_name: str) -> Optional[str]:
"""Get notes for a specific model file""" """Get notes for a specific model file"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
for model in cache.raw_data: for model in cache.raw_data:
if model['file_name'] == model_name: if model["file_name"] == model_name:
return model.get('notes', '') return model.get("notes", "")
return None return None
async def get_model_preview_url(self, model_name: str) -> Optional[str]: async def get_model_preview_url(self, model_name: str) -> Optional[str]:
"""Get the static preview URL for a model file""" """Get the static preview URL for a model file"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
for model in cache.raw_data: for model in cache.raw_data:
if model['file_name'] == model_name: if model["file_name"] == model_name:
preview_url = model.get('preview_url') preview_url = model.get("preview_url")
if preview_url: if preview_url:
from ..config import config from ..config import config
return config.get_preview_static_url(preview_url) return config.get_preview_static_url(preview_url)
return '/loras_static/images/no-preview.png' return "/loras_static/images/no-preview.png"
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]: async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
"""Get the Civitai URL for a model file""" """Get the Civitai URL for a model file"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
for model in cache.raw_data: for model in cache.raw_data:
if model['file_name'] == model_name: if model["file_name"] == model_name:
civitai_data = model.get('civitai', {}) civitai_data = model.get("civitai", {})
model_id = civitai_data.get('modelId') model_id = civitai_data.get("modelId")
version_id = civitai_data.get('id') version_id = civitai_data.get("id")
if model_id: if model_id:
civitai_url = f"https://civitai.com/models/{model_id}" civitai_url = f"https://civitai.com/models/{model_id}"
if version_id: if version_id:
civitai_url += f"?modelVersionId={version_id}" civitai_url += f"?modelVersionId={version_id}"
return { return {
'civitai_url': civitai_url, "civitai_url": civitai_url,
'model_id': str(model_id), "model_id": str(model_id),
'version_id': str(version_id) if version_id else None "version_id": str(version_id) if version_id else None,
} }
return {'civitai_url': None, 'model_id': None, 'version_id': None} return {"civitai_url": None, "model_id": None, "version_id": None}
async def get_model_metadata(self, file_path: str) -> Optional[Dict]: async def get_model_metadata(self, file_path: str) -> Optional[Dict]:
"""Load full metadata for a single model. """Load full metadata for a single model.
@@ -635,18 +756,21 @@ class BaseModelService(ABC):
Listing/search endpoints return lightweight cache entries; this method performs Listing/search endpoints return lightweight cache entries; this method performs
a lazy read of the on-disk metadata snapshot when callers need full detail. a lazy read of the on-disk metadata snapshot when callers need full detail.
""" """
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class) metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.metadata_class
)
if should_skip or metadata is None: if should_skip or metadata is None:
return None return None
return self.filter_civitai_data(metadata.to_dict().get("civitai", {})) return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]: async def get_model_description(self, file_path: str) -> Optional[str]:
"""Return the stored modelDescription field for a model.""" """Return the stored modelDescription field for a model."""
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.metadata_class) metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.metadata_class
)
if should_skip or metadata is None: if should_skip or metadata is None:
return None return None
return metadata.modelDescription or '' return metadata.modelDescription or ""
@staticmethod @staticmethod
def _parse_search_tokens(search_term: str) -> tuple[List[str], List[str]]: def _parse_search_tokens(search_term: str) -> tuple[List[str], List[str]]:
@@ -684,53 +808,64 @@ class BaseModelService(ABC):
def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple: def _relative_path_sort_key(relative_path: str, include_terms: List[str]) -> tuple:
"""Sort paths by how well they satisfy the include tokens.""" """Sort paths by how well they satisfy the include tokens."""
path_lower = relative_path.lower() path_lower = relative_path.lower()
prefix_hits = sum(1 for term in include_terms if term and path_lower.startswith(term)) prefix_hits = sum(
match_positions = [path_lower.find(term) for term in include_terms if term and term in path_lower] 1 for term in include_terms if term and path_lower.startswith(term)
)
match_positions = [
path_lower.find(term)
for term in include_terms
if term and term in path_lower
]
first_match_index = min(match_positions) if match_positions else 0 first_match_index = min(match_positions) if match_positions else 0
return (-prefix_hits, first_match_index, len(relative_path), path_lower) return (-prefix_hits, first_match_index, len(relative_path), path_lower)
async def search_relative_paths(
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]: self, search_term: str, limit: int = 15
) -> List[str]:
"""Search model relative file paths for autocomplete functionality""" """Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
include_terms, exclude_terms = self._parse_search_tokens(search_term) include_terms, exclude_terms = self._parse_search_tokens(search_term)
matching_paths = [] matching_paths = []
# Get model roots for path calculation # Get model roots for path calculation
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
for model in cache.raw_data: for model in cache.raw_data:
file_path = model.get('file_path', '') file_path = model.get("file_path", "")
if not file_path: if not file_path:
continue continue
# Calculate relative path from model root # Calculate relative path from model root
relative_path = None relative_path = None
for root in model_roots: for root in model_roots:
# Normalize paths for comparison # Normalize paths for comparison
normalized_root = os.path.normpath(root) normalized_root = os.path.normpath(root)
normalized_file = os.path.normpath(file_path) normalized_file = os.path.normpath(file_path)
if normalized_file.startswith(normalized_root): if normalized_file.startswith(normalized_root):
# Remove root and leading separator to get relative path # Remove root and leading separator to get relative path
relative_path = normalized_file[len(normalized_root):].lstrip(os.sep) relative_path = normalized_file[len(normalized_root) :].lstrip(
os.sep
)
break break
if not relative_path: if not relative_path:
continue continue
relative_lower = relative_path.lower() relative_lower = relative_path.lower()
if self._relative_path_matches_tokens(relative_lower, include_terms, exclude_terms): if self._relative_path_matches_tokens(
relative_lower, include_terms, exclude_terms
):
matching_paths.append(relative_path) matching_paths.append(relative_path)
if len(matching_paths) >= limit * 2: # Get more for better sorting if len(matching_paths) >= limit * 2: # Get more for better sorting
break break
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically) # Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
matching_paths.sort( matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms) key=lambda relative: self._relative_path_sort_key(relative, include_terms)
) )
return matching_paths[:limit] return matching_paths[:limit]

View File

@@ -35,6 +35,7 @@ class CheckpointService(BaseModelService):
"modified": checkpoint_data.get("modified", ""), "modified": checkpoint_data.get("modified", ""),
"tags": checkpoint_data.get("tags", []), "tags": checkpoint_data.get("tags", []),
"from_civitai": checkpoint_data.get("from_civitai", True), "from_civitai": checkpoint_data.get("from_civitai", True),
"usage_count": checkpoint_data.get("usage_count", 0),
"notes": checkpoint_data.get("notes", ""), "notes": checkpoint_data.get("notes", ""),
"model_type": checkpoint_data.get("model_type", "checkpoint"), "model_type": checkpoint_data.get("model_type", "checkpoint"),
"favorite": checkpoint_data.get("favorite", False), "favorite": checkpoint_data.get("favorite", False),

File diff suppressed because it is too large Load Diff

View File

@@ -128,6 +128,7 @@ class Downloader:
self._session = None self._session = None
self._session_created_at = None self._session_created_at = None
self._proxy_url = None # Store proxy URL for current session self._proxy_url = None # Store proxy URL for current session
self._session_lock = asyncio.Lock()
# Configuration # Configuration
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
@@ -148,7 +149,10 @@ class Downloader:
async def session(self) -> aiohttp.ClientSession: async def session(self) -> aiohttp.ClientSession:
"""Get or create the global aiohttp session with optimized settings""" """Get or create the global aiohttp session with optimized settings"""
if self._session is None or self._should_refresh_session(): if self._session is None or self._should_refresh_session():
await self._create_session() async with self._session_lock:
# Double check after acquiring lock
if self._session is None or self._should_refresh_session():
await self._create_session()
return self._session return self._session
@property @property
@@ -197,10 +201,18 @@ class Downloader:
return False return False
async def _create_session(self): async def _create_session(self):
"""Create a new aiohttp session with optimized settings""" """Create a new aiohttp session with optimized settings.
Note: This is private and caller MUST hold self._session_lock.
"""
# Close existing session if any # Close existing session if any
if self._session is not None: if self._session is not None:
await self._session.close() try:
await self._session.close()
except Exception as e: # pragma: no cover
logger.warning(f"Error closing previous session: {e}")
finally:
self._session = None
# Check for app-level proxy settings # Check for app-level proxy settings
proxy_url = None proxy_url = None
@@ -808,7 +820,8 @@ class Downloader:
async def refresh_session(self): async def refresh_session(self):
"""Force refresh the HTTP session (useful when proxy settings change)""" """Force refresh the HTTP session (useful when proxy settings change)"""
await self._create_session() async with self._session_lock:
await self._create_session()
logger.info("HTTP session refreshed due to settings change") logger.info("HTTP session refreshed due to settings change")
@staticmethod @staticmethod

View File

@@ -35,6 +35,7 @@ class EmbeddingService(BaseModelService):
"modified": embedding_data.get("modified", ""), "modified": embedding_data.get("modified", ""),
"tags": embedding_data.get("tags", []), "tags": embedding_data.get("tags", []),
"from_civitai": embedding_data.get("from_civitai", True), "from_civitai": embedding_data.get("from_civitai", True),
# "usage_count": embedding_data.get("usage_count", 0), # TODO: Enable when embedding usage tracking is implemented
"notes": embedding_data.get("notes", ""), "notes": embedding_data.get("notes", ""),
"model_type": embedding_data.get("model_type", "embedding"), "model_type": embedding_data.get("model_type", "embedding"),
"favorite": embedding_data.get("favorite", False), "favorite": embedding_data.get("favorite", False),

View File

@@ -8,24 +8,27 @@ from ..config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class LoraService(BaseModelService): class LoraService(BaseModelService):
"""LoRA-specific service implementation""" """LoRA-specific service implementation"""
def __init__(self, scanner, update_service=None): def __init__(self, scanner, update_service=None):
"""Initialize LoRA service """Initialize LoRA service
Args: Args:
scanner: LoRA scanner instance scanner: LoRA scanner instance
update_service: Optional service for remote update tracking. update_service: Optional service for remote update tracking.
""" """
super().__init__("lora", scanner, LoraMetadata, update_service=update_service) super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
async def format_response(self, lora_data: Dict) -> Dict: async def format_response(self, lora_data: Dict) -> Dict:
"""Format LoRA data for API response""" """Format LoRA data for API response"""
return { return {
"model_name": lora_data["model_name"], "model_name": lora_data["model_name"],
"file_name": lora_data["file_name"], "file_name": lora_data["file_name"],
"preview_url": config.get_preview_static_url(lora_data.get("preview_url", "")), "preview_url": config.get_preview_static_url(
lora_data.get("preview_url", "")
),
"preview_nsfw_level": lora_data.get("preview_nsfw_level", 0), "preview_nsfw_level": lora_data.get("preview_nsfw_level", 0),
"base_model": lora_data.get("base_model", ""), "base_model": lora_data.get("base_model", ""),
"folder": lora_data["folder"], "folder": lora_data["folder"],
@@ -35,149 +38,438 @@ class LoraService(BaseModelService):
"modified": lora_data.get("modified", ""), "modified": lora_data.get("modified", ""),
"tags": lora_data.get("tags", []), "tags": lora_data.get("tags", []),
"from_civitai": lora_data.get("from_civitai", True), "from_civitai": lora_data.get("from_civitai", True),
"usage_count": lora_data.get("usage_count", 0),
"usage_tips": lora_data.get("usage_tips", ""), "usage_tips": lora_data.get("usage_tips", ""),
"notes": lora_data.get("notes", ""), "notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False), "favorite": lora_data.get("favorite", False),
"update_available": bool(lora_data.get("update_available", False)), "update_available": bool(lora_data.get("update_available", False)),
"civitai": self.filter_civitai_data(lora_data.get("civitai", {}), minimal=True) "civitai": self.filter_civitai_data(
lora_data.get("civitai", {}), minimal=True
),
} }
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]: async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
"""Apply LoRA-specific filters""" """Apply LoRA-specific filters"""
# Handle first_letter filter for LoRAs # Handle first_letter filter for LoRAs
first_letter = kwargs.get('first_letter') first_letter = kwargs.get("first_letter")
if first_letter: if first_letter:
data = self._filter_by_first_letter(data, first_letter) data = self._filter_by_first_letter(data, first_letter)
return data return data
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]: def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
"""Filter data by first letter of model name """Filter data by first letter of model name
Special handling: Special handling:
- '#': Numbers (0-9) - '#': Numbers (0-9)
- '@': Special characters (not alphanumeric) - '@': Special characters (not alphanumeric)
- '': CJK characters - '': CJK characters
""" """
filtered_data = [] filtered_data = []
for lora in data: for lora in data:
model_name = lora.get('model_name', '') model_name = lora.get("model_name", "")
if not model_name: if not model_name:
continue continue
first_char = model_name[0].upper() first_char = model_name[0].upper()
if letter == '#' and first_char.isdigit(): if letter == "#" and first_char.isdigit():
filtered_data.append(lora) filtered_data.append(lora)
elif letter == '@' and not first_char.isalnum(): elif letter == "@" and not first_char.isalnum():
# Special characters (not alphanumeric) # Special characters (not alphanumeric)
filtered_data.append(lora) filtered_data.append(lora)
elif letter == '' and self._is_cjk_character(first_char): elif letter == "" and self._is_cjk_character(first_char):
# CJK characters # CJK characters
filtered_data.append(lora) filtered_data.append(lora)
elif letter.upper() == first_char: elif letter.upper() == first_char:
# Regular alphabet matching # Regular alphabet matching
filtered_data.append(lora) filtered_data.append(lora)
return filtered_data return filtered_data
def _is_cjk_character(self, char: str) -> bool: def _is_cjk_character(self, char: str) -> bool:
"""Check if character is a CJK character""" """Check if character is a CJK character"""
# Define Unicode ranges for CJK characters # Define Unicode ranges for CJK characters
cjk_ranges = [ cjk_ranges = [
(0x4E00, 0x9FFF), # CJK Unified Ideographs (0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A (0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B (0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C (0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D (0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E (0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F (0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G (0x30000, 0x3134F), # CJK Unified Ideographs Extension G
(0xF900, 0xFAFF), # CJK Compatibility Ideographs (0xF900, 0xFAFF), # CJK Compatibility Ideographs
(0x3300, 0x33FF), # CJK Compatibility (0x3300, 0x33FF), # CJK Compatibility
(0x3200, 0x32FF), # Enclosed CJK Letters and Months (0x3200, 0x32FF), # Enclosed CJK Letters and Months
(0x3100, 0x312F), # Bopomofo (0x3100, 0x312F), # Bopomofo
(0x31A0, 0x31BF), # Bopomofo Extended (0x31A0, 0x31BF), # Bopomofo Extended
(0x3040, 0x309F), # Hiragana (0x3040, 0x309F), # Hiragana
(0x30A0, 0x30FF), # Katakana (0x30A0, 0x30FF), # Katakana
(0x31F0, 0x31FF), # Katakana Phonetic Extensions (0x31F0, 0x31FF), # Katakana Phonetic Extensions
(0xAC00, 0xD7AF), # Hangul Syllables (0xAC00, 0xD7AF), # Hangul Syllables
(0x1100, 0x11FF), # Hangul Jamo (0x1100, 0x11FF), # Hangul Jamo
(0xA960, 0xA97F), # Hangul Jamo Extended-A (0xA960, 0xA97F), # Hangul Jamo Extended-A
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B (0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
] ]
code_point = ord(char) code_point = ord(char)
return any(start <= code_point <= end for start, end in cjk_ranges) return any(start <= code_point <= end for start, end in cjk_ranges)
# LoRA-specific methods # LoRA-specific methods
async def get_letter_counts(self) -> Dict[str, int]: async def get_letter_counts(self) -> Dict[str, int]:
"""Get count of LoRAs for each letter of the alphabet""" """Get count of LoRAs for each letter of the alphabet"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
data = cache.raw_data data = cache.raw_data
# Define letter categories # Define letter categories
letters = { letters = {
'#': 0, # Numbers "#": 0, # Numbers
'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0, 'F': 0, 'G': 0, 'H': 0, "A": 0,
'I': 0, 'J': 0, 'K': 0, 'L': 0, 'M': 0, 'N': 0, 'O': 0, 'P': 0, "B": 0,
'Q': 0, 'R': 0, 'S': 0, 'T': 0, 'U': 0, 'V': 0, 'W': 0, 'X': 0, "C": 0,
'Y': 0, 'Z': 0, "D": 0,
'@': 0, # Special characters "E": 0,
'': 0 # CJK characters "F": 0,
"G": 0,
"H": 0,
"I": 0,
"J": 0,
"K": 0,
"L": 0,
"M": 0,
"N": 0,
"O": 0,
"P": 0,
"Q": 0,
"R": 0,
"S": 0,
"T": 0,
"U": 0,
"V": 0,
"W": 0,
"X": 0,
"Y": 0,
"Z": 0,
"@": 0, # Special characters
"": 0, # CJK characters
} }
# Count models for each letter # Count models for each letter
for lora in data: for lora in data:
model_name = lora.get('model_name', '') model_name = lora.get("model_name", "")
if not model_name: if not model_name:
continue continue
first_char = model_name[0].upper() first_char = model_name[0].upper()
if first_char.isdigit(): if first_char.isdigit():
letters['#'] += 1 letters["#"] += 1
elif first_char in letters: elif first_char in letters:
letters[first_char] += 1 letters[first_char] += 1
elif self._is_cjk_character(first_char): elif self._is_cjk_character(first_char):
letters[''] += 1 letters[""] += 1
elif not first_char.isalnum(): elif not first_char.isalnum():
letters['@'] += 1 letters["@"] += 1
return letters return letters
async def get_lora_trigger_words(self, lora_name: str) -> List[str]: async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
"""Get trigger words for a specific LoRA file""" """Get trigger words for a specific LoRA file"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
for lora in cache.raw_data: for lora in cache.raw_data:
if lora['file_name'] == lora_name: if lora["file_name"] == lora_name:
civitai_data = lora.get('civitai', {}) civitai_data = lora.get("civitai", {})
return civitai_data.get('trainedWords', []) return civitai_data.get("trainedWords", [])
return [] return []
async def get_lora_usage_tips_by_relative_path(self, relative_path: str) -> Optional[str]: async def get_lora_usage_tips_by_relative_path(
self, relative_path: str
) -> Optional[str]:
"""Get usage tips for a LoRA by its relative path""" """Get usage tips for a LoRA by its relative path"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
for lora in cache.raw_data: for lora in cache.raw_data:
file_path = lora.get('file_path', '') file_path = lora.get("file_path", "")
if file_path: if file_path:
# Convert to forward slashes and extract relative path # Convert to forward slashes and extract relative path
file_path_normalized = file_path.replace('\\', '/') file_path_normalized = file_path.replace("\\", "/")
relative_path = relative_path.replace('\\', '/') relative_path = relative_path.replace("\\", "/")
# Find the relative path part by looking for the relative_path in the full path # Find the relative path part by looking for the relative_path in the full path
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized: if (
return lora.get('usage_tips', '') file_path_normalized.endswith(relative_path)
or relative_path in file_path_normalized
):
return lora.get("usage_tips", "")
return None return None
def find_duplicate_hashes(self) -> Dict: def find_duplicate_hashes(self) -> Dict:
"""Find LoRAs with duplicate SHA256 hashes""" """Find LoRAs with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes() return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict: def find_duplicate_filenames(self) -> Dict:
"""Find LoRAs with conflicting filenames""" """Find LoRAs with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames() return self.scanner._hash_index.get_duplicate_filenames()
async def get_random_loras(
self,
count: int,
model_strength_min: float = 0.0,
model_strength_max: float = 1.0,
use_same_clip_strength: bool = True,
clip_strength_min: float = 0.0,
clip_strength_max: float = 1.0,
locked_loras: Optional[List[Dict]] = None,
pool_config: Optional[Dict] = None,
count_mode: str = "fixed",
count_min: int = 3,
count_max: int = 7,
use_recommended_strength: bool = False,
recommended_strength_scale_min: float = 0.5,
recommended_strength_scale_max: float = 1.0,
) -> List[Dict]:
"""
Get random LoRAs with specified strength ranges.
Args:
count: Number of LoRAs to select (if count_mode='fixed')
model_strength_min: Minimum model strength
model_strength_max: Maximum model strength
use_same_clip_strength: Whether to use same strength for clip
clip_strength_min: Minimum clip strength
clip_strength_max: Maximum clip strength
locked_loras: List of locked LoRA dicts to preserve
pool_config: Optional pool config for filtering
count_mode: How to determine count ('fixed' or 'range')
count_min: Minimum count for range mode
count_max: Maximum count for range mode
use_recommended_strength: Whether to use recommended strength from usage_tips
recommended_strength_scale_min: Minimum scale factor for recommended strength
recommended_strength_scale_max: Maximum scale factor for recommended strength
Returns:
List of LoRA dicts with randomized strengths
"""
import random
import json
def get_recommended_strength(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended strength"""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("strength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
def get_recommended_clip_strength(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended clip strength"""
try:
usage_tips = lora_data.get("usage_tips", "")
if not usage_tips:
return None
tips_data = json.loads(usage_tips)
return tips_data.get("clipStrength")
except (json.JSONDecodeError, TypeError, AttributeError):
return None
if locked_loras is None:
locked_loras = []
# Determine target count based on count_mode
if count_mode == "fixed":
target_count = count
else:
target_count = random.randint(count_min, count_max)
# Get available loras from cache
cache = await self.scanner.get_cached_data(force_refresh=False)
available_loras = cache.raw_data if cache else []
# Apply pool filters if provided
if pool_config:
available_loras = await self._apply_pool_filters(
available_loras, pool_config
)
# Calculate slots needed (total - locked)
locked_count = len(locked_loras)
slots_needed = target_count - locked_count
if slots_needed < 0:
slots_needed = 0
# Too many locked, trim to target
locked_loras = locked_loras[:target_count]
# Filter out locked LoRAs from available pool
locked_names = {lora["name"] for lora in locked_loras}
available_pool = [
l for l in available_loras if l["file_name"] not in locked_names
]
# Ensure we don't try to select more than available
if slots_needed > len(available_pool):
slots_needed = len(available_pool)
# Random sample
selected = []
if slots_needed > 0:
selected = random.sample(available_pool, slots_needed)
# Generate random strengths for selected LoRAs
result_loras = []
for lora in selected:
if use_recommended_strength:
recommended_strength = get_recommended_strength(lora)
if recommended_strength is not None:
scale = random.uniform(
recommended_strength_scale_min, recommended_strength_scale_max
)
model_str = round(recommended_strength * scale, 2)
else:
model_str = round(
random.uniform(model_strength_min, model_strength_max), 2
)
else:
model_str = round(
random.uniform(model_strength_min, model_strength_max), 2
)
if use_same_clip_strength:
clip_str = model_str
elif use_recommended_strength:
recommended_clip_strength = get_recommended_clip_strength(lora)
if recommended_clip_strength is not None:
scale = random.uniform(
recommended_strength_scale_min, recommended_strength_scale_max
)
clip_str = round(recommended_clip_strength * scale, 2)
else:
clip_str = round(
random.uniform(clip_strength_min, clip_strength_max), 2
)
else:
clip_str = round(
random.uniform(clip_strength_min, clip_strength_max), 2
)
result_loras.append(
{
"name": lora["file_name"],
"strength": model_str,
"clipStrength": clip_str,
"active": True,
"expanded": abs(model_str - clip_str) > 0.001,
"locked": False,
}
)
# Merge with locked LoRAs
result_loras.extend(locked_loras)
return result_loras
async def _apply_pool_filters(
self, available_loras: List[Dict], pool_config: Dict
) -> List[Dict]:
"""
Apply pool_config filters to available LoRAs.
Args:
available_loras: List of all LoRA dicts
pool_config: Dict with filter settings from LoRA Pool node
Returns:
Filtered list of LoRA dicts
"""
from .model_query import FilterCriteria
filter_section = pool_config
# Extract filter parameters
selected_base_models = filter_section.get("baseModels", [])
tags_dict = filter_section.get("tags", {})
include_tags = tags_dict.get("include", [])
exclude_tags = tags_dict.get("exclude", [])
folders_dict = filter_section.get("folders", {})
include_folders = folders_dict.get("include", [])
exclude_folders = folders_dict.get("exclude", [])
license_dict = filter_section.get("license", {})
no_credit_required = license_dict.get("noCreditRequired", False)
allow_selling = license_dict.get("allowSelling", False)
# Build tag filters dict
tag_filters = {}
for tag in include_tags:
tag_filters[tag] = "include"
for tag in exclude_tags:
tag_filters[tag] = "exclude"
# Build folder filter
if include_folders or exclude_folders:
filtered = []
for lora in available_loras:
folder = lora.get("folder", "")
# Check exclude folders first
excluded = False
for exclude_folder in exclude_folders:
if folder.startswith(exclude_folder):
excluded = True
break
if excluded:
continue
# Check include folders
if include_folders:
included = False
for include_folder in include_folders:
if folder.startswith(include_folder):
included = True
break
if not included:
continue
filtered.append(lora)
available_loras = filtered
# Apply base model filter
if selected_base_models:
available_loras = [
lora
for lora in available_loras
if lora.get("base_model") in selected_base_models
]
# Apply tag filters
if tag_filters:
criteria = FilterCriteria(tags=tag_filters)
available_loras = self.filter_set.apply(available_loras, criteria)
# Apply license filters
# no_credit_required=True means keep only models where credit is NOT required
# (i.e., allowNoCredit=True, which is bit 0 = 1 in license_flags)
if no_credit_required:
available_loras = [
lora
for lora in available_loras
if bool(lora.get("license_flags", 127) & (1 << 0))
]
# allow_selling=True means keep only models where selling generated content is allowed
if allow_selling:
available_loras = [
lora
for lora in available_loras
if bool(lora.get("license_flags", 127) & (1 << 1))
]
return available_loras

View File

@@ -76,7 +76,7 @@ class MetadataSyncService:
files = meta.get("files") files = meta.get("files")
images = meta.get("images") images = meta.get("images")
source = meta.get("source") source = meta.get("source")
return bool(files) and bool(images) and source != "archive_db" return bool(files) and bool(images) and source not in ("archive_db", "civarchive")
async def update_model_metadata( async def update_model_metadata(
self, self,
@@ -90,11 +90,11 @@ class MetadataSyncService:
existing_civitai = local_metadata.get("civitai") or {} existing_civitai = local_metadata.get("civitai") or {}
if ( if (
civitai_metadata.get("source") == "archive_db" not self.is_civitai_api_metadata(civitai_metadata)
and self.is_civitai_api_metadata(existing_civitai) and self.is_civitai_api_metadata(existing_civitai)
): ):
logger.info( logger.info(
"Skip civitai update for %s (%s)", "Skip civitai update for %s (%s) - existing metadata is higher quality",
local_metadata.get("model_name", ""), local_metadata.get("model_name", ""),
existing_civitai.get("name", ""), existing_civitai.get("name", ""),
) )

View File

@@ -1,4 +1,8 @@
import asyncio import asyncio
import time
import logging
logger = logging.getLogger(__name__)
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field from dataclasses import dataclass, field
from operator import itemgetter from operator import itemgetter
@@ -13,7 +17,10 @@ SUPPORTED_SORT_MODES = [
('date', 'desc'), ('date', 'desc'),
('size', 'asc'), ('size', 'asc'),
('size', 'desc'), ('size', 'desc'),
('usage', 'asc'),
('usage', 'desc'),
] ]
# Is this in use?
DISPLAY_NAME_MODES = {"model_name", "file_name"} DISPLAY_NAME_MODES = {"model_name", "file_name"}
@@ -212,40 +219,63 @@ class ModelCache:
def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]: def _sort_data(self, data: List[Dict], sort_key: str, order: str) -> List[Dict]:
"""Sort data by sort_key and order""" """Sort data by sort_key and order"""
start_time = time.perf_counter()
reverse = (order == 'desc') reverse = (order == 'desc')
if sort_key == 'name': if sort_key == 'name':
# Natural sort by configured display name, case-insensitive # Natural sort by configured display name, case-insensitive
return natsorted( result = natsorted(
data, data,
key=lambda x: self._get_display_name(x).lower(), key=lambda x: self._get_display_name(x).lower(),
reverse=reverse reverse=reverse
) )
elif sort_key == 'date': elif sort_key == 'date':
# Sort by modified timestamp # Sort by modified timestamp
return sorted( result = sorted(
data, data,
key=itemgetter('modified'), key=itemgetter('modified'),
reverse=reverse reverse=reverse
) )
elif sort_key == 'size': elif sort_key == 'size':
# Sort by file size # Sort by file size
return sorted( result = sorted(
data, data,
key=itemgetter('size'), key=itemgetter('size'),
reverse=reverse reverse=reverse
) )
elif sort_key == 'usage':
# Sort by usage count, fallback to 0, then name for stability
return sorted(
data,
key=lambda x: (
x.get('usage_count', 0),
self._get_display_name(x).lower()
),
reverse=reverse
)
else: else:
# Fallback: no sort # Fallback: no sort
return list(data) result = list(data)
duration = time.perf_counter() - start_time
if duration > 0.05:
logger.debug("ModelCache._sort_data(%s, %s) for %d items took %.3fs", sort_key, order, len(data), duration)
return result
async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]: async def get_sorted_data(self, sort_key: str = 'name', order: str = 'asc') -> List[Dict]:
"""Get sorted data by sort_key and order, using cache if possible""" """Get sorted data by sort_key and order, using cache if possible"""
async with self._lock: async with self._lock:
if (sort_key, order) == self._last_sort: if (sort_key, order) == self._last_sort:
return self._last_sorted_data return self._last_sorted_data
start_time = time.perf_counter()
sorted_data = self._sort_data(self.raw_data, sort_key, order) sorted_data = self._sort_data(self.raw_data, sort_key, order)
self._last_sort = (sort_key, order) self._last_sort = (sort_key, order)
self._last_sorted_data = sorted_data self._last_sorted_data = sorted_data
duration = time.perf_counter() - start_time
if duration > 0.1:
logger.debug("ModelCache.get_sorted_data(%s, %s) took %.3fs", sort_key, order, duration)
return sorted_data return sorted_data
async def update_name_display_mode(self, display_mode: str) -> None: async def update_name_display_mode(self, display_mode: str) -> None:

View File

@@ -36,11 +36,13 @@ class AutoOrganizeResult:
self.results_truncated: bool = False self.results_truncated: bool = False
self.sample_results: List[Dict[str, Any]] = [] self.sample_results: List[Dict[str, Any]] = []
self.is_flat_structure: bool = False self.is_flat_structure: bool = False
self.status: str = 'success'
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary""" """Convert result to dictionary"""
result = { result = {
'success': True, 'success': self.status != 'error',
'status': self.status,
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total', 'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
'summary': { 'summary': {
'total': self.total, 'total': self.total,
@@ -98,6 +100,8 @@ class ModelFileService:
result = AutoOrganizeResult() result = AutoOrganizeResult()
source_directories: Set[str] = set() source_directories: Set[str] = set()
self.scanner.reset_cancellation()
try: try:
# Get all models from cache # Get all models from cache
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -186,6 +190,21 @@ class ModelFileService:
progress_callback, progress_callback,
source_directories # Pass the set to track source directories source_directories # Pass the set to track source directories
) )
if self.scanner.is_cancelled():
result.status = 'cancelled'
if progress_callback:
await progress_callback.on_progress({
'type': 'auto_organize_progress',
'status': 'cancelled',
'total': result.total,
'processed': result.processed,
'success': result.success_count,
'failures': result.failure_count,
'skipped': result.skipped_count,
'operation_type': result.operation_type
})
return result
# Send cleanup progress # Send cleanup progress
if progress_callback: if progress_callback:
@@ -246,9 +265,15 @@ class ModelFileService:
"""Process models in batches to avoid overwhelming the system""" """Process models in batches to avoid overwhelming the system"""
for i in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE): for i in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
if self.scanner.is_cancelled():
logger.info(f"{self.model_type.capitalize()} File Service: Auto-organize cancelled by user")
break
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE] batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch: for model in batch:
if self.scanner.is_cancelled():
break
await self._process_single_model(model, model_roots, result, source_directories) await self._process_single_model(model, model_roots, result, source_directories)
result.processed += 1 result.processed += 1
@@ -446,25 +471,46 @@ class ModelFileService:
class ModelMoveService: class ModelMoveService:
"""Service for handling individual model moves""" """Service for handling individual model moves"""
def __init__(self, scanner): def __init__(self, scanner, model_type: str):
"""Initialize the service """Initialize the service
Args: Args:
scanner: Model scanner instance scanner: Model scanner instance
model_type: Type of model (e.g., 'lora', 'checkpoint')
""" """
self.scanner = scanner self.scanner = scanner
self.model_type = model_type
async def move_model(self, file_path: str, target_path: str) -> Dict[str, Any]: async def move_model(self, file_path: str, target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move a single model file """Move a single model file
Args: Args:
file_path: Source file path file_path: Source file path
target_path: Target directory path target_path: Target directory path (used as root if use_default_paths is True)
use_default_paths: Whether to use default path template for organization
Returns: Returns:
Dictionary with move result Dictionary with move result
""" """
try: try:
if use_default_paths:
# Find the model in cache to get metadata
cache = await self.scanner.get_cached_data()
model_data = next((m for m in cache.raw_data if m.get('file_path') == file_path), None)
if model_data:
from ..utils.utils import calculate_relative_path_for_model
relative_path = calculate_relative_path_for_model(model_data, self.model_type)
if relative_path:
target_path = os.path.join(target_path, relative_path).replace(os.sep, '/')
elif not get_settings_manager().get_download_path_template(self.model_type):
# Flat structure, target_path remains the root
pass
else:
# Could not calculate relative path (e.g. missing metadata)
# Fallback to manual target_path or skip?
pass
source_dir = os.path.dirname(file_path) source_dir = os.path.dirname(file_path)
if os.path.normpath(source_dir) == os.path.normpath(target_path): if os.path.normpath(source_dir) == os.path.normpath(target_path):
logger.info(f"Source and target directories are the same: {source_dir}") logger.info(f"Source and target directories are the same: {source_dir}")
@@ -475,12 +521,15 @@ class ModelMoveService:
'new_file_path': file_path 'new_file_path': file_path
} }
new_file_path = await self.scanner.move_model(file_path, target_path) move_result = await self.scanner.move_model(file_path, target_path)
if new_file_path: if move_result:
new_file_path = move_result.get("new_path")
cache_entry = move_result.get("cache_entry")
return { return {
'success': True, 'success': True,
'original_file_path': file_path, 'original_file_path': file_path,
'new_file_path': new_file_path 'new_file_path': new_file_path,
'cache_entry': cache_entry
} }
else: else:
return { return {
@@ -498,26 +547,32 @@ class ModelMoveService:
'new_file_path': None 'new_file_path': None
} }
async def move_models_bulk(self, file_paths: List[str], target_path: str) -> Dict[str, Any]: async def move_models_bulk(self, file_paths: List[str], target_path: str, use_default_paths: bool = False) -> Dict[str, Any]:
"""Move multiple model files """Move multiple model files
Args: Args:
file_paths: List of source file paths file_paths: List of source file paths
target_path: Target directory path target_path: Target directory path (used as root if use_default_paths is True)
use_default_paths: Whether to use default path template for organization
Returns: Returns:
Dictionary with bulk move results Dictionary with bulk move results
""" """
try: try:
results = [] results = []
self.scanner.reset_cancellation()
for file_path in file_paths: for file_path in file_paths:
result = await self.move_model(file_path, target_path) if self.scanner.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Move Service: Bulk move cancelled by user")
break
result = await self.move_model(file_path, target_path, use_default_paths=use_default_paths)
results.append({ results.append({
"original_file_path": file_path, "original_file_path": file_path,
"new_file_path": result.get('new_file_path'), "new_file_path": result.get('new_file_path'),
"success": result['success'], "success": result['success'],
"message": result.get('message', result.get('error', 'Unknown')) "message": result.get('message', result.get('error', 'Unknown')),
"cache_entry": result.get('cache_entry')
}) })
success_count = sum(1 for r in results if r["success"]) success_count = sum(1 for r in results if r["success"])

View File

@@ -1,10 +1,25 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Protocol, Callable from typing import (
Any,
Dict,
Iterable,
List,
Mapping,
Optional,
Sequence,
Tuple,
Protocol,
Callable,
)
from ..utils.constants import NSFW_LEVELS from ..utils.constants import NSFW_LEVELS
from ..utils.utils import fuzzy_match as default_fuzzy_match from ..utils.utils import fuzzy_match as default_fuzzy_match
import time
import logging
logger = logging.getLogger(__name__)
DEFAULT_CIVITAI_MODEL_TYPE = "LORA" DEFAULT_CIVITAI_MODEL_TYPE = "LORA"
@@ -47,8 +62,7 @@ def resolve_civitai_model_type(entry: Mapping[str, Any]) -> str:
class SettingsProvider(Protocol): class SettingsProvider(Protocol):
"""Protocol describing the SettingsManager contract used by query helpers.""" """Protocol describing the SettingsManager contract used by query helpers."""
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any: ...
...
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -64,6 +78,8 @@ class FilterCriteria:
"""Container for model list filtering options.""" """Container for model list filtering options."""
folder: Optional[str] = None folder: Optional[str] = None
folder_include: Optional[Sequence[str]] = None
folder_exclude: Optional[Sequence[str]] = None
base_models: Optional[Sequence[str]] = None base_models: Optional[Sequence[str]] = None
tags: Optional[Dict[str, str]] = None tags: Optional[Dict[str, str]] = None
favorites_only: bool = False favorites_only: bool = False
@@ -109,82 +125,222 @@ class ModelCacheRepository:
class ModelFilterSet: class ModelFilterSet:
"""Applies common filtering rules to the model collection.""" """Applies common filtering rules to the model collection."""
def __init__(self, settings: SettingsProvider, nsfw_levels: Optional[Dict[str, int]] = None) -> None: def __init__(
self, settings: SettingsProvider, nsfw_levels: Optional[Dict[str, int]] = None
) -> None:
self._settings = settings self._settings = settings
self._nsfw_levels = nsfw_levels or NSFW_LEVELS self._nsfw_levels = nsfw_levels or NSFW_LEVELS
def apply(self, data: Iterable[Dict[str, Any]], criteria: FilterCriteria) -> List[Dict[str, Any]]: def apply(
self, data: Iterable[Dict[str, Any]], criteria: FilterCriteria
) -> List[Dict[str, Any]]:
"""Return items that satisfy the provided criteria.""" """Return items that satisfy the provided criteria."""
overall_start = time.perf_counter()
items = list(data) items = list(data)
initial_count = len(items)
if self._settings.get("show_only_sfw", False): if self._settings.get("show_only_sfw", False):
t0 = time.perf_counter()
threshold = self._nsfw_levels.get("R", 0) threshold = self._nsfw_levels.get("R", 0)
items = [ items = [
item for item in items item
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold for item in items
if not item.get("preview_nsfw_level")
or item.get("preview_nsfw_level") < threshold
] ]
sfw_duration = time.perf_counter() - t0
else:
sfw_duration = 0
favorites_duration = 0
if criteria.favorites_only: if criteria.favorites_only:
t0 = time.perf_counter()
items = [item for item in items if item.get("favorite", False)] items = [item for item in items if item.get("favorite", False)]
favorites_duration = time.perf_counter() - t0
folder_duration = 0
folder = criteria.folder folder = criteria.folder
folder_include = criteria.folder_include or []
folder_exclude = criteria.folder_exclude or []
options = criteria.search_options or {} options = criteria.search_options or {}
recursive = bool(options.get("recursive", True)) recursive = bool(options.get("recursive", True))
# Apply folder exclude filters first
if folder_exclude:
t0 = time.perf_counter()
for exclude_folder in folder_exclude:
if exclude_folder:
# Check exact match OR prefix match (for subfolders)
# Normalize exclude_folder for prefix matching
if not exclude_folder.endswith("/"):
exclude_prefix = f"{exclude_folder}/"
else:
exclude_prefix = exclude_folder
items = [
item
for item in items
if item.get("folder") != exclude_folder
and not item.get("folder", "").startswith(exclude_prefix)
]
folder_duration = time.perf_counter() - t0
# Apply folder include filters
if folder is not None: if folder is not None:
t0 = time.perf_counter()
if recursive: if recursive:
if folder: if folder:
folder_with_sep = f"{folder}/" folder_with_sep = f"{folder}/"
items = [ items = [
item for item in items item
if item.get("folder") == folder or item.get("folder", "").startswith(folder_with_sep) for item in items
if item.get("folder") == folder
or item.get("folder", "").startswith(folder_with_sep)
] ]
else: else:
items = [item for item in items if item.get("folder") == folder] items = [item for item in items if item.get("folder") == folder]
folder_duration = time.perf_counter() - t0 + folder_duration
# Apply folder include filters
if folder_include:
t0 = time.perf_counter()
matched_items = []
for include_folder in folder_include:
if include_folder:
if recursive:
# Normalize folder for prefix matching (similar to exclude logic)
if not include_folder.endswith("/"):
folder_prefix = f"{include_folder}/"
else:
folder_prefix = include_folder
folder_items = [
item
for item in items
if item.get("folder") == include_folder
or item.get("folder", "").startswith(folder_prefix)
]
else:
folder_items = [
item
for item in items
if item.get("folder") == include_folder
]
matched_items.extend(folder_items)
# Remove duplicates while preserving order
seen = set()
items = []
for item in matched_items:
# Use sha256 or id as unique identifier if available, otherwise use tuple representation
item_id = item.get("sha256") or item.get("id")
if item_id is not None:
identifier = item_id
else:
# For items without explicit id, use a tuple of key values
identifier = tuple(sorted((k, str(v)) for k, v in item.items()))
if identifier not in seen:
seen.add(identifier)
items.append(item)
folder_duration = time.perf_counter() - t0 + folder_duration
# Apply folder include filters (legacy single folder)
elif folder is not None:
t0 = time.perf_counter()
if recursive:
if folder:
# Normalize folder for prefix matching
if not folder.endswith("/"):
folder_prefix = f"{folder}/"
else:
folder_prefix = folder
items = [
item
for item in items
if item.get("folder") == folder
or item.get("folder", "").startswith(folder_prefix)
]
else:
items = [item for item in items if item.get("folder") == folder]
folder_duration = time.perf_counter() - t0 + folder_duration
base_models_duration = 0
base_models = criteria.base_models or [] base_models = criteria.base_models or []
if base_models: if base_models:
t0 = time.perf_counter()
base_model_set = set(base_models) base_model_set = set(base_models)
items = [item for item in items if item.get("base_model") in base_model_set] items = [item for item in items if item.get("base_model") in base_model_set]
base_models_duration = time.perf_counter() - t0
tags_duration = 0
tag_filters = criteria.tags or {} tag_filters = criteria.tags or {}
include_tags = set() if tag_filters:
exclude_tags = set() t0 = time.perf_counter()
if isinstance(tag_filters, dict): include_tags = set()
for tag, state in tag_filters.items(): exclude_tags = set()
if not tag: if isinstance(tag_filters, dict):
continue for tag, state in tag_filters.items():
if state == "exclude": if not tag:
exclude_tags.add(tag) continue
else: if state == "exclude":
include_tags.add(tag) exclude_tags.add(tag)
else: else:
include_tags = {tag for tag in tag_filters if tag} include_tags.add(tag)
else:
include_tags = {tag for tag in tag_filters if tag}
if include_tags: if include_tags:
items = [
item for item in items
if any(tag in include_tags for tag in (item.get("tags", []) or []))
]
if exclude_tags: def matches_include(item_tags):
items = [ if not item_tags and "__no_tags__" in include_tags:
item for item in items return True
if not any(tag in exclude_tags for tag in (item.get("tags", []) or [])) return any(tag in include_tags for tag in (item_tags or []))
]
items = [item for item in items if matches_include(item.get("tags"))]
if exclude_tags:
def matches_exclude(item_tags):
if not item_tags and "__no_tags__" in exclude_tags:
return True
return any(tag in exclude_tags for tag in (item_tags or []))
items = [
item for item in items if not matches_exclude(item.get("tags"))
]
tags_duration = time.perf_counter() - t0
model_types_duration = 0
model_types = criteria.model_types or [] model_types = criteria.model_types or []
normalized_model_types = { if model_types:
model_type for model_type in ( t0 = time.perf_counter()
normalize_civitai_model_type(value) for value in model_types normalized_model_types = {
) model_type
if model_type for model_type in (
} normalize_civitai_model_type(value) for value in model_types
if normalized_model_types: )
items = [ if model_type
item for item in items }
if normalize_civitai_model_type(resolve_civitai_model_type(item)) in normalized_model_types if normalized_model_types:
] items = [
item
for item in items
if normalize_civitai_model_type(resolve_civitai_model_type(item))
in normalized_model_types
]
model_types_duration = time.perf_counter() - t0
duration = time.perf_counter() - overall_start
if duration > 0.1: # Only log if it's potentially slow
logger.debug(
"ModelFilterSet.apply took %.3fs (sfw: %.3fs, fav: %.3fs, folder: %.3fs, base: %.3fs, tags: %.3fs, types: %.3fs). "
"Count: %d -> %d",
duration,
sfw_duration,
favorites_duration,
folder_duration,
base_models_duration,
tags_duration,
model_types_duration,
initial_count,
len(items),
)
return items return items
@@ -199,7 +355,9 @@ class SearchStrategy:
"creator": False, "creator": False,
} }
def __init__(self, fuzzy_matcher: Optional[Callable[[str, str], bool]] = None) -> None: def __init__(
self, fuzzy_matcher: Optional[Callable[[str, str], bool]] = None
) -> None:
self._fuzzy_match = fuzzy_matcher or default_fuzzy_match self._fuzzy_match = fuzzy_matcher or default_fuzzy_match
def normalize_options(self, options: Optional[Dict[str, Any]]) -> Dict[str, Any]: def normalize_options(self, options: Optional[Dict[str, Any]]) -> Dict[str, Any]:
@@ -238,7 +396,9 @@ class SearchStrategy:
if options.get("tags", False): if options.get("tags", False):
tags = item.get("tags", []) or [] tags = item.get("tags", []) or []
if any(self._matches(tag, search_term, search_lower, fuzzy) for tag in tags): if any(
self._matches(tag, search_term, search_lower, fuzzy) for tag in tags
):
results.append(item) results.append(item)
continue continue
@@ -249,13 +409,17 @@ class SearchStrategy:
creator = civitai.get("creator") creator = civitai.get("creator")
if isinstance(creator, dict): if isinstance(creator, dict):
creator_username = creator.get("username", "") creator_username = creator.get("username", "")
if creator_username and self._matches(creator_username, search_term, search_lower, fuzzy): if creator_username and self._matches(
creator_username, search_term, search_lower, fuzzy
):
results.append(item) results.append(item)
continue continue
return results return results
def _matches(self, candidate: str, search_term: str, search_lower: str, fuzzy: bool) -> bool: def _matches(
self, candidate: str, search_term: str, search_lower: str, fuzzy: bool
) -> bool:
if not isinstance(candidate, str): if not isinstance(candidate, str):
candidate = "" if candidate is None else str(candidate) candidate = "" if candidate is None else str(candidate)

View File

@@ -84,6 +84,7 @@ class ModelScanner:
self._excluded_models = [] # List to track excluded models self._excluded_models = [] # List to track excluded models
self._persistent_cache = get_persistent_cache() self._persistent_cache = get_persistent_cache()
self._name_display_mode = self._resolve_name_display_mode() self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
except RuntimeError: except RuntimeError:
@@ -653,6 +654,11 @@ class ModelScanner:
self._is_initializing = True # Set flag self._is_initializing = True # Set flag
try: try:
start_time = time.time() start_time = time.time()
# Manually trigger a symlink rescan during a full rebuild.
# This ensures that any new symlink mappings are correctly picked up.
config.rebuild_symlink_cache()
# Determine the page type based on model type # Determine the page type based on model type
# Scan for new data # Scan for new data
scan_result = await self._gather_model_data() scan_result = await self._gather_model_data()
@@ -678,6 +684,7 @@ class ModelScanner:
async def _reconcile_cache(self) -> None: async def _reconcile_cache(self) -> None:
"""Fast cache reconciliation - only process differences between cache and filesystem""" """Fast cache reconciliation - only process differences between cache and filesystem"""
self.reset_cancellation()
self._is_initializing = True # Set flag for reconciliation duration self._is_initializing = True # Set flag for reconciliation duration
try: try:
start_time = time.time() start_time = time.time()
@@ -737,6 +744,9 @@ class ModelScanner:
# Yield control periodically # Yield control periodically
await asyncio.sleep(0) await asyncio.sleep(0)
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
return
# Process new files in batches # Process new files in batches
total_added = 0 total_added = 0
@@ -784,6 +794,10 @@ class ModelScanner:
logger.error(f"Could not determine root path for {path}") logger.error(f"Could not determine root path for {path}")
except Exception as e: except Exception as e:
logger.error(f"Error adding {path} to cache: {e}") logger.error(f"Error adding {path} to cache: {e}")
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled")
return
# Find missing files (in cache but not in filesystem) # Find missing files (in cache but not in filesystem)
missing_files = cached_paths - found_paths missing_files = cached_paths - found_paths
@@ -838,6 +852,19 @@ class ModelScanner:
"""Check if the scanner is currently initializing""" """Check if the scanner is currently initializing"""
return self._is_initializing return self._is_initializing
def cancel_task(self) -> None:
"""Request cancellation of the current long-running task."""
self._cancel_requested = True
logger.info(f"{self.model_type.capitalize()} Scanner: Cancellation requested")
def reset_cancellation(self) -> None:
"""Reset the cancellation flag."""
self._cancel_requested = False
def is_cancelled(self) -> bool:
"""Check if cancellation has been requested."""
return self._cancel_requested
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get model root directories""" """Get model root directories"""
raise NotImplementedError("Subclasses must implement get_model_roots") raise NotImplementedError("Subclasses must implement get_model_roots")
@@ -927,7 +954,7 @@ class ModelScanner:
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path) metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path)) metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
await MetadataManager.save_metadata(file_path, metadata) await MetadataManager.save_metadata(file_path, metadata)
logger.debug(f"Created metadata from .civitai.info for {file_path}") logger.info(f"Created metadata from .civitai.info for {file_path} (Reason: .civitai.info was found but .metadata.json was missing)")
except Exception as e: except Exception as e:
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}") logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
else: else:
@@ -1030,6 +1057,8 @@ class ModelScanner:
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error reporting progress for {self.model_type}: {exc}") logger.error(f"Error reporting progress for {self.model_type}: {exc}")
self.reset_cancellation()
async def scan_recursive(current_path: str, root_path: str, visited_paths: Set[str]) -> None: async def scan_recursive(current_path: str, root_path: str, visited_paths: Set[str]) -> None:
nonlocal processed_files nonlocal processed_files
@@ -1073,6 +1102,8 @@ class ModelScanner:
await handle_progress() await handle_progress()
await asyncio.sleep(0) await asyncio.sleep(0)
if self.is_cancelled():
return
elif entry.is_dir(follow_symlinks=True): elif entry.is_dir(follow_symlinks=True):
await scan_recursive(entry.path, root_path, visited_paths) await scan_recursive(entry.path, root_path, visited_paths)
except Exception as entry_error: except Exception as entry_error:
@@ -1080,6 +1111,9 @@ class ModelScanner:
except Exception as scan_error: except Exception as scan_error:
logger.error(f"Error scanning {current_path}: {scan_error}") logger.error(f"Error scanning {current_path}: {scan_error}")
if self.is_cancelled():
return
for model_root in self.get_model_roots(): for model_root in self.get_model_roots():
if not os.path.exists(model_root): if not os.path.exists(model_root):
continue continue
@@ -1216,9 +1250,12 @@ class ModelScanner:
except Exception as e: except Exception as e:
logger.error(f"Error moving metadata file: {e}") logger.error(f"Error moving metadata file: {e}")
await self.update_single_model_cache(source_path, target_file, metadata) update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
return target_file return {
"new_path": target_file,
"cache_entry": update_result if isinstance(update_result, dict) else None
}
except Exception as e: except Exception as e:
logger.error(f"Error moving model: {e}", exc_info=True) logger.error(f"Error moving model: {e}", exc_info=True)
@@ -1250,7 +1287,7 @@ class ModelScanner:
logger.error(f"Error updating metadata paths: {e}", exc_info=True) logger.error(f"Error updating metadata paths: {e}", exc_info=True)
return None return None
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool: async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Dict, recalculate_type: bool = False) -> Union[bool, Dict]:
"""Update cache after a model has been moved or modified""" """Update cache after a model has been moved or modified"""
cache = await self.get_cached_data() cache = await self.get_cached_data()
@@ -1287,6 +1324,9 @@ class ModelScanner:
file_path_override=normalized_new_path, file_path_override=normalized_new_path,
) )
if recalculate_type:
cache_entry = self.adjust_cached_entry(cache_entry)
cache.raw_data.append(cache_entry) cache.raw_data.append(cache_entry)
cache.add_to_version_index(cache_entry) cache.add_to_version_index(cache_entry)
@@ -1307,7 +1347,7 @@ class ModelScanner:
if cache_modified: if cache_modified:
await self._persist_current_cache() await self._persist_current_cache()
return True return cache_entry if metadata else True
def has_hash(self, sha256: str) -> bool: def has_hash(self, sha256: str) -> bool:
"""Check if a model with given hash exists""" """Check if a model with given hash exists"""
@@ -1442,6 +1482,10 @@ class ModelScanner:
deleted_models = [] deleted_models = []
for file_path in file_paths: for file_path in file_paths:
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
break
try: try:
target_dir = os.path.dirname(file_path) target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path) base_name = os.path.basename(file_path)
@@ -1482,6 +1526,7 @@ class ModelScanner:
return { return {
'success': True, 'success': True,
'status': 'cancelled' if self.is_cancelled() else 'success',
'total_deleted': total_deleted, 'total_deleted': total_deleted,
'total_attempted': len(file_paths), 'total_attempted': len(file_paths),
'cache_updated': cache_updated, 'cache_updated': cache_updated,

View File

@@ -22,7 +22,6 @@ class ModelServiceFactory:
""" """
cls._services[model_type] = service_class cls._services[model_type] = service_class
cls._routes[model_type] = route_class cls._routes[model_type] = route_class
logger.info(f"Registered model type '{model_type}' with service {service_class.__name__} and routes {route_class.__name__}")
@classmethod @classmethod
def get_service_class(cls, model_type: str) -> Type: def get_service_class(cls, model_type: str) -> Type:
@@ -80,13 +79,10 @@ class ModelServiceFactory:
Args: Args:
app: The aiohttp application instance app: The aiohttp application instance
""" """
logger.info(f"Setting up routes for {len(cls._services)} registered model types")
for model_type in cls._services.keys(): for model_type in cls._services.keys():
try: try:
routes_instance = cls.get_route_instance(model_type) routes_instance = cls.get_route_instance(model_type)
routes_instance.setup_routes(app) routes_instance.setup_routes(app)
logger.info(f"Successfully set up routes for {model_type}")
except Exception as e: except Exception as e:
logger.error(f"Failed to setup routes for {model_type}: {e}", exc_info=True) logger.error(f"Failed to setup routes for {model_type}: {e}", exc_info=True)
@@ -137,6 +133,4 @@ def register_default_model_types():
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes) ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
# Register Embedding model type # Register Embedding model type
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes) ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
logger.info("Registered default model types: lora, checkpoint, embedding")

View File

@@ -466,6 +466,7 @@ class ModelUpdateService:
target_model_ids: Optional[Sequence[int]] = None, target_model_ids: Optional[Sequence[int]] = None,
) -> Dict[int, ModelUpdateRecord]: ) -> Dict[int, ModelUpdateRecord]:
"""Refresh update information for every model present in the cache.""" """Refresh update information for every model present in the cache."""
scanner.reset_cancellation()
normalized_targets = ( normalized_targets = (
self._normalize_sequence(target_model_ids) self._normalize_sequence(target_model_ids)
@@ -542,6 +543,9 @@ class ModelUpdateService:
force_refresh=force_refresh, force_refresh=force_refresh,
prefetched_response=prefetched.get(model_id), prefetched_response=prefetched.get(model_id),
) )
if scanner.is_cancelled():
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
return results
if record: if record:
results[model_id] = record results[model_id] = record
if index % progress_interval == 0 or index == total_models: if index % progress_interval == 0 or index == total_models:
@@ -585,6 +589,8 @@ class ModelUpdateService:
model_type: str, model_type: str,
model_id: int, model_id: int,
version_ids: Sequence[int], version_ids: Sequence[int],
*,
version_info: Optional[Mapping] = None,
) -> ModelUpdateRecord: ) -> ModelUpdateRecord:
"""Persist a new set of in-library version identifiers.""" """Persist a new set of in-library version identifiers."""
@@ -596,6 +602,7 @@ class ModelUpdateService:
normalized_versions, normalized_versions,
model_type=model_type, model_type=model_type,
model_id=model_id, model_id=model_id,
version_info=version_info,
) )
self._upsert_record(record) self._upsert_record(record)
return record return record
@@ -940,6 +947,7 @@ class ModelUpdateService:
model_type: Optional[str] = None, model_type: Optional[str] = None,
model_id: Optional[int] = None, model_id: Optional[int] = None,
last_checked_at: Optional[float] = None, last_checked_at: Optional[float] = None,
version_info: Optional[Mapping] = None,
) -> ModelUpdateRecord: ) -> ModelUpdateRecord:
local_set = set(normalized_local) local_set = set(normalized_local)
versions: List[ModelVersionRecord] = [] versions: List[ModelVersionRecord] = []
@@ -961,19 +969,26 @@ class ModelUpdateService:
seen_ids = {version.version_id for version in versions} seen_ids = {version.version_id for version in versions}
for missing_id in sorted(local_set - seen_ids): for missing_id in sorted(local_set - seen_ids):
versions.append( new_version: Optional[ModelVersionRecord] = None
ModelVersionRecord( if version_info and _normalize_int(version_info.get("id")) == missing_id:
version_id=missing_id, new_version = self._extract_single_version(version_info, index=len(versions))
name=None,
base_model=None, if new_version:
released_at=None, versions.append(replace(new_version, is_in_library=True))
size_bytes=None, else:
preview_url=None, versions.append(
is_in_library=True, ModelVersionRecord(
should_ignore=ignore_map.get(missing_id, False), version_id=missing_id,
sort_index=len(versions), name=None,
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=True,
should_ignore=ignore_map.get(missing_id, False),
sort_index=len(versions),
)
) )
)
return ModelUpdateRecord( return ModelUpdateRecord(
model_type=model_type, model_type=model_type,
@@ -1079,33 +1094,45 @@ class ModelUpdateService:
return [] return []
if not isinstance(versions, Iterable): if not isinstance(versions, Iterable):
return None return None
extracted: List[ModelVersionRecord] = [] extracted: List[ModelVersionRecord] = []
for index, entry in enumerate(versions): for index, entry in enumerate(versions):
if not isinstance(entry, Mapping): version_record = self._extract_single_version(entry, index)
continue if version_record:
version_id = _normalize_int(entry.get("id")) extracted.append(version_record)
if version_id is None:
continue
name = _normalize_string(entry.get("name"))
base_model = _normalize_string(entry.get("baseModel"))
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
size_bytes = self._extract_size_bytes(entry.get("files"))
preview_url = self._extract_preview_url(entry.get("images"))
extracted.append(
ModelVersionRecord(
version_id=version_id,
name=name,
base_model=base_model,
released_at=released_at,
size_bytes=size_bytes,
preview_url=preview_url,
is_in_library=False,
should_ignore=False,
sort_index=index,
)
)
return extracted return extracted
def _extract_single_version(
self, entry: Any, index: int = 0
) -> Optional[ModelVersionRecord]:
"""Convert a raw metadata entry into a structured record."""
if not isinstance(entry, Mapping):
return None
version_id = _normalize_int(entry.get("id"))
if version_id is None:
return None
name = _normalize_string(entry.get("name"))
base_model = _normalize_string(entry.get("baseModel"))
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
size_bytes = self._extract_size_bytes(entry.get("files"))
preview_url = self._extract_preview_url(entry.get("images"))
return ModelVersionRecord(
version_id=version_id,
name=name,
base_model=base_model,
released_at=released_at,
size_bytes=size_bytes,
preview_url=preview_url,
is_in_library=False,
should_ignore=False,
sort_index=index,
)
def _extract_size_bytes(self, files) -> Optional[int]: def _extract_size_bytes(self, files) -> Optional[int]:
if not isinstance(files, Iterable): if not isinstance(files, Iterable):
return None return None

View File

@@ -7,12 +7,18 @@ from natsort import natsorted
@dataclass @dataclass
class RecipeCache: class RecipeCache:
"""Cache structure for Recipe data""" """Cache structure for Recipe data"""
raw_data: List[Dict] raw_data: List[Dict]
sorted_by_name: List[Dict] sorted_by_name: List[Dict]
sorted_by_date: List[Dict] sorted_by_date: List[Dict]
folders: List[str] | None = None
folder_tree: Dict | None = None
def __post_init__(self): def __post_init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# Normalize optional metadata containers
self.folders = self.folders or []
self.folder_tree = self.folder_tree or {}
async def resort(self, name_only: bool = False): async def resort(self, name_only: bool = False):
"""Resort all cached data views""" """Resort all cached data views"""

View File

@@ -0,0 +1,547 @@
"""SQLite FTS5-based full-text search index for recipes.
This module provides fast recipe search using SQLite's FTS5 extension,
enabling sub-100ms search times even with 20k+ recipes.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import sqlite3
import threading
import time
from typing import Any, Dict, List, Optional, Set
from ..utils.settings_paths import get_settings_dir
logger = logging.getLogger(__name__)
class RecipeFTSIndex:
"""SQLite FTS5-based full-text search index for recipes.
Provides fast prefix-based search across multiple recipe fields:
- title
- tags
- lora_names (file names)
- lora_models (model names)
- prompt
- negative_prompt
"""
_DEFAULT_FILENAME = "recipe_fts.sqlite"
# Map of search option keys to FTS column names
FIELD_MAP = {
'title': ['title'],
'tags': ['tags'],
'lora_name': ['lora_names'],
'lora_model': ['lora_models'],
'prompt': ['prompt', 'negative_prompt'],
}
def __init__(self, db_path: Optional[str] = None) -> None:
"""Initialize the FTS index.
Args:
db_path: Optional path to the SQLite database file.
If not provided, uses the default location in settings directory.
"""
self._db_path = db_path or self._resolve_default_path()
self._lock = threading.Lock()
self._ready = threading.Event()
self._indexing_in_progress = False
self._schema_initialized = False
self._warned_not_ready = False
# Ensure directory exists
try:
directory = os.path.dirname(self._db_path)
if directory:
os.makedirs(directory, exist_ok=True)
except Exception as exc:
logger.warning("Could not create FTS index directory %s: %s", directory, exc)
def _resolve_default_path(self) -> str:
"""Resolve the default database path."""
override = os.environ.get("LORA_MANAGER_RECIPE_FTS_DB")
if override:
return override
try:
settings_dir = get_settings_dir(create=True)
except Exception as exc:
logger.warning("Falling back to current directory for FTS index: %s", exc)
settings_dir = "."
return os.path.join(settings_dir, self._DEFAULT_FILENAME)
def get_database_path(self) -> str:
"""Return the resolved database path."""
return self._db_path
def is_ready(self) -> bool:
"""Check if the FTS index is ready for queries."""
return self._ready.is_set()
def is_indexing(self) -> bool:
"""Check if indexing is currently in progress."""
return self._indexing_in_progress
def initialize(self) -> None:
"""Initialize the database schema."""
if self._schema_initialized:
return
with self._lock:
if self._schema_initialized:
return
try:
conn = self._connect()
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.executescript("""
-- FTS5 virtual table for full-text search
-- Note: We use a regular FTS5 table (not contentless) so we can retrieve recipe_id
CREATE VIRTUAL TABLE IF NOT EXISTS recipe_fts USING fts5(
recipe_id,
title,
tags,
lora_names,
lora_models,
prompt,
negative_prompt,
tokenize='unicode61 remove_diacritics 2'
);
-- Recipe ID to rowid mapping for fast lookups and deletions
CREATE TABLE IF NOT EXISTS recipe_rowid (
recipe_id TEXT PRIMARY KEY,
fts_rowid INTEGER UNIQUE
);
-- Index version tracking
CREATE TABLE IF NOT EXISTS fts_metadata (
key TEXT PRIMARY KEY,
value TEXT
);
""")
conn.commit()
self._schema_initialized = True
logger.debug("FTS index schema initialized at %s", self._db_path)
finally:
conn.close()
except Exception as exc:
logger.error("Failed to initialize FTS schema: %s", exc)
def build_index(self, recipes: List[Dict[str, Any]]) -> None:
"""Build or rebuild the entire FTS index from recipe data.
Args:
recipes: List of recipe dictionaries to index.
"""
if self._indexing_in_progress:
logger.warning("FTS indexing already in progress, skipping")
return
self._indexing_in_progress = True
self._ready.clear()
start_time = time.time()
try:
self.initialize()
if not self._schema_initialized:
logger.error("Cannot build FTS index: schema not initialized")
return
with self._lock:
conn = self._connect()
try:
conn.execute("BEGIN")
# Clear existing data
conn.execute("DELETE FROM recipe_fts")
conn.execute("DELETE FROM recipe_rowid")
# Batch insert for performance
batch_size = 500
total = len(recipes)
inserted = 0
for i in range(0, total, batch_size):
batch = recipes[i:i + batch_size]
rows = []
rowid_mappings = []
for recipe in batch:
recipe_id = str(recipe.get('id', ''))
if not recipe_id:
continue
row = self._prepare_fts_row(recipe)
rows.append(row)
inserted += 1
if rows:
# Insert into FTS table
conn.executemany(
"""INSERT INTO recipe_fts (recipe_id, title, tags, lora_names,
lora_models, prompt, negative_prompt)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
rows
)
# Build rowid mappings
for row in rows:
recipe_id = row[0]
cursor = conn.execute(
"SELECT rowid FROM recipe_fts WHERE recipe_id = ?",
(recipe_id,)
)
result = cursor.fetchone()
if result:
rowid_mappings.append((recipe_id, result[0]))
if rowid_mappings:
conn.executemany(
"INSERT OR REPLACE INTO recipe_rowid (recipe_id, fts_rowid) VALUES (?, ?)",
rowid_mappings
)
# Update metadata
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
('last_build_time', str(time.time()))
)
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
('recipe_count', str(inserted))
)
conn.commit()
elapsed = time.time() - start_time
logger.info("FTS index built: %d recipes indexed in %.2fs", inserted, elapsed)
finally:
conn.close()
self._ready.set()
except Exception as exc:
logger.error("Failed to build FTS index: %s", exc, exc_info=True)
finally:
self._indexing_in_progress = False
def search(self, query: str, fields: Optional[Set[str]] = None) -> Set[str]:
"""Search recipes using FTS5 with prefix matching.
Args:
query: The search query string.
fields: Optional set of field names to search. If None, searches all fields.
Valid fields: 'title', 'tags', 'lora_name', 'lora_model', 'prompt'
Returns:
Set of matching recipe IDs.
"""
if not self.is_ready():
if not self._warned_not_ready:
logger.debug("FTS index not ready, returning empty results")
self._warned_not_ready = True
return set()
if not query or not query.strip():
return set()
fts_query = self._build_fts_query(query, fields)
if not fts_query:
return set()
try:
with self._lock:
conn = self._connect(readonly=True)
try:
cursor = conn.execute(
"SELECT recipe_id FROM recipe_fts WHERE recipe_fts MATCH ?",
(fts_query,)
)
return {row[0] for row in cursor.fetchall()}
finally:
conn.close()
except Exception as exc:
logger.debug("FTS search error for query '%s': %s", query, exc)
return set()
def add_recipe(self, recipe: Dict[str, Any]) -> bool:
"""Add a single recipe to the FTS index.
Args:
recipe: The recipe dictionary to add.
Returns:
True if successful, False otherwise.
"""
if not self.is_ready():
return False
recipe_id = str(recipe.get('id', ''))
if not recipe_id:
return False
try:
with self._lock:
conn = self._connect()
try:
# Remove existing entry if present
self._remove_recipe_locked(conn, recipe_id)
# Insert new entry
row = self._prepare_fts_row(recipe)
conn.execute(
"""INSERT INTO recipe_fts (recipe_id, title, tags, lora_names,
lora_models, prompt, negative_prompt)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
row
)
# Update rowid mapping
cursor = conn.execute(
"SELECT rowid FROM recipe_fts WHERE recipe_id = ?",
(recipe_id,)
)
result = cursor.fetchone()
if result:
conn.execute(
"INSERT OR REPLACE INTO recipe_rowid (recipe_id, fts_rowid) VALUES (?, ?)",
(recipe_id, result[0])
)
conn.commit()
return True
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to add recipe %s to FTS index: %s", recipe_id, exc)
return False
def remove_recipe(self, recipe_id: str) -> bool:
"""Remove a recipe from the FTS index.
Args:
recipe_id: The ID of the recipe to remove.
Returns:
True if successful, False otherwise.
"""
if not self.is_ready():
return False
if not recipe_id:
return False
try:
with self._lock:
conn = self._connect()
try:
self._remove_recipe_locked(conn, recipe_id)
conn.commit()
return True
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to remove recipe %s from FTS index: %s", recipe_id, exc)
return False
def update_recipe(self, recipe: Dict[str, Any]) -> bool:
"""Update a recipe in the FTS index.
Args:
recipe: The updated recipe dictionary.
Returns:
True if successful, False otherwise.
"""
return self.add_recipe(recipe) # add_recipe handles removal and re-insertion
def clear(self) -> bool:
"""Clear all data from the FTS index.
Returns:
True if successful, False otherwise.
"""
try:
with self._lock:
conn = self._connect()
try:
conn.execute("DELETE FROM recipe_fts")
conn.execute("DELETE FROM recipe_rowid")
conn.commit()
self._ready.clear()
return True
finally:
conn.close()
except Exception as exc:
logger.error("Failed to clear FTS index: %s", exc)
return False
def get_indexed_count(self) -> int:
"""Return the number of recipes currently indexed."""
if not self._schema_initialized:
return 0
try:
with self._lock:
conn = self._connect(readonly=True)
try:
cursor = conn.execute("SELECT COUNT(*) FROM recipe_fts")
result = cursor.fetchone()
return result[0] if result else 0
finally:
conn.close()
except Exception:
return 0
# Internal helpers
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection."""
uri = False
path = self._db_path
if readonly:
if not os.path.exists(path):
raise FileNotFoundError(path)
path = f"file:{path}?mode=ro"
uri = True
conn = sqlite3.connect(path, check_same_thread=False, uri=uri)
conn.row_factory = sqlite3.Row
return conn
def _remove_recipe_locked(self, conn: sqlite3.Connection, recipe_id: str) -> None:
"""Remove a recipe entry. Caller must hold the lock."""
# Get the rowid for deletion
cursor = conn.execute(
"SELECT fts_rowid FROM recipe_rowid WHERE recipe_id = ?",
(recipe_id,)
)
result = cursor.fetchone()
if result:
fts_rowid = result[0]
# Delete from FTS using rowid
conn.execute(
"DELETE FROM recipe_fts WHERE rowid = ?",
(fts_rowid,)
)
# Also try direct delete by recipe_id (handles edge cases)
conn.execute(
"DELETE FROM recipe_fts WHERE recipe_id = ?",
(recipe_id,)
)
conn.execute(
"DELETE FROM recipe_rowid WHERE recipe_id = ?",
(recipe_id,)
)
def _prepare_fts_row(self, recipe: Dict[str, Any]) -> tuple:
"""Prepare a row tuple for FTS insertion."""
recipe_id = str(recipe.get('id', ''))
title = str(recipe.get('title', ''))
# Extract tags as space-separated string
tags_list = recipe.get('tags', [])
tags = ' '.join(str(t) for t in tags_list if t) if tags_list else ''
# Extract LoRA file names and model names
loras = recipe.get('loras', [])
lora_names = []
lora_models = []
for lora in loras:
if isinstance(lora, dict):
file_name = lora.get('file_name', '')
if file_name:
lora_names.append(str(file_name))
model_name = lora.get('modelName', '')
if model_name:
lora_models.append(str(model_name))
lora_names_str = ' '.join(lora_names)
lora_models_str = ' '.join(lora_models)
# Extract prompts from gen_params
gen_params = recipe.get('gen_params', {})
prompt = str(gen_params.get('prompt', '')) if gen_params else ''
negative_prompt = str(gen_params.get('negative_prompt', '')) if gen_params else ''
return (recipe_id, title, tags, lora_names_str, lora_models_str, prompt, negative_prompt)
def _build_fts_query(self, query: str, fields: Optional[Set[str]] = None) -> str:
"""Build an FTS5 query string with prefix matching and field restrictions.
Args:
query: The user's search query.
fields: Optional set of field names to restrict search to.
Returns:
FTS5 query string.
"""
# Split query into words and clean them
words = query.lower().split()
if not words:
return ''
# Escape and add prefix wildcard to each word
prefix_terms = []
for word in words:
escaped = self._escape_fts_query(word)
if escaped:
# Add prefix wildcard for substring-like matching
# FTS5 prefix queries: word* matches words starting with "word"
prefix_terms.append(f'{escaped}*')
if not prefix_terms:
return ''
# Combine terms with implicit AND (all words must match)
term_expr = ' '.join(prefix_terms)
# If no field restriction, search all indexed fields (not recipe_id)
if not fields:
return term_expr
# Build field-restricted query with OR between fields
field_clauses = []
for field in fields:
if field in self.FIELD_MAP:
cols = self.FIELD_MAP[field]
for col in cols:
# FTS5 column filter syntax: column:term
# Need to handle multiple terms properly
for term in prefix_terms:
field_clauses.append(f'{col}:{term}')
if not field_clauses:
return term_expr
# Combine field clauses with OR
return ' OR '.join(field_clauses)
def _escape_fts_query(self, text: str) -> str:
"""Escape special FTS5 characters.
FTS5 special characters: " ( ) * : ^ -
We keep * for prefix matching but escape others.
"""
if not text:
return ''
# Replace FTS5 special characters with space
# Keep alphanumeric, CJK characters, and common punctuation
special = ['"', '(', ')', '*', ':', '^', '-', '{', '}', '[', ']']
result = text
for char in special:
result = result.replace(char, ' ')
# Collapse multiple spaces and strip
result = re.sub(r'\s+', ' ', result).strip()
return result

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import numpy as np
from PIL import Image from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import rewrite_preview_url
from .errors import ( from .errors import (
RecipeDownloadError, RecipeDownloadError,
RecipeNotFoundError, RecipeNotFoundError,
@@ -94,18 +95,39 @@ class RecipeAnalysisService:
if civitai_client is None: if civitai_client is None:
raise RecipeServiceError("Civitai client unavailable") raise RecipeServiceError("Civitai client unavailable")
temp_path = self._create_temp_path() temp_path = None
metadata: Optional[dict[str, Any]] = None metadata: Optional[dict[str, Any]] = None
is_video = False
extension = ".jpg" # Default
try: try:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url) civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
if civitai_match: if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1)) image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info: if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai") raise RecipeDownloadError("Failed to fetch image information from Civitai")
image_url = image_info.get("url") image_url = image_info.get("url")
if not image_url: if not image_url:
raise RecipeDownloadError("No image URL found in Civitai response") raise RecipeDownloadError("No image URL found in Civitai response")
is_video = image_info.get("type") == "video"
# Use optimized preview URLs if possible
rewritten_url, _ = rewrite_preview_url(image_url, media_type=image_info.get("type"))
if rewritten_url:
image_url = rewritten_url
if is_video:
# Extract extension from URL
url_path = image_url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower() or ".mp4"
else:
extension = ".jpg"
temp_path = self._create_temp_path(suffix=extension)
await self._download_image(image_url, temp_path) await self._download_image(image_url, temp_path)
metadata = image_info.get("meta") if "meta" in image_info else None metadata = image_info.get("meta") if "meta" in image_info else None
if ( if (
isinstance(metadata, dict) isinstance(metadata, dict)
@@ -114,22 +136,31 @@ class RecipeAnalysisService:
): ):
metadata = metadata["meta"] metadata = metadata["meta"]
else: else:
# Basic extension detection for non-Civitai URLs
url_path = url.split('?')[0].split('#')[0]
extension = os.path.splitext(url_path)[1].lower()
if extension in [".mp4", ".webm"]:
is_video = True
else:
extension = ".jpg"
temp_path = self._create_temp_path(suffix=extension)
await self._download_image(url, temp_path) await self._download_image(url, temp_path)
if metadata is None: if metadata is None and not is_video:
metadata = self._exif_utils.extract_image_metadata(temp_path) metadata = self._exif_utils.extract_image_metadata(temp_path)
if not metadata:
return self._metadata_not_found_response(temp_path)
return await self._parse_metadata( return await self._parse_metadata(
metadata, metadata or {},
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
image_path=temp_path, image_path=temp_path,
include_image_base64=True, include_image_base64=True,
is_video=is_video,
extension=extension,
) )
finally: finally:
self._safe_cleanup(temp_path) if temp_path:
self._safe_cleanup(temp_path)
async def analyze_local_image( async def analyze_local_image(
self, self,
@@ -198,12 +229,16 @@ class RecipeAnalysisService:
recipe_scanner, recipe_scanner,
image_path: Optional[str], image_path: Optional[str],
include_image_base64: bool, include_image_base64: bool,
is_video: bool = False,
extension: str = ".jpg",
) -> AnalysisResult: ) -> AnalysisResult:
parser = self._recipe_parser_factory.create_parser(metadata) parser = self._recipe_parser_factory.create_parser(metadata)
if parser is None: if parser is None:
payload = {"error": "No parser found for this image", "loras": []} payload = {"error": "No parser found for this image", "loras": []}
if include_image_base64 and image_path: if include_image_base64 and image_path:
payload["image_base64"] = self._encode_file(image_path) payload["image_base64"] = self._encode_file(image_path)
payload["is_video"] = is_video
payload["extension"] = extension
return AnalysisResult(payload) return AnalysisResult(payload)
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner) result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
@@ -211,6 +246,9 @@ class RecipeAnalysisService:
if include_image_base64 and image_path: if include_image_base64 and image_path:
result["image_base64"] = self._encode_file(image_path) result["image_base64"] = self._encode_file(image_path)
result["is_video"] = is_video
result["extension"] = extension
if "error" in result and not result.get("loras"): if "error" in result and not result.get("loras"):
return AnalysisResult(result) return AnalysisResult(result)
@@ -241,8 +279,8 @@ class RecipeAnalysisService:
temp_file.write(data) temp_file.write(data)
return temp_file.name return temp_file.name
def _create_temp_path(self) -> str: def _create_temp_path(self, suffix: str = ".jpg") -> str:
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file: with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
return temp_file.name return temp_file.name
def _safe_cleanup(self, path: Optional[str]) -> None: def _safe_cleanup(self, path: Optional[str]) -> None:

View File

@@ -5,6 +5,7 @@ import base64
import json import json
import os import os
import re import re
import shutil
import time import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
@@ -46,6 +47,7 @@ class RecipePersistenceService:
name: str | None, name: str | None,
tags: Iterable[str], tags: Iterable[str],
metadata: Optional[dict[str, Any]], metadata: Optional[dict[str, Any]],
extension: str | None = None,
) -> PersistenceResult: ) -> PersistenceResult:
"""Persist a user uploaded recipe.""" """Persist a user uploaded recipe."""
@@ -64,13 +66,21 @@ class RecipePersistenceService:
os.makedirs(recipes_dir, exist_ok=True) os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4()) recipe_id = str(uuid.uuid4())
optimized_image, extension = self._exif_utils.optimize_image(
image_data=resolved_image_bytes, # Handle video formats by bypassing optimization and metadata embedding
target_width=self._card_preview_width, is_video = extension in [".mp4", ".webm"]
format="webp", if is_video:
quality=85, optimized_image = resolved_image_bytes
preserve_metadata=True, # extension is already set
) else:
optimized_image, extension = self._exif_utils.optimize_image(
image_data=resolved_image_bytes,
target_width=self._card_preview_width,
format="webp",
quality=85,
preserve_metadata=True,
)
image_filename = f"{recipe_id}{extension}" image_filename = f"{recipe_id}{extension}"
image_path = os.path.join(recipes_dir, image_filename) image_path = os.path.join(recipes_dir, image_filename)
normalized_image_path = os.path.normpath(image_path) normalized_image_path = os.path.normpath(image_path)
@@ -126,7 +136,8 @@ class RecipePersistenceService:
with open(json_path, "w", encoding="utf-8") as file_obj: with open(json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False) json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data) if not is_video:
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id) matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
await recipe_scanner.add_recipe(recipe_data) await recipe_scanner.add_recipe(recipe_data)
@@ -144,12 +155,8 @@ class RecipePersistenceService:
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult: async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
"""Delete an existing recipe.""" """Delete an existing recipe."""
recipes_dir = recipe_scanner.recipes_dir recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipes_dir or not os.path.exists(recipes_dir): if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipes directory not found")
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
if not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found") raise RecipeNotFoundError("Recipe not found")
with open(recipe_json_path, "r", encoding="utf-8") as file_obj: with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
@@ -166,9 +173,9 @@ class RecipePersistenceService:
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe.""" """Update persisted metadata for a recipe."""
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level")): if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
raise RecipeValidationError( raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)" "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)"
) )
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates) success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
@@ -177,6 +184,163 @@ class RecipePersistenceService:
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates}) return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]:
"""Normalize and validate the target path for recipe moves."""
if not target_path:
raise RecipeValidationError("Target path is required")
recipes_root = recipe_scanner.recipes_dir
if not recipes_root:
raise RecipeNotFoundError("Recipes directory not found")
normalized_target = os.path.normpath(target_path)
recipes_root = os.path.normpath(recipes_root)
if not os.path.isabs(normalized_target):
normalized_target = os.path.normpath(os.path.join(recipes_root, normalized_target))
try:
common_root = os.path.commonpath([normalized_target, recipes_root])
except ValueError as exc:
raise RecipeValidationError("Invalid target path") from exc
if common_root != recipes_root:
raise RecipeValidationError("Target path must be inside the recipes directory")
return normalized_target, recipes_root
async def _move_recipe_files(
self,
*,
recipe_scanner,
recipe_id: str,
normalized_target: str,
recipes_root: str,
) -> dict[str, Any]:
"""Move the recipe's JSON and preview image into the normalized target."""
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found")
recipe_data = await recipe_scanner.get_recipe_by_id(recipe_id)
if not recipe_data:
raise RecipeNotFoundError("Recipe not found")
current_json_dir = os.path.dirname(recipe_json_path)
normalized_image_path = os.path.normpath(recipe_data.get("file_path") or "") if recipe_data.get("file_path") else None
os.makedirs(normalized_target, exist_ok=True)
if os.path.normpath(current_json_dir) == normalized_target:
return {
"success": True,
"message": "Recipe is already in the target folder",
"recipe_id": recipe_id,
"original_file_path": recipe_data.get("file_path"),
"new_file_path": recipe_data.get("file_path"),
}
new_json_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(recipe_json_path)))
shutil.move(recipe_json_path, new_json_path)
new_image_path = normalized_image_path
if normalized_image_path:
target_image_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(normalized_image_path)))
if os.path.exists(normalized_image_path) and normalized_image_path != target_image_path:
shutil.move(normalized_image_path, target_image_path)
new_image_path = target_image_path
relative_folder = os.path.relpath(normalized_target, recipes_root)
if relative_folder in (".", ""):
relative_folder = ""
updates = {"file_path": new_image_path or recipe_data.get("file_path"), "folder": relative_folder.replace(os.path.sep, "/")}
updated = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
if not updated:
raise RecipeNotFoundError("Recipe not found after move")
return {
"success": True,
"recipe_id": recipe_id,
"original_file_path": recipe_data.get("file_path"),
"new_file_path": updates["file_path"],
"json_path": new_json_path,
"folder": updates["folder"],
}
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult:
"""Move a recipe's assets into a new folder under the recipes root."""
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
result = await self._move_recipe_files(
recipe_scanner=recipe_scanner,
recipe_id=recipe_id,
normalized_target=normalized_target,
recipes_root=recipes_root,
)
return PersistenceResult(result)
async def move_recipes_bulk(
self,
*,
recipe_scanner,
recipe_ids: Iterable[str],
target_path: str,
) -> PersistenceResult:
"""Move multiple recipes to a new folder."""
recipe_ids = list(recipe_ids)
if not recipe_ids:
raise RecipeValidationError("No recipe IDs provided")
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
results: list[dict[str, Any]] = []
success_count = 0
failure_count = 0
for recipe_id in recipe_ids:
try:
move_result = await self._move_recipe_files(
recipe_scanner=recipe_scanner,
recipe_id=str(recipe_id),
normalized_target=normalized_target,
recipes_root=recipes_root,
)
results.append(
{
"recipe_id": recipe_id,
"original_file_path": move_result.get("original_file_path"),
"new_file_path": move_result.get("new_file_path"),
"success": True,
"message": move_result.get("message", ""),
"folder": move_result.get("folder", ""),
}
)
success_count += 1
except Exception as exc: # pragma: no cover - per-item error handling
results.append(
{
"recipe_id": recipe_id,
"original_file_path": None,
"new_file_path": None,
"success": False,
"message": str(exc),
}
)
failure_count += 1
return PersistenceResult(
{
"success": True,
"message": f"Moved {success_count} of {len(recipe_ids)} recipes",
"results": results,
"success_count": success_count,
"failure_count": failure_count,
}
)
async def reconnect_lora( async def reconnect_lora(
self, self,
*, *,
@@ -187,8 +351,8 @@ class RecipePersistenceService:
) -> PersistenceResult: ) -> PersistenceResult:
"""Reconnect a LoRA entry within an existing recipe.""" """Reconnect a LoRA entry within an existing recipe."""
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json") recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not os.path.exists(recipe_path): if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found") raise RecipeNotFoundError("Recipe not found")
target_lora = await recipe_scanner.get_local_lora(target_name) target_lora = await recipe_scanner.get_local_lora(target_name)
@@ -233,16 +397,12 @@ class RecipePersistenceService:
if not recipe_ids: if not recipe_ids:
raise RecipeValidationError("No recipe IDs provided") raise RecipeValidationError("No recipe IDs provided")
recipes_dir = recipe_scanner.recipes_dir
if not recipes_dir or not os.path.exists(recipes_dir):
raise RecipeNotFoundError("Recipes directory not found")
deleted_recipes: list[str] = [] deleted_recipes: list[str] = []
failed_recipes: list[dict[str, Any]] = [] failed_recipes: list[dict[str, Any]] = []
for recipe_id in recipe_ids: for recipe_id in recipe_ids:
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json") recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not os.path.exists(recipe_json_path): if not recipe_json_path or not os.path.exists(recipe_json_path):
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"}) failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
continue continue

View File

@@ -44,6 +44,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"proxy_type": "http", "proxy_type": "http",
"default_lora_root": "", "default_lora_root": "",
"default_checkpoint_root": "", "default_checkpoint_root": "",
"default_unet_root": "",
"default_embedding_root": "", "default_embedding_root": "",
"base_model_path_mappings": {}, "base_model_path_mappings": {},
"download_path_templates": {}, "download_path_templates": {},
@@ -215,6 +216,7 @@ class SettingsManager:
folder_paths=merged.get("folder_paths", {}), folder_paths=merged.get("folder_paths", {}),
default_lora_root=merged.get("default_lora_root"), default_lora_root=merged.get("default_lora_root"),
default_checkpoint_root=merged.get("default_checkpoint_root"), default_checkpoint_root=merged.get("default_checkpoint_root"),
default_unet_root=merged.get("default_unet_root"),
default_embedding_root=merged.get("default_embedding_root"), default_embedding_root=merged.get("default_embedding_root"),
) )
} }
@@ -300,6 +302,7 @@ class SettingsManager:
folder_paths=normalized_top_level_paths, folder_paths=normalized_top_level_paths,
default_lora_root=self.settings.get("default_lora_root", ""), default_lora_root=self.settings.get("default_lora_root", ""),
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""), default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
default_unet_root=self.settings.get("default_unet_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""), default_embedding_root=self.settings.get("default_embedding_root", ""),
) )
libraries = {library_name: library_payload} libraries = {library_name: library_payload}
@@ -342,6 +345,7 @@ class SettingsManager:
folder_paths=candidate_folder_paths, folder_paths=candidate_folder_paths,
default_lora_root=data.get("default_lora_root"), default_lora_root=data.get("default_lora_root"),
default_checkpoint_root=data.get("default_checkpoint_root"), default_checkpoint_root=data.get("default_checkpoint_root"),
default_unet_root=data.get("default_unet_root"),
default_embedding_root=data.get("default_embedding_root"), default_embedding_root=data.get("default_embedding_root"),
metadata=data.get("metadata"), metadata=data.get("metadata"),
base=data, base=data,
@@ -380,6 +384,7 @@ class SettingsManager:
self.settings["folder_paths"] = folder_paths self.settings["folder_paths"] = folder_paths
self.settings["default_lora_root"] = active_library.get("default_lora_root", "") self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "") self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "")
self.settings["default_unet_root"] = active_library.get("default_unet_root", "")
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "") self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "")
if save: if save:
@@ -394,6 +399,7 @@ class SettingsManager:
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None,
base: Optional[Mapping[str, Any]] = None, base: Optional[Mapping[str, Any]] = None,
@@ -416,6 +422,11 @@ class SettingsManager:
else: else:
payload.setdefault("default_checkpoint_root", "") payload.setdefault("default_checkpoint_root", "")
if default_unet_root is not None:
payload["default_unet_root"] = default_unet_root
else:
payload.setdefault("default_unet_root", "")
if default_embedding_root is not None: if default_embedding_root is not None:
payload["default_embedding_root"] = default_embedding_root payload["default_embedding_root"] = default_embedding_root
else: else:
@@ -517,6 +528,7 @@ class SettingsManager:
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
) -> bool: ) -> bool:
libraries = self.settings.get("libraries", {}) libraries = self.settings.get("libraries", {})
@@ -541,6 +553,10 @@ class SettingsManager:
library["default_checkpoint_root"] = default_checkpoint_root library["default_checkpoint_root"] = default_checkpoint_root
changed = True changed = True
if default_unet_root is not None and library.get("default_unet_root") != default_unet_root:
library["default_unet_root"] = default_unet_root
changed = True
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root: if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root:
library["default_embedding_root"] = default_embedding_root library["default_embedding_root"] = default_embedding_root
changed = True changed = True
@@ -596,7 +612,11 @@ class SettingsManager:
logger.info("Migration completed") logger.info("Migration completed")
def _auto_set_default_roots(self): def _auto_set_default_roots(self):
"""Auto set default root paths when only one folder is present and the current default is unset or not among the options.""" """Auto set default root paths when the current default is unset or not among the options.
For single-path cases, always use that path.
For multi-path cases, only set if current default is empty or invalid.
"""
folder_paths = self.settings.get('folder_paths', {}) folder_paths = self.settings.get('folder_paths', {})
updated = False updated = False
# loras # loras
@@ -613,6 +633,14 @@ class SettingsManager:
if current_checkpoint_root not in checkpoints: if current_checkpoint_root not in checkpoints:
self.settings['default_checkpoint_root'] = checkpoints[0] self.settings['default_checkpoint_root'] = checkpoints[0]
updated = True updated = True
# unet (diffusion models) - auto-set if empty or invalid
unet_paths = folder_paths.get('unet', [])
if isinstance(unet_paths, list) and len(unet_paths) >= 1:
current_unet_root = self.settings.get('default_unet_root')
# Set to first path if current is empty or not in the valid paths
if not current_unet_root or current_unet_root not in unet_paths:
self.settings['default_unet_root'] = unet_paths[0]
updated = True
# embeddings # embeddings
embeddings = folder_paths.get('embeddings', []) embeddings = folder_paths.get('embeddings', [])
if isinstance(embeddings, list) and len(embeddings) == 1: if isinstance(embeddings, list) and len(embeddings) == 1:
@@ -624,6 +652,7 @@ class SettingsManager:
self._update_active_library_entry( self._update_active_library_entry(
default_lora_root=self.settings.get('default_lora_root'), default_lora_root=self.settings.get('default_lora_root'),
default_checkpoint_root=self.settings.get('default_checkpoint_root'), default_checkpoint_root=self.settings.get('default_checkpoint_root'),
default_unet_root=self.settings.get('default_unet_root'),
default_embedding_root=self.settings.get('default_embedding_root'), default_embedding_root=self.settings.get('default_embedding_root'),
) )
if self._bootstrap_reason == "missing": if self._bootstrap_reason == "missing":
@@ -851,6 +880,8 @@ class SettingsManager:
self._update_active_library_entry(default_lora_root=str(value)) self._update_active_library_entry(default_lora_root=str(value))
elif key == 'default_checkpoint_root': elif key == 'default_checkpoint_root':
self._update_active_library_entry(default_checkpoint_root=str(value)) self._update_active_library_entry(default_checkpoint_root=str(value))
elif key == 'default_unet_root':
self._update_active_library_entry(default_unet_root=str(value))
elif key == 'default_embedding_root': elif key == 'default_embedding_root':
self._update_active_library_entry(default_embedding_root=str(value)) self._update_active_library_entry(default_embedding_root=str(value))
elif key == 'model_name_display': elif key == 'model_name_display':
@@ -883,6 +914,7 @@ class SettingsManager:
if os.path.abspath(previous_path) != os.path.abspath(target_path): if os.path.abspath(previous_path) != os.path.abspath(target_path):
self._copy_model_cache_directory(previous_dir, target_dir) self._copy_model_cache_directory(previous_dir, target_dir)
logger.info("Switching settings file to: %s", target_path)
self._pending_portable_switch = {"other_path": other_path} self._pending_portable_switch = {"other_path": other_path}
self.settings_file = target_path self.settings_file = target_path
@@ -929,7 +961,12 @@ class SettingsManager:
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir) and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir)
): ):
try: try:
shutil.copytree(source_cache_dir, target_cache_dir, dirs_exist_ok=True) shutil.copytree(
source_cache_dir,
target_cache_dir,
dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.sqlite-shm", "*.sqlite-wal"),
)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"Failed to copy model_cache directory from %s to %s: %s", "Failed to copy model_cache directory from %s to %s: %s",
@@ -1125,6 +1162,7 @@ class SettingsManager:
folder_paths: Optional[Mapping[str, Iterable[str]]] = None, folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False, activate: bool = False,
@@ -1149,6 +1187,11 @@ class SettingsManager:
if default_checkpoint_root is not None if default_checkpoint_root is not None
else existing.get("default_checkpoint_root") else existing.get("default_checkpoint_root")
), ),
default_unet_root=(
default_unet_root
if default_unet_root is not None
else existing.get("default_unet_root")
),
default_embedding_root=( default_embedding_root=(
default_embedding_root default_embedding_root
if default_embedding_root is not None if default_embedding_root is not None
@@ -1178,6 +1221,7 @@ class SettingsManager:
folder_paths: Mapping[str, Iterable[str]], folder_paths: Mapping[str, Iterable[str]],
default_lora_root: str = "", default_lora_root: str = "",
default_checkpoint_root: str = "", default_checkpoint_root: str = "",
default_unet_root: str = "",
default_embedding_root: str = "", default_embedding_root: str = "",
metadata: Optional[Mapping[str, Any]] = None, metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False, activate: bool = False,
@@ -1193,6 +1237,7 @@ class SettingsManager:
folder_paths=folder_paths, folder_paths=folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root, default_embedding_root=default_embedding_root,
metadata=metadata, metadata=metadata,
activate=activate, activate=activate,
@@ -1250,6 +1295,7 @@ class SettingsManager:
*, *,
default_lora_root: Optional[str] = None, default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None, default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None, default_embedding_root: Optional[str] = None,
) -> None: ) -> None:
"""Update folder paths for the active library.""" """Update folder paths for the active library."""
@@ -1260,6 +1306,7 @@ class SettingsManager:
folder_paths=folder_paths, folder_paths=folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root, default_embedding_root=default_embedding_root,
activate=True, activate=True,
) )

View File

@@ -59,6 +59,8 @@ class BulkMetadataRefreshUseCase:
success = 0 success = 0
needs_resort = False needs_resort = False
self._service.scanner.reset_cancellation()
async def emit(status: str, **extra: Any) -> None: async def emit(status: str, **extra: Any) -> None:
if progress_callback is None: if progress_callback is None:
return return
@@ -69,6 +71,10 @@ class BulkMetadataRefreshUseCase:
await emit("started") await emit("started")
for model in to_process: for model in to_process:
if self._service.scanner.is_cancelled():
self._logger.info("Bulk metadata refresh cancelled by user")
await emit("cancelled", processed=processed, success=success)
return {"success": False, "message": "Operation cancelled", "processed": processed, "updated": success, "total": total_models}
try: try:
original_name = model.get("model_name") original_name = model.get("model_name")
await MetadataManager.hydrate_model_data(model) await MetadataManager.hydrate_model_data(model)

View File

@@ -20,6 +20,8 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict] = {} self._last_init_progress: Dict[str, Dict] = {}
# Add auto-organize progress tracking # Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict] = None self._auto_organize_progress: Optional[Dict] = None
# Add recipe repair progress tracking
self._recipe_repair_progress: Optional[Dict] = None
self._auto_organize_lock = asyncio.Lock() self._auto_organize_lock = asyncio.Lock()
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse: async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
@@ -189,6 +191,14 @@ class WebSocketManager:
# Broadcast via WebSocket # Broadcast via WebSocket
await self.broadcast(data) await self.broadcast(data)
async def broadcast_recipe_repair_progress(self, data: Dict):
"""Broadcast recipe repair progress to connected clients"""
# Store progress data in memory
self._recipe_repair_progress = data
# Broadcast via WebSocket
await self.broadcast(data)
def get_auto_organize_progress(self) -> Optional[Dict]: def get_auto_organize_progress(self) -> Optional[Dict]:
"""Get current auto-organize progress""" """Get current auto-organize progress"""
return self._auto_organize_progress return self._auto_organize_progress
@@ -197,6 +207,22 @@ class WebSocketManager:
"""Clear auto-organize progress data""" """Clear auto-organize progress data"""
self._auto_organize_progress = None self._auto_organize_progress = None
def get_recipe_repair_progress(self) -> Optional[Dict]:
"""Get current recipe repair progress"""
return self._recipe_repair_progress
def cleanup_recipe_repair_progress(self):
"""Clear recipe repair progress data if it is in a finished state"""
if self._recipe_repair_progress and self._recipe_repair_progress.get('status') in ['completed', 'cancelled', 'error']:
self._recipe_repair_progress = None
def is_recipe_repair_running(self) -> bool:
"""Check if recipe repair is currently running"""
if not self._recipe_repair_progress:
return False
status = self._recipe_repair_progress.get('status')
return status in ['started', 'processing']
def is_auto_organize_running(self) -> bool: def is_auto_organize_running(self) -> bool:
"""Check if auto-organize is currently running""" """Check if auto-organize is currently running"""
if not self._auto_organize_progress: if not self._auto_organize_progress:

View File

@@ -20,11 +20,25 @@ _COMMERCIAL_SHIFT = 1
def _normalize_commercial_values(value: Any) -> Sequence[str]: def _normalize_commercial_values(value: Any) -> Sequence[str]:
"""Return a normalized list of commercial permissions preserving source values.""" """Return a normalized list of commercial permissions preserving source values."""
def _split_aggregate(value_str: str) -> list[str]:
stripped = value_str.strip()
looks_aggregate = "," in stripped or (stripped.startswith("{") and stripped.endswith("}"))
if not looks_aggregate:
return [value_str]
trimmed = stripped
if trimmed.startswith("{") and trimmed.endswith("}"):
trimmed = trimmed[1:-1]
parts = [part.strip() for part in trimmed.split(",")]
result = [part for part in parts if part]
return result or [value_str]
if value is None: if value is None:
return list(_DEFAULT_ALLOW_COMMERCIAL_USE) return list(_DEFAULT_ALLOW_COMMERCIAL_USE)
if isinstance(value, str): if isinstance(value, str):
return [value] return _split_aggregate(value)
if isinstance(value, Iterable): if isinstance(value, Iterable):
result = [] result = []
@@ -32,7 +46,7 @@ def _normalize_commercial_values(value: Any) -> Sequence[str]:
if item is None: if item is None:
continue continue
if isinstance(item, str): if isinstance(item, str):
result.append(item) result.extend(_split_aggregate(item))
continue continue
result.append(str(item)) result.append(str(item))
if result: if result:

View File

@@ -4,14 +4,14 @@ NSFW_LEVELS = {
"R": 4, "R": 4,
"X": 8, "X": 8,
"XXX": 16, "XXX": 16,
"Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account? "Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account?
} }
# Node type constants # Node type constants
NODE_TYPES = { NODE_TYPES = {
"Lora Loader (LoraManager)": 1, "Lora Loader (LoraManager)": 1,
"Lora Stacker (LoraManager)": 2, "Lora Stacker (LoraManager)": 2,
"WanVideo Lora Select (LoraManager)": 3 "WanVideo Lora Select (LoraManager)": 3,
} }
# Default ComfyUI node color when bgcolor is null # Default ComfyUI node color when bgcolor is null
@@ -19,18 +19,18 @@ DEFAULT_NODE_COLOR = "#353535"
# preview extensions # preview extensions
PREVIEW_EXTENSIONS = [ PREVIEW_EXTENSIONS = [
'.webp', ".webp",
'.preview.webp', ".preview.webp",
'.preview.png', ".preview.png",
'.preview.jpeg', ".preview.jpeg",
'.preview.jpg', ".preview.jpg",
'.preview.mp4', ".preview.mp4",
'.png', ".png",
'.jpeg', ".jpeg",
'.jpg', ".jpg",
'.mp4', ".mp4",
'.gif', ".gif",
'.webm' ".webm",
] ]
# Card preview image width # Card preview image width
@@ -41,37 +41,70 @@ EXAMPLE_IMAGE_WIDTH = 832
# Supported media extensions for example downloads # Supported media extensions for example downloads
SUPPORTED_MEDIA_EXTENSIONS = { SUPPORTED_MEDIA_EXTENSIONS = {
'images': ['.jpg', '.jpeg', '.png', '.webp', '.gif'], "images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
'videos': ['.mp4', '.webm'] "videos": [".mp4", ".webm"],
} }
# Valid Lora types # Valid Lora types
VALID_LORA_TYPES = ['lora', 'locon', 'dora'] VALID_LORA_TYPES = ["lora", "locon", "dora"]
# Supported Civitai model types for user model queries (case-insensitive) # Supported Civitai model types for user model queries (case-insensitive)
CIVITAI_USER_MODEL_TYPES = [ CIVITAI_USER_MODEL_TYPES = [
*VALID_LORA_TYPES, *VALID_LORA_TYPES,
'textualinversion', "textualinversion",
'checkpoint', "checkpoint",
] ]
# Default chunk size in megabytes used for hashing large files. # Default chunk size in megabytes used for hashing large files.
DEFAULT_HASH_CHUNK_SIZE_MB = 4 DEFAULT_HASH_CHUNK_SIZE_MB = 4
# Auto-organize settings # Auto-organize settings
AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming the system AUTO_ORGANIZE_BATCH_SIZE = (
50 # Process models in batches to avoid overwhelming the system
)
# Civitai model tags in priority order for subfolder organization # Civitai model tags in priority order for subfolder organization
CIVITAI_MODEL_TAGS = [ CIVITAI_MODEL_TAGS = [
'character', 'concept', 'clothing', "character",
'realistic', 'anime', 'toon', 'furry', 'style', "concept",
'poses', 'background', 'tool', 'vehicle', 'buildings', "clothing",
'objects', 'assets', 'animal', 'action' "realistic",
"anime",
"toon",
"furry",
"style",
"poses",
"background",
"tool",
"vehicle",
"buildings",
"objects",
"assets",
"animal",
"action",
] ]
# Default priority tag configuration strings for each model type # Default priority tag configuration strings for each model type
DEFAULT_PRIORITY_TAG_CONFIG = { DEFAULT_PRIORITY_TAG_CONFIG = {
'lora': ', '.join(CIVITAI_MODEL_TAGS), "lora": ", ".join(CIVITAI_MODEL_TAGS),
'checkpoint': ', '.join(CIVITAI_MODEL_TAGS), "checkpoint": ", ".join(CIVITAI_MODEL_TAGS),
'embedding': ', '.join(CIVITAI_MODEL_TAGS), "embedding": ", ".join(CIVITAI_MODEL_TAGS),
} }
# baseModel values from CivitAI that should be treated as diffusion models (unet)
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
DIFFUSION_MODEL_BASE_MODELS = frozenset(
[
"ZImageTurbo",
"Wan Video 1.3B t2v",
"Wan Video 14B t2v",
"Wan Video 14B i2v 480p",
"Wan Video 14B i2v 720p",
"Wan Video 2.2 TI2V-5B",
"Wan Video 2.2 I2V-A14B",
"Wan Video 2.2 T2V-A14B",
"Wan Video 2.5 T2V",
"Wan Video 2.5 I2V",
"Qwen",
]
)

File diff suppressed because it is too large Load Diff

View File

@@ -593,5 +593,114 @@ class ExampleImagesProcessor:
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
@staticmethod
async def set_example_image_nsfw_level(request: web.Request) -> web.StreamResponse:
"""
Update the NSFW level for a single example image (regular or custom).
"""
try:
data = await request.json()
except Exception:
return web.json_response({'success': False, 'error': 'Invalid JSON body'}, status=400)
model_hash = data.get('model_hash')
raw_level = data.get('nsfw_level')
source = (data.get('source') or 'civitai').lower()
index = data.get('index')
image_id = data.get('id')
if model_hash is None or raw_level is None:
return web.json_response(
{'success': False, 'error': 'Missing required parameters: model_hash and nsfw_level'},
status=400,
)
try:
nsfw_level = int(raw_level)
except (TypeError, ValueError):
return web.json_response(
{'success': False, 'error': 'nsfw_level must be an integer'}, status=400
)
if source == 'custom':
if not image_id:
return web.json_response(
{'success': False, 'error': 'Custom images require an id field'}, status=400
)
else:
try:
index = int(index)
except (TypeError, ValueError):
return web.json_response(
{'success': False, 'error': 'Regular images require a numeric index'}, status=400
)
try:
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
model_data = None
scanner = None
for scan_obj in [lora_scanner, checkpoint_scanner, embedding_scanner]:
if scan_obj.has_hash(model_hash):
cache = await scan_obj.get_cached_data()
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
scanner = scan_obj
break
if model_data:
break
if not model_data:
return web.json_response(
{'success': False, 'error': f"Model with hash {model_hash} not found in cache"},
status=404,
)
await MetadataManager.hydrate_model_data(model_data)
civitai_data = model_data.setdefault('civitai', {})
regular_images = civitai_data.get('images') or []
custom_images = civitai_data.get('customImages') or []
target_image = None
if source == 'custom':
for image in custom_images:
if image.get('id') == image_id:
target_image = image
break
else:
if 0 <= index < len(regular_images):
target_image = regular_images[index]
if target_image is None:
return web.json_response(
{'success': False, 'error': 'Target image not found'}, status=404
)
target_image['nsfwLevel'] = nsfw_level
civitai_data['images'] = regular_images
civitai_data['customImages'] = custom_images
file_path = model_data.get('file_path')
if file_path:
model_copy = model_data.copy()
model_copy.pop('folder', None)
await MetadataManager.save_metadata(file_path, model_copy)
await scanner.update_single_model_cache(file_path, file_path, model_data)
return web.json_response({
'success': True,
'regular_images': regular_images,
'custom_images': custom_images,
'model_file_path': model_data.get('file_path', ''),
'nsfw_level': nsfw_level
})
except Exception as exc:
logger.error("Failed to update example image NSFW level: %s", exc, exc_info=True)
return web.json_response({'success': False, 'error': str(exc)}, status=500)

View File

@@ -22,6 +22,12 @@ class ExifUtils:
Optional[str]: Extracted metadata or None if not found Optional[str]: Extracted metadata or None if not found
""" """
try: try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
return None
# First try to open the image # First try to open the image
with Image.open(image_path) as img: with Image.open(image_path) as img:
# Method 1: Check for parameters in image info # Method 1: Check for parameters in image info
@@ -80,6 +86,12 @@ class ExifUtils:
str: Path to the updated image str: Path to the updated image
""" """
try: try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
return image_path
# Load the image and check its format # Load the image and check its format
with Image.open(image_path) as img: with Image.open(image_path) as img:
img_format = img.format img_format = img.format
@@ -133,6 +145,12 @@ class ExifUtils:
def append_recipe_metadata(image_path, recipe_data) -> str: def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data""" """Append recipe metadata to an image's EXIF data"""
try: try:
# Skip for video files
if image_path:
ext = os.path.splitext(image_path)[1].lower()
if ext in ['.mp4', '.webm']:
return image_path
# First, extract existing metadata # First, extract existing metadata
metadata = ExifUtils.extract_image_metadata(image_path) metadata = ExifUtils.extract_image_metadata(image_path)
@@ -242,6 +260,16 @@ class ExifUtils:
Tuple of (optimized_image_data, extension) Tuple of (optimized_image_data, extension)
""" """
try: try:
# Skip for video files early if it's a file path
if isinstance(image_data, str) and os.path.exists(image_data):
ext = os.path.splitext(image_data)[1].lower()
if ext in ['.mp4', '.webm']:
try:
with open(image_data, 'rb') as f:
return f.read(), ext
except Exception:
return image_data, ext
# First validate the image data is usable # First validate the image data is usable
img = None img = None
if isinstance(image_data, str) and os.path.exists(image_data): if isinstance(image_data, str) and os.path.exists(image_data):

View File

@@ -0,0 +1,26 @@
import logging
import os
def setup_logging():
"""
Sets up a global log record factory that prepends '[LoRA-Manager]' to all logs
generated by this extension.
"""
# project_root should be the parent of the directory containing this file (py/utils/logging_config.py)
# So project_root is ComfyUI-Lora-Manager/
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
prefix = "[LoRA-Manager] "
old_factory = logging.getLogRecordFactory()
def factory(*args, **kwargs):
record = old_factory(*args, **kwargs)
# Check if the log is coming from our extension
# We use pathname to verify if it's within our project directory
if record.pathname and os.path.abspath(record.pathname).startswith(project_root):
if isinstance(record.msg, str) and not record.msg.startswith(prefix):
record.msg = f"{prefix}{record.msg}"
return record
logging.setLogRecordFactory(factory)

View File

@@ -2,6 +2,7 @@ from datetime import datetime
import os import os
import json import json
import logging import logging
import time
from typing import Any, Dict, Optional, Type, Union from typing import Any, Dict, Optional, Type, Union
from .models import BaseModelMetadata, LoraMetadata from .models import BaseModelMetadata, LoraMetadata
@@ -203,7 +204,11 @@ class MetadataManager:
preview_url = find_preview_file(base_name, dir_path) preview_url = find_preview_file(base_name, dir_path)
# Calculate file hash # Calculate file hash
start_hash_time = time.perf_counter()
logger.debug(f"Calculating SHA256 hash for {real_path}...")
sha256 = await calculate_sha256(real_path) sha256 = await calculate_sha256(real_path)
hash_duration = time.perf_counter() - start_hash_time
logger.info(f"SHA256 hash calculated for {real_path} in {hash_duration:.3f}s")
# Create instance based on model type # Create instance based on model type
if model_class.__name__ == "CheckpointMetadata": if model_class.__name__ == "CheckpointMetadata":
@@ -255,6 +260,7 @@ class MetadataManager:
# await MetadataManager._enrich_metadata(metadata, real_path) # await MetadataManager._enrich_metadata(metadata, real_path)
# Save the created metadata # Save the created metadata
logger.info(f"Creating new .metadata.json for {file_path} (Reason: No existing metadata found)")
await MetadataManager.save_metadata(file_path, metadata) await MetadataManager.save_metadata(file_path, metadata)
return metadata return metadata

241
py/vue_widget_builder.py Normal file
View File

@@ -0,0 +1,241 @@
"""
Vue Widget Build Checker and Auto-builder
This module checks if Vue widgets are built and attempts to build them if needed.
Useful for development mode where source code might be newer than build output.
"""
import os
import subprocess
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
class VueWidgetBuilder:
"""Manages Vue widget build checking and auto-building."""
def __init__(self, project_root: Optional[Path] = None):
"""
Initialize the builder.
Args:
project_root: Project root directory. If None, auto-detects.
"""
if project_root is None:
# Auto-detect project root (where __init__.py is)
project_root = Path(__file__).parent.parent
self.project_root = Path(project_root)
self.vue_widgets_dir = self.project_root / "vue-widgets"
self.build_output_dir = self.project_root / "web" / "comfyui" / "vue-widgets"
self.src_dir = self.vue_widgets_dir / "src"
def check_build_exists(self) -> bool:
"""
Check if build output exists.
Returns:
True if at least one built .js file exists
"""
if not self.build_output_dir.exists():
return False
js_files = list(self.build_output_dir.glob("*.js"))
return len(js_files) > 0
def check_build_outdated(self) -> bool:
"""
Check if source code is newer than build output.
Returns:
True if source is newer, False otherwise or if can't determine
"""
if not self.src_dir.exists():
return False
if not self.check_build_exists():
return True
try:
# Get newest file in source directory
src_files = [f for f in self.src_dir.rglob("*") if f.is_file()]
if not src_files:
return False
newest_src_time = max(f.stat().st_mtime for f in src_files)
# Get oldest file in build directory
build_files = [f for f in self.build_output_dir.rglob("*.js") if f.is_file()]
if not build_files:
return True
oldest_build_time = min(f.stat().st_mtime for f in build_files)
return newest_src_time > oldest_build_time
except Exception as e:
logger.debug(f"Error checking build timestamps: {e}")
return False
def check_node_available(self) -> bool:
"""
Check if Node.js is available.
Returns:
True if node/npm are available
"""
try:
result = subprocess.run(
["npm", "--version"],
capture_output=True,
timeout=5,
check=False
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def build_widgets(self, force: bool = False) -> bool:
"""
Build Vue widgets.
Args:
force: If True, build even if not needed
Returns:
True if build succeeded or not needed, False if failed
"""
if not force and self.check_build_exists() and not self.check_build_outdated():
logger.debug("Vue widgets build is up to date")
return True
if not self.vue_widgets_dir.exists():
logger.warning(f"Vue widgets directory not found: {self.vue_widgets_dir}")
return False
if not self.check_node_available():
logger.warning(
"Node.js/npm not found. Cannot build Vue widgets. "
"Please install Node.js or build manually: cd vue-widgets && npm run build"
)
return False
logger.info("Building Vue widgets...")
try:
# Check if node_modules exists, if not run npm install first
node_modules = self.vue_widgets_dir / "node_modules"
if not node_modules.exists():
logger.info("Installing npm dependencies...")
install_result = subprocess.run(
["npm", "install"],
cwd=self.vue_widgets_dir,
capture_output=True,
timeout=300, # 5 minutes for install
check=False
)
if install_result.returncode != 0:
logger.error(f"npm install failed: {install_result.stderr.decode()}")
return False
# Run build
build_result = subprocess.run(
["npm", "run", "build"],
cwd=self.vue_widgets_dir,
capture_output=True,
timeout=120, # 2 minutes for build
check=False
)
if build_result.returncode == 0:
logger.info("✓ Vue widgets built successfully")
return True
else:
logger.error(f"Build failed: {build_result.stderr.decode()}")
return False
except subprocess.TimeoutExpired:
logger.error("Build timed out")
return False
except Exception as e:
logger.error(f"Build error: {e}")
return False
def ensure_built(self, auto_build: bool = True, warn_only: bool = True) -> bool:
"""
Ensure Vue widgets are built, optionally auto-building if needed.
Args:
auto_build: If True, attempt to build if needed
warn_only: If True, only warn on failure instead of raising
Returns:
True if widgets are available (built or successfully auto-built)
Raises:
RuntimeError: If warn_only=False and build is missing/failed
"""
if self.check_build_exists():
# Build exists, check if outdated
if self.check_build_outdated():
logger.info("Vue widget source code is newer than build")
if auto_build:
return self.build_widgets()
else:
logger.warning(
"Vue widget build is outdated. "
"Please rebuild: cd vue-widgets && npm run build"
)
return True
# No build exists
logger.warning("Vue widget build not found")
if auto_build:
if self.build_widgets():
return True
else:
msg = (
"Failed to build Vue widgets. "
"Please build manually: cd vue-widgets && npm install && npm run build"
)
if warn_only:
logger.warning(msg)
return False
else:
raise RuntimeError(msg)
else:
msg = "Vue widgets not built. Please run: cd vue-widgets && npm install && npm run build"
if warn_only:
logger.warning(msg)
return False
else:
raise RuntimeError(msg)
def check_and_build_vue_widgets(
auto_build: bool = True,
warn_only: bool = True,
force: bool = False
) -> bool:
"""
Convenience function to check and build Vue widgets.
Args:
auto_build: If True, attempt to build if needed
warn_only: If True, only warn on failure instead of raising
force: If True, force rebuild even if up to date
Returns:
True if widgets are available
"""
builder = VueWidgetBuilder()
if force:
return builder.build_widgets(force=True)
return builder.ensure_built(auto_build=auto_build, warn_only=warn_only)

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.11" version = "0.9.12"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -1,82 +1,33 @@
{ {
"id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc", "id": "42803a29-02dc-49e1-b798-27da70e8b408",
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg", "file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
"title": "a mysterious, steampunk-inspired character standing in a dramatic pose", "title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
"modified": 1741837612.3931093, "modified": 1754897325.0507245,
"created_date": 1741492786.5581934, "created_date": 1754897325.0507245,
"base_model": "Flux.1 D", "base_model": "Illustrious",
"loras": [ "loras": [
{ {
"file_name": "ChronoDivinitiesFlux_r1", "file_name": "",
"hash": "ddbc5abd00db46ad464f5e3ca85f8f7121bc14b594d6785f441d9b002fffe66a", "hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
"strength": 0.8, "strength": 1.0,
"modelVersionId": 1438879, "modelVersionId": 2007092,
"modelName": "Chrono Divinities - By HailoKnight", "modelName": "Pony: People's Works +",
"modelVersionName": "Flux" "modelVersionName": "v8_Illusv1.0",
}, "isDeleted": false,
{ "exclude": false
"file_name": "flux.1_lora_flyway_ink-dynamic",
"hash": "4b4f3b469a0d5d3a04a46886abfa33daa37a905db070ccfbd10b345c6fb00eff",
"strength": 0.2,
"modelVersionId": 914935,
"modelName": "Ink-style",
"modelVersionName": "ink-dynamic"
},
{
"file_name": "ck-painterly-fantasy-000017",
"hash": "48c67064e2936aec342580a2a729d91d75eb818e45ecf993b9650cc66c94c420",
"strength": 0.2,
"modelVersionId": 1189379,
"modelName": "Painterly Fantasy by ChronoKnight - [FLUX & IL]",
"modelVersionName": "FLUX"
},
{
"file_name": "RetroAnimeFluxV1",
"hash": "8f43c31b6c3238ac44195c970d511d759c5893bddd00f59f42b8fe51e8e76fa0",
"strength": 0.8,
"modelVersionId": 806265,
"modelName": "Retro Anime Flux - Style",
"modelVersionName": "v1.0"
},
{
"file_name": "Mezzotint_Artstyle_for_Flux_-_by_Ethanar",
"hash": "e6961502769123bf23a66c5c5298d76264fd6b9610f018319a0ccb091bfc308e",
"strength": 0.2,
"modelVersionId": 757030,
"modelName": "Mezzotint Artstyle for Flux - by Ethanar",
"modelVersionName": "V1"
},
{
"file_name": "FluxMythG0thicL1nes",
"hash": "ecb03595de62bd6183a0dd2b38bea35669fd4d509f4bbae5aa0572cfb7ef4279",
"strength": 0.4,
"modelVersionId": 1202162,
"modelName": "Velvet's Mythic Fantasy Styles | Flux + Pony + illustrious",
"modelVersionName": "Flux Gothic Lines"
},
{
"file_name": "Elden_Ring_-_Yoshitaka_Amano",
"hash": "c660c4c55320be7206cb6a917c59d8da3953cc07169fe10bda833a54ec0024f9",
"strength": 0.75,
"modelVersionId": 746484,
"modelName": "Elden Ring - Yoshitaka Amano",
"modelVersionName": "V1"
} }
], ],
"gen_params": { "gen_params": {
"prompt": "a mysterious, steampunk-inspired character standing in a dramatic pose. The character is dressed in a long, intricately detailed dark coat with ornate patterns, a wide-brimmed hat, and leather boots. The face is partially obscured by the hat's shadow, adding to the enigmatic aura. The background showcases a large, antique clock with Roman numerals, surrounded by dynamic lightning and ethereal white birds, enhancing the fantastical atmosphere. The color palette is dominated by dark tones with striking contrasts of white and blue lightning, creating a sense of tension and energy. The overall composition is vertical, with the character centrally positioned, exuding a sense of power and mystery. hkchrono", "prompt": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect eyes, realistic eyes,\n(flat colors:1.5), (anime:1.5), (lineart:1.5),\nclose-up, solo, tongue, 1girl, food, (saliva:0.1), open mouth, candy, simple background, blue background, large lollipop, tongue out, fade background, lips, hand up, holding, looking at viewer, licking, seductive, half-closed eyes,",
"negative_prompt": "", "negative_prompt": "shiny skin,",
"checkpoint": { "steps": 19,
"type": "checkpoint", "sampler": "Euler a",
"modelVersionId": 691639, "cfg_scale": 5,
"modelName": "FLUX", "seed": 1765271748,
"modelVersionName": "Dev"
},
"steps": "30",
"sampler": "Undefined",
"cfg_scale": "3.5",
"seed": "1472903449",
"size": "832x1216", "size": "832x1216",
"clip_skip": "2" "clip_skip": 2
} },
"fingerprint": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a:1.0",
"source_path": "https://civitai.com/images/92427432",
"folder": "test"
} }

View File

@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
self.locales_dir = locales_dir self.locales_dir = locales_dir
self.verbose = verbose self.verbose = verbose
self.reference_locale = 'en' self.reference_locale = 'en'
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko'] self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko', 'he']
def log(self, message: str, level: str = 'INFO'): def log(self, message: str, level: str = 'INFO'):
"""Log a message if verbose mode is enabled.""" """Log a message if verbose mode is enabled."""

View File

@@ -1,8 +1,10 @@
html, body { html,
body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
overflow: hidden; /* Disable default scrolling */ overflow: hidden;
/* Disable default scrolling */
} }
/* 针对Firefox */ /* 针对Firefox */
@@ -58,12 +60,12 @@ html, body {
--badge-update-bg: oklch(72% 0.2 220); --badge-update-bg: oklch(72% 0.2 220);
--badge-update-text: oklch(28% 0.03 220); --badge-update-text: oklch(28% 0.03 220);
--badge-update-glow: oklch(72% 0.2 220 / 0.28); --badge-update-glow: oklch(72% 0.2 220 / 0.28);
/* Spacing Scale */ /* Spacing Scale */
--space-1: calc(8px * 1); --space-1: calc(8px * 1);
--space-2: calc(8px * 2); --space-2: calc(8px * 2);
--space-3: calc(8px * 3); --space-3: calc(8px * 3);
/* Z-index Scale */ /* Z-index Scale */
--z-base: 10; --z-base: 10;
--z-header: 100; --z-header: 100;
@@ -75,8 +77,9 @@ html, body {
--border-radius-sm: 8px; --border-radius-sm: 8px;
--border-radius-xs: 4px; --border-radius-xs: 4px;
--scrollbar-width: 8px; /* 添加滚动条宽度变量 */ --scrollbar-width: 8px;
/* 添加滚动条宽度变量 */
/* Shortcut styles */ /* Shortcut styles */
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12); --shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25); --shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
@@ -104,7 +107,8 @@ html[data-theme="light"] {
--lora-surface: oklch(25% 0.02 256 / 0.98); --lora-surface: oklch(25% 0.02 256 / 0.98);
--lora-border: oklch(90% 0.02 256 / 0.15); --lora-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(98% 0.02 256); --lora-text: oklch(98% 0.02 256);
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */ --lora-warning: oklch(75% 0.25 80);
/* Modified to be used with oklch() */
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent); --lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent); --lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
--badge-update-bg: oklch(62% 0.18 220); --badge-update-bg: oklch(62% 0.18 220);
@@ -118,5 +122,10 @@ body {
color: var(--text-color); color: var(--text-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-top: 0; /* Remove the padding-top */ padding-top: 0;
/* Remove the padding-top */
} }
.hidden {
display: none !important;
}

View File

@@ -4,9 +4,11 @@
position: fixed; position: fixed;
top: 0; top: 0;
z-index: var(--z-header); z-index: var(--z-header);
height: 48px; /* Reduced height */ height: 48px;
/* Reduced height */
width: 100%; width: 100%;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* Slightly stronger shadow */ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/* Slightly stronger shadow */
} }
.header-container { .header-container {
@@ -25,6 +27,7 @@
max-width: 1800px; max-width: 1800px;
} }
} }
@media (min-width: 3000px) { @media (min-width: 3000px) {
.header-container { .header-container {
max-width: 2400px; max-width: 2400px;
@@ -43,7 +46,7 @@
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
color: var(--text-color); color: var(--text-color);
gap: 2px; gap: 8px;
} }
.app-logo { .app-logo {
@@ -153,7 +156,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.header-controls > div { .header-controls>div {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
@@ -168,14 +171,15 @@
position: relative; position: relative;
} }
.header-controls > div:hover { .header-controls>div:hover {
background: var(--lora-accent); background: var(--lora-accent);
color: white; color: white;
transform: translateY(-2px); transform: translateY(-2px);
} }
.theme-toggle { .theme-toggle {
position: relative; /* Ensure relative positioning for the container */ position: relative;
/* Ensure relative positioning for the container */
} }
.theme-toggle .light-icon, .theme-toggle .light-icon,
@@ -184,7 +188,8 @@
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); /* Center perfectly */ transform: translate(-50%, -50%);
/* Center perfectly */
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
@@ -246,23 +251,24 @@
/* Mobile adjustments */ /* Mobile adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.app-title { .app-title {
display: none; /* Hide text title on mobile */ display: none;
/* Hide text title on mobile */
} }
.header-controls { .header-controls {
gap: 4px; gap: 4px;
} }
.header-controls > div { .header-controls>div {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
.header-search { .header-search {
max-width: none; max-width: none;
margin: 0 0.5rem; margin: 0 0.5rem;
} }
.main-nav { .main-nav {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
@@ -273,12 +279,13 @@
.header-container { .header-container {
padding: 0 8px; padding: 0 8px;
} }
.main-nav { .main-nav {
display: none; /* Hide navigation on very small screens */ display: none;
/* Hide navigation on very small screens */
} }
.header-search { .header-search {
flex: 1; flex: 1;
} }
} }

View File

@@ -1,7 +1,8 @@
/* Import Modal Styles */ /* Import Modal Styles */
.import-step { .import-step {
margin: var(--space-2) 0; margin: var(--space-2) 0;
transition: none !important; /* Disable any transitions that might affect display */ transition: none !important;
/* Disable any transitions that might affect display */
} }
/* Import Mode Toggle */ /* Import Mode Toggle */
@@ -107,7 +108,8 @@
justify-content: center; justify-content: center;
} }
.recipe-image img { .recipe-image img,
.recipe-preview-video {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
@@ -379,7 +381,7 @@
.recipe-details-layout { .recipe-details-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.recipe-image-container { .recipe-image-container {
height: 150px; height: 150px;
} }
@@ -512,14 +514,17 @@
/* Prevent layout shift with scrollbar */ /* Prevent layout shift with scrollbar */
.modal-content { .modal-content {
overflow-y: scroll; /* Always show scrollbar */ overflow-y: scroll;
scrollbar-gutter: stable; /* Reserve space for scrollbar */ /* Always show scrollbar */
scrollbar-gutter: stable;
/* Reserve space for scrollbar */
} }
/* For browsers that don't support scrollbar-gutter */ /* For browsers that don't support scrollbar-gutter */
@supports not (scrollbar-gutter: stable) { @supports not (scrollbar-gutter: stable) {
.modal-content { .modal-content {
padding-right: calc(var(--space-2) + var(--scrollbar-width)); /* Add extra padding for scrollbar */ padding-right: calc(var(--space-2) + var(--scrollbar-width));
/* Add extra padding for scrollbar */
} }
} }
@@ -586,7 +591,8 @@
/* Remove the old warning-message styles that were causing layout issues */ /* Remove the old warning-message styles that were causing layout issues */
.warning-message { .warning-message {
display: none; /* Hide the old style */ display: none;
/* Hide the old style */
} }
/* Update deleted badge to be more prominent */ /* Update deleted badge to be more prominent */
@@ -613,7 +619,8 @@
color: var(--lora-error); color: var(--lora-error);
font-size: 0.9em; font-size: 0.9em;
margin-top: 8px; margin-top: 8px;
min-height: 20px; /* Ensure there's always space for the error message */ min-height: 20px;
/* Ensure there's always space for the error message */
font-weight: 500; font-weight: 500;
} }
@@ -662,8 +669,15 @@
} }
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.duplicate-warning { .duplicate-warning {
@@ -779,6 +793,7 @@
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
@@ -793,9 +808,9 @@
opacity: 0.8; opacity: 0.8;
} }
.duplicate-recipe-date, .duplicate-recipe-date,
.duplicate-recipe-lora-count { .duplicate-recipe-lora-count {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }

View File

@@ -41,7 +41,7 @@
padding: 8px; padding: 8px;
position: absolute; position: absolute;
z-index: 9999; /* Ensure tooltip appears above cards */ z-index: 9999; /* Ensure tooltip appears above cards */
left: 120%; /* Position tooltip to the right of the icon */ right: 120%; /* Position tooltip to the left of the icon */
top: 50%; /* Vertically center */ top: 50%; /* Vertically center */
transform: translateY(-15%); /* Vertically center */ transform: translateY(-15%); /* Vertically center */
opacity: 0; opacity: 0;
@@ -56,11 +56,11 @@
content: ""; content: "";
position: absolute; position: absolute;
top: 50%; /* Vertically center arrow */ top: 50%; /* Vertically center arrow */
right: 100%; /* Arrow on the left side */ left: 100%; /* Arrow on the right side */
margin-top: -5px; margin-top: -5px;
border-width: 5px; border-width: 5px;
border-style: solid; border-style: solid;
border-color: transparent var(--lora-border) transparent transparent; /* Arrow points left */ border-color: transparent transparent transparent var(--lora-border); /* Arrow points right */
} }
.tooltip:hover .tooltiptext { .tooltip:hover .tooltiptext {

View File

@@ -7,6 +7,7 @@
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
padding-bottom: var(--space-2); padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border); border-bottom: 1px solid var(--lora-border);
position: relative;
} }
.modal-header-actions { .modal-header-actions {
@@ -18,6 +19,55 @@
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
} }
.modal-header-row {
width: 84%;
display: flex;
align-items: flex-start;
gap: var(--space-2);
position: relative;
padding-right: 96px; /* Reserve space for nav buttons to prevent wrapping overlap */
}
.modal-nav-controls {
display: inline-flex;
gap: 8px;
align-items: center;
margin-left: auto;
position: absolute;
top: 0;
right: 0;
}
.modal-nav-btn {
display: grid;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 50%;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
}
.modal-nav-btn:hover:not(:disabled) {
background: var(--bg-hover, var(--card-bg));
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.modal-nav-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.modal-nav-btn i {
font-size: 14px;
}
.modal-header-actions .license-restrictions { .modal-header-actions .license-restrictions {
margin-left: auto; margin-left: auto;
} }

View File

@@ -129,6 +129,12 @@
border-color: var(--lora-accent); border-color: var(--lora-accent);
} }
.media-control-btn.set-nsfw-btn:hover {
background: var(--warning-color, #f0ad4e);
color: #fff;
border-color: var(--warning-color, #f0ad4e);
}
.media-control-btn.example-delete-btn:hover:not(.disabled) { .media-control-btn.example-delete-btn:hover:not(.disabled) {
background: var(--lora-error); background: var(--lora-error);
color: white; color: white;
@@ -475,4 +481,4 @@
/* For dark theme */ /* For dark theme */
[data-theme="dark"] .import-container { [data-theme="dark"] .import-container {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }

View File

@@ -35,6 +35,7 @@ body.modal-open {
0 10px 15px -3px rgba(0, 0, 0, 0.05); 0 10px 15px -3px rgba(0, 0, 0, 0.05);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; /* 防止水平滚动条 */ overflow-x: hidden; /* 防止水平滚动条 */
scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */
} }
.modal-content-large { .modal-content-large {
@@ -121,6 +122,7 @@ body.modal-open {
cursor: pointer; cursor: pointer;
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s; transition: opacity 0.2s;
z-index: 10;
} }
.close:hover { .close:hover {

View File

@@ -328,11 +328,11 @@
display: block; display: block;
} }
.tree-node.has-children > .tree-node-content .tree-expand-icon { .tree-node.has-children>.tree-node-content .tree-expand-icon {
opacity: 1; opacity: 1;
} }
.tree-node:not(.has-children) > .tree-node-content .tree-expand-icon { .tree-node:not(.has-children)>.tree-node-content .tree-expand-icon {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
@@ -470,11 +470,11 @@
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
} }
.inline-toggle-container .toggle-switch input:checked + .toggle-slider { .inline-toggle-container .toggle-switch input:checked+.toggle-slider {
background-color: var(--lora-accent); background-color: var(--lora-accent);
} }
.inline-toggle-container .toggle-switch input:checked + .toggle-slider:before { .inline-toggle-container .toggle-switch input:checked+.toggle-slider:before {
transform: translateX(18px); transform: translateX(18px);
} }

View File

@@ -20,7 +20,7 @@
} }
.settings-modal { .settings-modal {
max-width: 650px; /* Further increased from 600px for more space */ max-width: 700px; /* Further increased from 600px for more space */
} }
.settings-header { .settings-header {

View File

@@ -242,6 +242,20 @@
border-color: var(--lora-error-border); border-color: var(--lora-error-border);
} }
/* Subtle styling for special system tags like "No tags" */
.filter-tag.special-tag {
border-style: dashed;
opacity: 0.8;
font-style: italic;
}
/* Ensure solid border and full opacity when active or excluded */
.filter-tag.special-tag.active,
.filter-tag.special-tag.exclude {
border-style: solid;
opacity: 1;
}
/* Tag filter styles */ /* Tag filter styles */
.tag-filter { .tag-filter {
display: flex; display: flex;

View File

@@ -52,7 +52,7 @@ export function getApiEndpoints(modelType) {
if (!Object.values(MODEL_TYPES).includes(modelType)) { if (!Object.values(MODEL_TYPES).includes(modelType)) {
throw new Error(`Invalid model type: ${modelType}`); throw new Error(`Invalid model type: ${modelType}`);
} }
return { return {
// Base CRUD operations // Base CRUD operations
list: `/api/lm/${modelType}/list`, list: `/api/lm/${modelType}/list`,
@@ -60,17 +60,18 @@ export function getApiEndpoints(modelType) {
exclude: `/api/lm/${modelType}/exclude`, exclude: `/api/lm/${modelType}/exclude`,
rename: `/api/lm/${modelType}/rename`, rename: `/api/lm/${modelType}/rename`,
save: `/api/lm/${modelType}/save-metadata`, save: `/api/lm/${modelType}/save-metadata`,
cancelTask: `/api/lm/${modelType}/cancel-task`,
// Bulk operations // Bulk operations
bulkDelete: `/api/lm/${modelType}/bulk-delete`, bulkDelete: `/api/lm/${modelType}/bulk-delete`,
// Tag operations // Tag operations
addTags: `/api/lm/${modelType}/add-tags`, addTags: `/api/lm/${modelType}/add-tags`,
// Move operations (now common for all model types that support move) // Move operations (now common for all model types that support move)
moveModel: `/api/lm/${modelType}/move_model`, moveModel: `/api/lm/${modelType}/move_model`,
moveBulk: `/api/lm/${modelType}/move_models_bulk`, moveBulk: `/api/lm/${modelType}/move_models_bulk`,
// CivitAI integration // CivitAI integration
fetchCivitai: `/api/lm/${modelType}/fetch-civitai`, fetchCivitai: `/api/lm/${modelType}/fetch-civitai`,
fetchAllCivitai: `/api/lm/${modelType}/fetch-all-civitai`, fetchAllCivitai: `/api/lm/${modelType}/fetch-all-civitai`,
@@ -82,10 +83,10 @@ export function getApiEndpoints(modelType) {
modelUpdateVersions: `/api/lm/${modelType}/updates/versions`, modelUpdateVersions: `/api/lm/${modelType}/updates/versions`,
ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`, ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`,
ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`, ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`,
// Preview management // Preview management
replacePreview: `/api/lm/${modelType}/replace-preview`, replacePreview: `/api/lm/${modelType}/replace-preview`,
// Query operations // Query operations
scan: `/api/lm/${modelType}/scan`, scan: `/api/lm/${modelType}/scan`,
topTags: `/api/lm/${modelType}/top-tags`, topTags: `/api/lm/${modelType}/top-tags`,
@@ -99,11 +100,11 @@ export function getApiEndpoints(modelType) {
verify: `/api/lm/${modelType}/verify-duplicates`, verify: `/api/lm/${modelType}/verify-duplicates`,
metadata: `/api/lm/${modelType}/metadata`, metadata: `/api/lm/${modelType}/metadata`,
modelDescription: `/api/lm/${modelType}/model-description`, modelDescription: `/api/lm/${modelType}/model-description`,
// Auto-organize operations // Auto-organize operations
autoOrganize: `/api/lm/${modelType}/auto-organize`, autoOrganize: `/api/lm/${modelType}/auto-organize`,
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`, autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
// Model-specific endpoints (will be merged with specific configs) // Model-specific endpoints (will be merged with specific configs)
specific: {} specific: {}
}; };
@@ -144,7 +145,7 @@ export function getCompleteApiConfig(modelType) {
const baseEndpoints = getApiEndpoints(modelType); const baseEndpoints = getApiEndpoints(modelType);
const specificEndpoints = MODEL_SPECIFIC_ENDPOINTS[modelType] || {}; const specificEndpoints = MODEL_SPECIFIC_ENDPOINTS[modelType] || {};
const config = MODEL_CONFIG[modelType]; const config = MODEL_CONFIG[modelType];
return { return {
modelType, modelType,
config, config,

View File

@@ -2,9 +2,9 @@ import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import { import {
getCompleteApiConfig, getCompleteApiConfig,
getCurrentModelType, getCurrentModelType,
isValidModelType, isValidModelType,
DOWNLOAD_ENDPOINTS, DOWNLOAD_ENDPOINTS,
WS_ENDPOINTS WS_ENDPOINTS
@@ -51,7 +51,7 @@ export class BaseModelApiClient {
async fetchModelsPage(page = 1, pageSize = null) { async fetchModelsPage(page = 1, pageSize = null) {
const pageState = this.getPageState(); const pageState = this.getPageState();
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize; const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
try { try {
const params = this._buildQueryParams({ const params = this._buildQueryParams({
page, page,
@@ -63,9 +63,9 @@ export class BaseModelApiClient {
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`); throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
return { return {
items: data.items, items: data.items,
totalItems: data.total, totalItems: data.total,
@@ -74,7 +74,7 @@ export class BaseModelApiClient {
hasMore: page < data.total_pages, hasMore: page < data.total_pages,
folders: data.folders folders: data.folders
}; };
} catch (error) { } catch (error) {
console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error); console.error(`Error fetching ${this.apiConfig.config.displayName}s:`, error);
showToast('toast.api.fetchFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error'); showToast('toast.api.fetchFailed', { type: this.apiConfig.config.displayName, message: error.message }, 'error');
@@ -82,9 +82,22 @@ export class BaseModelApiClient {
} }
} }
async cancelTask() {
try {
const endpoint = this.apiConfig.endpoints.cancelTask;
const response = await fetch(endpoint, {
method: 'POST'
});
return await response.json();
} catch (error) {
console.error(`Error cancelling task for ${this.modelType}:`, error);
return { success: false, error: error.message };
}
}
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) { async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
const pageState = this.getPageState(); const pageState = this.getPageState();
try { try {
state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`); state.loadingManager.showSimpleLoading(`Loading more ${this.apiConfig.config.displayName}s...`);
@@ -92,22 +105,22 @@ export class BaseModelApiClient {
if (resetPage) { if (resetPage) {
pageState.currentPage = 1; // Reset to first page pageState.currentPage = 1; // Reset to first page
} }
const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize); const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize);
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
result.totalItems, result.totalItems,
result.hasMore result.hasMore
); );
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = pageState.currentPage + 1; pageState.currentPage = pageState.currentPage + 1;
if (updateFolders) { if (updateFolders) {
sidebarManager.refresh(); sidebarManager.refresh();
} }
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error); console.error(`Error reloading ${this.apiConfig.config.displayName}s:`, error);
@@ -128,13 +141,13 @@ export class BaseModelApiClient {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }) body: JSON.stringify({ file_path: filePath })
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to delete ${this.apiConfig.config.singularName}: ${response.statusText}`); throw new Error(`Failed to delete ${this.apiConfig.config.singularName}: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
if (state.virtualScroller) { if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath); state.virtualScroller.removeItemByFilePath(filePath);
@@ -162,13 +175,13 @@ export class BaseModelApiClient {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }) body: JSON.stringify({ file_path: filePath })
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to exclude ${this.apiConfig.config.singularName}: ${response.statusText}`); throw new Error(`Failed to exclude ${this.apiConfig.config.singularName}: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
if (state.virtualScroller) { if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath); state.virtualScroller.removeItemByFilePath(filePath);
@@ -190,7 +203,7 @@ export class BaseModelApiClient {
async renameModelFile(filePath, newFileName) { async renameModelFile(filePath, newFileName) {
try { try {
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`); state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
const response = await fetch(this.apiConfig.endpoints.rename, { const response = await fetch(this.apiConfig.endpoints.rename, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -203,12 +216,12 @@ export class BaseModelApiClient {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
state.virtualScroller.updateSingleItem(filePath, { state.virtualScroller.updateSingleItem(filePath, {
file_name: newFileName, file_name: newFileName,
file_path: result.new_file_path, file_path: result.new_file_path,
preview_url: result.new_preview_path preview_url: result.new_preview_path
}); });
showToast('toast.api.fileNameUpdated', {}, 'success'); showToast('toast.api.fileNameUpdated', {}, 'success');
} else { } else {
showToast('toast.api.fileRenameFailed', { error: result.error || 'Unknown error' }, 'error'); showToast('toast.api.fileRenameFailed', { error: result.error || 'Unknown error' }, 'error');
@@ -227,21 +240,21 @@ export class BaseModelApiClient {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'image/*,video/mp4'; input.accept = 'image/*,video/mp4';
input.onchange = async () => { input.onchange = async () => {
if (!input.files || !input.files[0]) return; if (!input.files || !input.files[0]) return;
const file = input.files[0]; const file = input.files[0];
await this.uploadPreview(filePath, file); await this.uploadPreview(filePath, file);
}; };
input.click(); input.click();
} }
async uploadPreview(filePath, file, nsfwLevel = 0) { async uploadPreview(filePath, file, nsfwLevel = 0) {
try { try {
state.loadingManager.showSimpleLoading('Uploading preview...'); state.loadingManager.showSimpleLoading('Uploading preview...');
const formData = new FormData(); const formData = new FormData();
formData.append('preview_file', file); formData.append('preview_file', file);
formData.append('model_path', filePath); formData.append('model_path', filePath);
@@ -251,18 +264,18 @@ export class BaseModelApiClient {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Upload failed'); throw new Error('Upload failed');
} }
const data = await response.json(); const data = await response.json();
const pageState = this.getPageState(); const pageState = this.getPageState();
const timestamp = Date.now(); const timestamp = Date.now();
if (pageState.previewVersions) { if (pageState.previewVersions) {
pageState.previewVersions.set(filePath, timestamp); pageState.previewVersions.set(filePath, timestamp);
const storageKey = `${this.modelType}_preview_versions`; const storageKey = `${this.modelType}_preview_versions`;
saveMapToStorage(storageKey, pageState.previewVersions); saveMapToStorage(storageKey, pageState.previewVersions);
} }
@@ -285,7 +298,7 @@ export class BaseModelApiClient {
async saveModelMetadata(filePath, data) { async saveModelMetadata(filePath, data) {
try { try {
state.loadingManager.showSimpleLoading('Saving metadata...'); state.loadingManager.showSimpleLoading('Saving metadata...');
const response = await fetch(this.apiConfig.endpoints.save, { const response = await fetch(this.apiConfig.endpoints.save, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -308,6 +321,7 @@ export class BaseModelApiClient {
async addTags(filePath, data) { async addTags(filePath, data) {
try { try {
state.loadingManager.showSimpleLoading('Adding tags...');
const response = await fetch(this.apiConfig.endpoints.addTags, { const response = await fetch(this.apiConfig.endpoints.addTags, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -331,26 +345,36 @@ export class BaseModelApiClient {
} catch (error) { } catch (error) {
console.error('Error adding tags:', error); console.error('Error adding tags:', error);
throw error; throw error;
} finally {
state.loadingManager.hide();
} }
} }
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {
try { try {
state.loadingManager.showSimpleLoading( state.loadingManager.show(
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...` `${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
0
); );
state.loadingManager.showCancelButton(() => this.cancelTask());
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin); const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild); url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
} }
const data = await response.json();
if (data.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
resetAndReload(true); resetAndReload(true);
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success'); showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
} catch (error) { } catch (error) {
console.error('Refresh failed:', error); console.error('Refresh failed:', error);
@@ -364,7 +388,7 @@ export class BaseModelApiClient {
async refreshSingleModelMetadata(filePath) { async refreshSingleModelMetadata(filePath) {
try { try {
state.loadingManager.showSimpleLoading('Refreshing metadata...'); state.loadingManager.showSimpleLoading('Refreshing metadata...');
const response = await fetch(this.apiConfig.endpoints.fetchCivitai, { const response = await fetch(this.apiConfig.endpoints.fetchCivitai, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -376,7 +400,7 @@ export class BaseModelApiClient {
} }
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
if (data.metadata && state.virtualScroller) { if (data.metadata && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, data.metadata); state.virtualScroller.updateSingleItem(filePath, data.metadata);
@@ -399,21 +423,22 @@ export class BaseModelApiClient {
async fetchCivitaiMetadata() { async fetchCivitaiMetadata() {
let ws = null; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
try { try {
loading.showCancelButton(() => this.cancelTask());
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => { const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
switch(data.status) { switch (data.status) {
case 'started': case 'started':
loading.setStatus('Starting metadata fetch...'); loading.setStatus('Starting metadata fetch...');
break; break;
case 'processing': case 'processing':
const percent = ((data.processed / data.total) * 100).toFixed(1); const percent = ((data.processed / data.total) * 100).toFixed(1);
loading.setProgress(percent); loading.setProgress(percent);
@@ -421,47 +446,56 @@ export class BaseModelApiClient {
`Processing (${data.processed}/${data.total}) ${data.current_name}` `Processing (${data.processed}/${data.total}) ${data.current_name}`
); );
break; break;
case 'completed': case 'completed':
loading.setProgress(100); loading.setProgress(100);
loading.setStatus( loading.setStatus(
`Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s` `Completed: Updated ${data.success} of ${data.processed} ${this.apiConfig.config.displayName}s`
); );
resolve(); resolve(data);
break; break;
case 'cancelled':
loading.setStatus('Operation cancelled by user');
resolve(data); // Consider it complete but marked as cancelled
break;
case 'error': case 'error':
reject(new Error(data.error)); reject(new Error(data.error));
break; break;
} }
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
reject(new Error('WebSocket error: ' + error.message)); reject(new Error('WebSocket error: ' + error.message));
}; };
}); });
// Wait for WebSocket connection to establish // Wait for WebSocket connection to establish
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ws.onopen = resolve; ws.onopen = resolve;
ws.onerror = reject; ws.onerror = reject;
}); });
const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, { const response = await fetch(this.apiConfig.endpoints.fetchAllCivitai, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}) body: JSON.stringify({})
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch metadata'); throw new Error('Failed to fetch metadata');
} }
// Wait for the operation to complete via WebSocket // Wait for the operation to complete via WebSocket
await operationComplete; const finalData = await operationComplete;
resetAndReload(false); resetAndReload(false);
showToast('toast.api.metadataUpdateComplete', {}, 'success'); if (finalData && finalData.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: finalData.success, total: finalData.total }, 'info');
} else {
showToast('toast.api.metadataUpdateComplete', {}, 'success');
}
} catch (error) { } catch (error) {
console.error('Error fetching metadata:', error); console.error('Error fetching metadata:', error);
showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error'); showToast('toast.api.metadataFetchFailed', { message: error.message }, 'error');
@@ -487,20 +521,28 @@ export class BaseModelApiClient {
let failedItems = []; let failedItems = [];
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...'); const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
let cancelled = false;
progressController.showCancelButton(() => {
cancelled = true;
this.cancelTask();
});
try { try {
for (let i = 0; i < filePaths.length; i++) { for (let i = 0; i < filePaths.length; i++) {
if (cancelled) {
break;
}
const filePath = filePaths[i]; const filePath = filePaths[i];
const fileName = filePath.split('/').pop(); const fileName = filePath.split('/').pop();
try { try {
const overallProgress = Math.floor((i / totalItems) * 100); const overallProgress = Math.floor((i / totalItems) * 100);
progressController.updateProgress( progressController.updateProgress(
overallProgress, overallProgress,
fileName, fileName,
`Processing ${i + 1}/${totalItems}: ${fileName}` `Processing ${i + 1}/${totalItems}: ${fileName}`
); );
const response = await fetch(this.apiConfig.endpoints.fetchCivitai, { const response = await fetch(this.apiConfig.endpoints.fetchCivitai, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -512,7 +554,7 @@ export class BaseModelApiClient {
} }
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
if (data.metadata && state.virtualScroller) { if (data.metadata && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, data.metadata); state.virtualScroller.updateSingleItem(filePath, data.metadata);
@@ -521,30 +563,25 @@ export class BaseModelApiClient {
} else { } else {
throw new Error(data.error || 'Failed to refresh metadata'); throw new Error(data.error || 'Failed to refresh metadata');
} }
} catch (error) { } catch (error) {
console.error(`Error refreshing metadata for ${fileName}:`, error); console.error(`Error refreshing metadata for ${fileName}:`, error);
failedItems.push({ filePath, fileName, error: error.message }); failedItems.push({ filePath, fileName, error: error.message });
} }
processedCount++; processedCount++;
} }
let completionMessage; let completionMessage;
if (successCount === totalItems) { if (cancelled) {
completionMessage = translate('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, `Operation cancelled. ${successCount} items processed.`);
showToast('toast.api.operationCancelledPartial', { success: successCount, total: totalItems }, 'info');
} else if (successCount === totalItems) {
completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`); completionMessage = translate('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success'); showToast('toast.api.bulkMetadataCompleteAll', { count: successCount, type: this.apiConfig.config.displayName }, 'success');
} else if (successCount > 0) { } else if (successCount > 0) {
completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`); completionMessage = translate('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, `Refreshed ${successCount} of ${totalItems} ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning'); showToast('toast.api.bulkMetadataCompletePartial', { success: successCount, total: totalItems, type: this.apiConfig.config.displayName }, 'warning');
// if (failedItems.length > 0) {
// const failureMessage = failedItems.length <= 3
// ? failedItems.map(item => `${item.fileName}: ${item.error}`).join('\n')
// : failedItems.slice(0, 3).map(item => `${item.fileName}: ${item.error}`).join('\n') +
// `\n(and ${failedItems.length - 3} more)`;
// showToast('toast.api.bulkMetadataFailureDetails', { failures: failureMessage }, 'warning', 6000);
// }
} else { } else {
completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`); completionMessage = translate('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, `Failed to refresh metadata for any ${this.apiConfig.config.displayName}s`);
showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error'); showToast('toast.api.bulkMetadataCompleteNone', { type: this.apiConfig.config.displayName }, 'error');
@@ -574,28 +611,42 @@ export class BaseModelApiClient {
throw new Error('No model IDs provided'); throw new Error('No model IDs provided');
} }
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_ids: modelIds,
force
})
});
let payload = {};
try { try {
payload = await response.json(); state.loadingManager.show('Checking for updates...', 0);
state.loadingManager.showCancelButton(() => this.cancelTask());
const response = await fetch(this.apiConfig.endpoints.refreshUpdates, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_ids: modelIds,
force
})
});
let payload = {};
try {
payload = await response.json();
} catch (error) {
console.warn('Unable to parse refresh updates response as JSON', error);
}
if (!response.ok || payload?.success !== true) {
if (payload?.status === 'cancelled') {
showToast('toast.api.operationCancelled', {}, 'info');
return null;
}
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} catch (error) { } catch (error) {
console.warn('Unable to parse refresh updates response as JSON', error); console.error('Error refreshing updates for models:', error);
throw error;
} finally {
state.loadingManager.hide();
} }
if (!response.ok || payload?.success !== true) {
const message = payload?.error || response.statusText || 'Failed to refresh updates';
throw new Error(message);
}
return payload;
} }
async fetchCivitaiVersions(modelId, source = null) { async fetchCivitaiVersions(modelId, source = null) {
@@ -770,7 +821,7 @@ export class BaseModelApiClient {
_buildQueryParams(baseParams, pageState) { _buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams); const params = new URLSearchParams(baseParams);
if (pageState.activeFolder !== null) { if (pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder); params.append('folder', pageState.activeFolder);
} }
@@ -790,7 +841,7 @@ export class BaseModelApiClient {
if (pageState.filters?.search) { if (pageState.filters?.search) {
params.append('search', pageState.filters.search); params.append('search', pageState.filters.search);
params.append('fuzzy', 'true'); params.append('fuzzy', 'true');
if (pageState.searchOptions) { if (pageState.searchOptions) {
params.append('search_filename', pageState.searchOptions.filename.toString()); params.append('search_filename', pageState.searchOptions.filename.toString());
params.append('search_modelname', pageState.searchOptions.modelname.toString()); params.append('search_modelname', pageState.searchOptions.modelname.toString());
@@ -804,7 +855,7 @@ export class BaseModelApiClient {
} }
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false'); params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
if (pageState.filters) { if (pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) { if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -815,17 +866,17 @@ export class BaseModelApiClient {
} }
}); });
} }
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
pageState.filters.baseModel.forEach(model => { pageState.filters.baseModel.forEach(model => {
params.append('base_model', model); params.append('base_model', model);
}); });
} }
// Add license filters // Add license filters
if (pageState.filters.license) { if (pageState.filters.license) {
const licenseFilters = pageState.filters.license; const licenseFilters = pageState.filters.license;
if (licenseFilters.noCredit) { if (licenseFilters.noCredit) {
// For noCredit filter: // For noCredit filter:
// - 'include' means credit_required=False (no credit required) // - 'include' means credit_required=False (no credit required)
@@ -836,7 +887,7 @@ export class BaseModelApiClient {
params.append('credit_required', 'true'); params.append('credit_required', 'true');
} }
} }
if (licenseFilters.allowSelling) { if (licenseFilters.allowSelling) {
// For allowSelling filter: // For allowSelling filter:
// - 'include' means allow_selling_generated_content=True // - 'include' means allow_selling_generated_content=True
@@ -848,7 +899,7 @@ export class BaseModelApiClient {
} }
} }
} }
if (pageState.filters.modelTypes && pageState.filters.modelTypes.length > 0) { if (pageState.filters.modelTypes && pageState.filters.modelTypes.length > 0) {
pageState.filters.modelTypes.forEach((type) => { pageState.filters.modelTypes.forEach((type) => {
params.append('model_type', type); params.append('model_type', type);
@@ -895,13 +946,13 @@ export class BaseModelApiClient {
} }
} }
async moveSingleModel(filePath, targetPath) { async moveSingleModel(filePath, targetPath, useDefaultPaths = false) {
// Only allow move if supported // Only allow move if supported
if (!this.apiConfig.config.supportsMove) { if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return null; return null;
} }
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath && !useDefaultPaths) {
showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info'); showToast('toast.api.alreadyInFolder', { type: this.apiConfig.config.displayName }, 'info');
return null; return null;
} }
@@ -913,7 +964,8 @@ export class BaseModelApiClient {
}, },
body: JSON.stringify({ body: JSON.stringify({
file_path: filePath, file_path: filePath,
target_path: targetPath target_path: targetPath,
use_default_paths: useDefaultPaths
}) })
}); });
@@ -935,18 +987,19 @@ export class BaseModelApiClient {
if (result.success) { if (result.success) {
return { return {
original_file_path: result.original_file_path || filePath, original_file_path: result.original_file_path || filePath,
new_file_path: result.new_file_path new_file_path: result.new_file_path,
cache_entry: result.cache_entry
}; };
} }
return null; return null;
} }
async moveBulkModels(filePaths, targetPath) { async moveBulkModels(filePaths, targetPath, useDefaultPaths = false) {
if (!this.apiConfig.config.supportsMove) { if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning'); showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return []; return [];
} }
const movedPaths = filePaths.filter(path => { const movedPaths = useDefaultPaths ? filePaths : filePaths.filter(path => {
return path.substring(0, path.lastIndexOf('/')) !== targetPath; return path.substring(0, path.lastIndexOf('/')) !== targetPath;
}); });
@@ -962,7 +1015,8 @@ export class BaseModelApiClient {
}, },
body: JSON.stringify({ body: JSON.stringify({
file_paths: movedPaths, file_paths: movedPaths,
target_path: targetPath target_path: targetPath,
use_default_paths: useDefaultPaths
}) })
}); });
@@ -974,10 +1028,10 @@ export class BaseModelApiClient {
if (result.success) { if (result.success) {
if (result.failure_count > 0) { if (result.failure_count > 0) {
showToast('toast.api.bulkMovePartial', { showToast('toast.api.bulkMovePartial', {
successCount: result.success_count, successCount: result.success_count,
type: this.apiConfig.config.displayName, type: this.apiConfig.config.displayName,
failureCount: result.failure_count failureCount: result.failure_count
}, 'warning'); }, 'warning');
console.log('Move operation results:', result.results); console.log('Move operation results:', result.results);
const failedFiles = result.results const failedFiles = result.results
@@ -987,18 +1041,18 @@ export class BaseModelApiClient {
return `${fileName}: ${r.message}`; return `${fileName}: ${r.message}`;
}); });
if (failedFiles.length > 0) { if (failedFiles.length > 0) {
const failureMessage = failedFiles.length <= 3 const failureMessage = failedFiles.length <= 3
? failedFiles.join('\n') ? failedFiles.join('\n')
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`; : failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000); showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
} }
} else { } else {
showToast('toast.api.bulkMoveSuccess', { showToast('toast.api.bulkMoveSuccess', {
successCount: result.success_count, successCount: result.success_count,
type: this.apiConfig.config.displayName type: this.apiConfig.config.displayName
}, 'success'); }, 'success');
} }
// Return the results array with original_file_path and new_file_path // Return the results array with original_file_path and new_file_path
return result.results || []; return result.results || [];
} else { } else {
@@ -1013,7 +1067,8 @@ export class BaseModelApiClient {
try { try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`); state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
state.loadingManager.showCancelButton(() => this.cancelTask());
const response = await fetch(this.apiConfig.endpoints.bulkDelete, { const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -1023,13 +1078,13 @@ export class BaseModelApiClient {
file_paths: filePaths file_paths: filePaths
}) })
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`); throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`);
} }
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
return { return {
success: true, success: true,
@@ -1050,20 +1105,21 @@ export class BaseModelApiClient {
async downloadExampleImages(modelHashes, modelTypes = null) { async downloadExampleImages(modelHashes, modelTypes = null) {
let ws = null; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
loading.showCancelButton(() => this.stopExampleImages());
try { try {
// Connect to WebSocket for progress updates // Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => { const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type !== 'example_images_progress') return; if (data.type !== 'example_images_progress') return;
switch(data.status) { switch (data.status) {
case 'running': case 'running':
const percent = ((data.processed / data.total) * 100).toFixed(1); const percent = ((data.processed / data.total) * 100).toFixed(1);
loading.setProgress(percent); loading.setProgress(percent);
@@ -1071,7 +1127,7 @@ export class BaseModelApiClient {
`Processing (${data.processed}/${data.total}) ${data.current_model || ''}` `Processing (${data.processed}/${data.total}) ${data.current_model || ''}`
); );
break; break;
case 'completed': case 'completed':
loading.setProgress(100); loading.setProgress(100);
loading.setStatus( loading.setStatus(
@@ -1079,33 +1135,33 @@ export class BaseModelApiClient {
); );
resolve(); resolve();
break; break;
case 'error': case 'error':
reject(new Error(data.error)); reject(new Error(data.error));
break; break;
} }
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
reject(new Error('WebSocket error: ' + error.message)); reject(new Error('WebSocket error: ' + error.message));
}; };
}); });
// Wait for WebSocket connection to establish // Wait for WebSocket connection to establish
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ws.onopen = resolve; ws.onopen = resolve;
ws.onerror = reject; ws.onerror = reject;
}); });
// Get the output directory from state // Get the output directory from state
const outputDir = state.global?.settings?.example_images_path || ''; const outputDir = state.global?.settings?.example_images_path || '';
if (!outputDir) { if (!outputDir) {
throw new Error('Please set the example images path in the settings first.'); throw new Error('Please set the example images path in the settings first.');
} }
// Determine optimize setting // Determine optimize setting
const optimize = state.global?.settings?.optimize_example_images ?? true; const optimize = state.global?.settings?.optimize_example_images ?? true;
// Make the API request to start the download process // Make the API request to start the download process
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, { const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, {
method: 'POST', method: 'POST',
@@ -1119,18 +1175,18 @@ export class BaseModelApiClient {
model_types: modelTypes || [this.apiConfig.config.singularName] model_types: modelTypes || [this.apiConfig.config.singularName]
}) })
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to download example images'); throw new Error(errorData.error || 'Failed to download example images');
} }
// Wait for the operation to complete via WebSocket // Wait for the operation to complete via WebSocket
await operationComplete; await operationComplete;
showToast('toast.api.exampleImagesDownloadSuccess', {}, 'success'); showToast('toast.api.exampleImagesDownloadSuccess', {}, 'success');
return true; return true;
} catch (error) { } catch (error) {
console.error('Error downloading example images:', error); console.error('Error downloading example images:', error);
showToast('toast.api.exampleImagesDownloadFailed', { message: error.message }, 'error'); showToast('toast.api.exampleImagesDownloadFailed', { message: error.message }, 'error');
@@ -1150,13 +1206,13 @@ export class BaseModelApiClient {
try { try {
const params = new URLSearchParams({ file_path: filePath }); const params = new URLSearchParams({ file_path: filePath });
const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`); const response = await fetch(`${this.apiConfig.endpoints.metadata}?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} metadata: ${response.statusText}`); throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} metadata: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
return data.metadata; return data.metadata;
} else { } else {
@@ -1172,13 +1228,13 @@ export class BaseModelApiClient {
try { try {
const params = new URLSearchParams({ file_path: filePath }); const params = new URLSearchParams({ file_path: filePath });
const response = await fetch(`${this.apiConfig.endpoints.modelDescription}?${params}`); const response = await fetch(`${this.apiConfig.endpoints.modelDescription}?${params}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} description: ${response.statusText}`); throw new Error(`Failed to fetch ${this.apiConfig.config.singularName} description: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
return data.description; return data.description;
} else { } else {
@@ -1197,26 +1253,27 @@ export class BaseModelApiClient {
*/ */
async autoOrganizeModels(filePaths = null) { async autoOrganizeModels(filePaths = null) {
let ws = null; let ws = null;
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
loading.showCancelButton(() => this.cancelTask());
try { try {
// Connect to WebSocket for progress updates // Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => { const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type !== 'auto_organize_progress') return; if (data.type !== 'auto_organize_progress') return;
switch(data.status) { switch (data.status) {
case 'started': case 'started':
loading.setProgress(0); loading.setProgress(0);
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models'; const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.starting', { type: operationType }, `Starting auto-organize for ${operationType}...`)); loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.starting', { type: operationType }, `Starting auto-organize for ${operationType}...`));
break; break;
case 'processing': case 'processing':
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0; const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
loading.setProgress(percent); loading.setProgress(percent);
@@ -1230,12 +1287,12 @@ export class BaseModelApiClient {
}, `Processing (${data.processed}/${data.total}) - ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`) }, `Processing (${data.processed}/${data.total}) - ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
); );
break; break;
case 'cleaning': case 'cleaning':
loading.setProgress(95); loading.setProgress(95);
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.cleaning', {}, 'Cleaning up empty directories...')); loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.cleaning', {}, 'Cleaning up empty directories...'));
break; break;
case 'completed': case 'completed':
loading.setProgress(100); loading.setProgress(100);
loading.setStatus( loading.setStatus(
@@ -1246,25 +1303,30 @@ export class BaseModelApiClient {
total: data.total total: data.total
}, `Completed: ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`) }, `Completed: ${data.success} moved, ${data.skipped} skipped, ${data.failures} failed`)
); );
setTimeout(() => { setTimeout(() => {
resolve(data); resolve(data);
}, 1500); }, 1500);
break; break;
case 'cancelled':
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
resolve(data);
break;
case 'error': case 'error':
loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`)); loading.setStatus(translate('loras.bulkOperations.autoOrganizeProgress.error', { error: data.error }, `Error: ${data.error}`));
reject(new Error(data.error)); reject(new Error(data.error));
break; break;
} }
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error during auto-organize:', error); console.error('WebSocket error during auto-organize:', error);
reject(new Error('Connection error')); reject(new Error('Connection error'));
}; };
}); });
// Start the auto-organize operation // Start the auto-organize operation
const endpoint = this.apiConfig.endpoints.autoOrganize; const endpoint = this.apiConfig.endpoints.autoOrganize;
const exclusionPatterns = (state.global.settings.auto_organize_exclusions || []) const exclusionPatterns = (state.global.settings.auto_organize_exclusions || [])
@@ -1286,29 +1348,31 @@ export class BaseModelApiClient {
}; };
const response = await fetch(endpoint, requestOptions); const response = await fetch(endpoint, requestOptions);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to start auto-organize operation'); throw new Error(errorData.error || 'Failed to start auto-organize operation');
} }
// Wait for the operation to complete via WebSocket // Wait for the operation to complete via WebSocket
const result = await operationComplete; const result = await operationComplete;
// Show appropriate success message based on results // Show appropriate success message based on results
if (result.failures === 0) { if (result.status === 'cancelled') {
showToast('toast.loras.autoOrganizeSuccess', { showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
} else if (result.failures === 0) {
showToast('toast.loras.autoOrganizeSuccess', {
count: result.success, count: result.success,
type: result.operation_type === 'bulk' ? 'selected models' : 'all models' type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
}, 'success'); }, 'success');
} else { } else {
showToast('toast.loras.autoOrganizePartialSuccess', { showToast('toast.loras.autoOrganizePartialSuccess', {
success: result.success, success: result.success,
failures: result.failures, failures: result.failures,
total: result.total total: result.total
}, 'warning'); }, 'warning');
} }
} catch (error) { } catch (error) {
console.error('Error during auto-organize:', error); console.error('Error during auto-organize:', error);
showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error'); showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error');
@@ -1323,4 +1387,17 @@ export class BaseModelApiClient {
completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete') completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete')
}); });
} }
async stopExampleImages() {
try {
const response = await fetch('/api/lm/stop-example-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
return response.ok;
} catch (error) {
console.error('Error stopping example images:', error);
return false;
}
}
} }

View File

@@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
const RECIPE_ENDPOINTS = {
list: '/api/lm/recipes',
detail: '/api/lm/recipe',
scan: '/api/lm/recipes/scan',
update: '/api/lm/recipe',
roots: '/api/lm/recipes/roots',
folders: '/api/lm/recipes/folders',
folderTree: '/api/lm/recipes/folder-tree',
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete',
};
const RECIPE_SIDEBAR_CONFIG = {
config: {
displayName: 'Recipe',
supportsMove: true,
},
endpoints: RECIPE_ENDPOINTS,
};
export function extractRecipeId(filePath) {
if (!filePath) return null;
const basename = filePath.split('/').pop().split('\\').pop();
const dotIndex = basename.lastIndexOf('.');
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
}
/** /**
* Fetch recipes with pagination for virtual scrolling * Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch * @param {number} page - Page number to fetch
@@ -10,25 +39,36 @@ import { showToast } from '../utils/uiHelpers.js';
*/ */
export async function fetchRecipesPage(page = 1, pageSize = 100) { export async function fetchRecipesPage(page = 1, pageSize = 100) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page, page: page,
page_size: pageSize || pageState.pageSize || 20, page_size: pageSize || pageState.pageSize || 20,
sort_by: pageState.sortBy sort_by: pageState.sortBy
}); });
if (pageState.showFavoritesOnly) {
params.append('favorite', 'true');
}
if (pageState.activeFolder !== null && pageState.activeFolder !== undefined) {
params.append('folder', pageState.activeFolder);
params.append('recursive', pageState.searchOptions?.recursive !== false);
} else if (pageState.searchOptions?.recursive !== undefined) {
params.append('recursive', pageState.searchOptions.recursive);
}
// If we have a specific recipe ID to load // If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe // Special case: load specific recipe
const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`); const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`); throw new Error(`Failed to load recipe: ${response.statusText}`);
} }
const recipe = await response.json(); const recipe = await response.json();
// Return in expected format // Return in expected format
return { return {
items: [recipe], items: [recipe],
@@ -38,33 +78,34 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
hasMore: false hasMore: false
}; };
} }
// Add custom filter for Lora if present // Add custom filter for Lora if present
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) { if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
params.append('lora_hash', pageState.customFilter.loraHash); params.append('lora_hash', pageState.customFilter.loraHash);
params.append('bypass_filters', 'true'); params.append('bypass_filters', 'true');
} else { } else {
// Normal filtering logic // Normal filtering logic
// Add search filter if present // Add search filter if present
if (pageState.filters?.search) { if (pageState.filters?.search) {
params.append('search', pageState.filters.search); params.append('search', pageState.filters.search);
// Add search option parameters // Add search option parameters
if (pageState.searchOptions) { if (pageState.searchOptions) {
params.append('search_title', pageState.searchOptions.title.toString()); params.append('search_title', pageState.searchOptions.title.toString());
params.append('search_tags', pageState.searchOptions.tags.toString()); params.append('search_tags', pageState.searchOptions.tags.toString());
params.append('search_lora_name', pageState.searchOptions.loraName.toString()); params.append('search_lora_name', pageState.searchOptions.loraName.toString());
params.append('search_lora_model', pageState.searchOptions.loraModel.toString()); params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
params.append('search_prompt', (pageState.searchOptions.prompt || false).toString());
params.append('fuzzy', 'true'); params.append('fuzzy', 'true');
} }
} }
// Add base model filters // Add base model filters
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) { if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
params.append('base_models', pageState.filters.baseModel.join(',')); params.append('base_models', pageState.filters.baseModel.join(','));
} }
// Add tag filters // Add tag filters
if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) { if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -78,14 +119,14 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
} }
// Fetch recipes // Fetch recipes
const response = await fetch(`/api/lm/recipes?${params.toString()}`); const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`); throw new Error(`Failed to load recipes: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
return { return {
items: data.items, items: data.items,
totalItems: data.total, totalItems: data.total,
@@ -111,29 +152,29 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
updateFolders = false, updateFolders = false,
fetchPageFunction fetchPageFunction
} = options; } = options;
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
pageState.isLoading = true; pageState.isLoading = true;
// Reset page counter // Reset page counter
pageState.currentPage = 1; pageState.currentPage = 1;
// Fetch the first page // Fetch the first page
const result = await fetchPageFunction(1, pageState.pageSize || 50); const result = await fetchPageFunction(1, pageState.pageSize || 50);
// Update the virtual scroller // Update the virtual scroller
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
result.totalItems, result.totalItems,
result.hasMore result.hasMore
); );
// Update state // Update state
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page will be 2 pageState.currentPage = 2; // Next page will be 2
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error reloading ${modelType}s:`, error); console.error(`Error reloading ${modelType}s:`, error);
@@ -156,32 +197,32 @@ export async function loadMoreWithVirtualScroll(options = {}) {
updateFolders = false, updateFolders = false,
fetchPageFunction fetchPageFunction
} = options; } = options;
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
// Start loading state // Start loading state
pageState.isLoading = true; pageState.isLoading = true;
// Reset to first page if requested // Reset to first page if requested
if (resetPage) { if (resetPage) {
pageState.currentPage = 1; pageState.currentPage = 1;
} }
// Fetch the first page of data // Fetch the first page of data
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
// Update virtual scroller with the new data // Update virtual scroller with the new data
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
result.totalItems, result.totalItems,
result.hasMore result.hasMore
); );
// Update state // Update state
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page to load would be 2 pageState.currentPage = 2; // Next page to load would be 2
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error loading ${modelType}s:`, error); console.error(`Error loading ${modelType}s:`, error);
@@ -211,18 +252,18 @@ export async function resetAndReload(updateFolders = false) {
export async function refreshRecipes() { export async function refreshRecipes() {
try { try {
state.loadingManager.showSimpleLoading('Refreshing recipes...'); state.loadingManager.showSimpleLoading('Refreshing recipes...');
// Call the API endpoint to rebuild the recipe cache // Call the API endpoint to rebuild the recipe cache
const response = await fetch('/api/lm/recipes/scan'); const response = await fetch(RECIPE_ENDPOINTS.scan);
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache'); throw new Error(data.error || 'Failed to refresh recipe cache');
} }
// After successful cache rebuild, reload the recipes // After successful cache rebuild, reload the recipes
await resetAndReload(); await resetAndReload();
showToast('toast.recipes.refreshComplete', {}, 'success'); showToast('toast.recipes.refreshComplete', {}, 'success');
} catch (error) { } catch (error) {
console.error('Error refreshing recipes:', error); console.error('Error refreshing recipes:', error);
@@ -240,7 +281,7 @@ export async function refreshRecipes() {
*/ */
export async function loadMoreRecipes(resetPage = false) { export async function loadMoreRecipes(resetPage = false) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
// Use virtual scroller if available // Use virtual scroller if available
if (state.virtualScroller) { if (state.virtualScroller) {
return loadMoreWithVirtualScroll({ return loadMoreWithVirtualScroll({
@@ -277,10 +318,12 @@ export async function updateRecipeMetadata(filePath, updates) {
state.loadingManager.showSimpleLoading('Saving metadata...'); state.loadingManager.showSimpleLoading('Saving metadata...');
// Extract recipeId from filePath (basename without extension) // Extract recipeId from filePath (basename without extension)
const basename = filePath.split('/').pop().split('\\').pop(); const recipeId = extractRecipeId(filePath);
const recipeId = basename.substring(0, basename.lastIndexOf('.')); if (!recipeId) {
throw new Error('Unable to determine recipe ID');
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, { }
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -296,7 +339,7 @@ export async function updateRecipeMetadata(filePath, updates) {
} }
state.virtualScroller.updateSingleItem(filePath, updates); state.virtualScroller.updateSingleItem(filePath, updates);
return data; return data;
} catch (error) { } catch (error) {
console.error('Error updating recipe:', error); console.error('Error updating recipe:', error);
@@ -306,3 +349,187 @@ export async function updateRecipeMetadata(filePath, updates) {
state.loadingManager.hide(); state.loadingManager.hide();
} }
} }
export class RecipeSidebarApiClient {
constructor() {
this.apiConfig = RECIPE_SIDEBAR_CONFIG;
}
async fetchUnifiedFolderTree() {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
if (!response.ok) {
throw new Error('Failed to fetch recipe folder tree');
}
return response.json();
}
async fetchModelRoots() {
const response = await fetch(this.apiConfig.endpoints.roots);
if (!response.ok) {
throw new Error('Failed to fetch recipe roots');
}
return response.json();
}
async fetchModelFolders() {
const response = await fetch(this.apiConfig.endpoints.folders);
if (!response.ok) {
throw new Error('Failed to fetch recipe folders');
}
return response.json();
}
async moveBulkModels(filePaths, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return [];
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return [];
}
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
target_path: targetPath,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}s`);
}
if (result.failure_count > 0) {
showToast(
'toast.api.bulkMovePartial',
{
successCount: result.success_count,
type: this.apiConfig.config.displayName,
failureCount: result.failure_count,
},
'warning'
);
const failedFiles = (result.results || [])
.filter((item) => !item.success)
.map((item) => item.message || 'Unknown error');
if (failedFiles.length > 0) {
const failureMessage =
failedFiles.length <= 3
? failedFiles.join('\n')
: `${failedFiles.slice(0, 3).join('\n')}\n(and ${failedFiles.length - 3} more)`;
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
}
} else {
showToast(
'toast.api.bulkMoveSuccess',
{
successCount: result.success_count,
type: this.apiConfig.config.displayName,
},
'success'
);
}
return result.results || [];
}
async moveSingleModel(filePath, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return null;
}
const recipeId = extractRecipeId(filePath);
if (!recipeId) {
showToast('toast.api.moveFailed', { message: 'Recipe ID missing' }, 'error');
return null;
}
const response = await fetch(this.apiConfig.endpoints.move, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: recipeId,
target_path: targetPath,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}`);
}
if (result.message) {
showToast('toast.api.moveInfo', { message: result.message }, 'info');
} else {
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
return {
original_file_path: result.original_file_path || filePath,
new_file_path: result.new_file_path || filePath,
folder: result.folder || '',
message: result.message,
};
}
async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
throw new Error('No recipe IDs could be derived from file paths');
}
try {
state.loadingManager?.showSimpleLoading('Deleting recipes...');
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to delete recipes');
}
return {
success: true,
deleted_count: result.total_deleted,
failed_count: result.total_failed || 0,
errors: result.failed || [],
};
} finally {
state.loadingManager?.hide();
}
}
}

View File

@@ -3,6 +3,7 @@ import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js'; import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { moveManager } from '../../managers/MoveManager.js'; import { moveManager } from '../../managers/MoveManager.js';
import { i18n } from '../../i18n/index.js';
export class CheckpointContextMenu extends BaseContextMenu { export class CheckpointContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -10,19 +11,28 @@ export class CheckpointContextMenu extends BaseContextMenu {
this.nsfwSelector = document.getElementById('nsfwLevelSelector'); this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'checkpoint'; this.modelType = 'checkpoint';
this.resetAndReload = resetAndReload; this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events only if not already initialized this.initNSFWSelector();
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
} }
// Implementation needed by the mixin // Implementation needed by the mixin
async saveModelMetadata(filePath, data) { async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data); return getModelApiClient().saveModelMetadata(filePath, data);
} }
showMenu(x, y, card) {
super.showMenu(x, y, card);
// Update the "Move to other root" label based on current model type
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
if (moveOtherItem) {
const currentType = card.dataset.model_type || 'checkpoint';
const otherType = currentType === 'checkpoint' ? 'diffusion_model' : 'checkpoint';
const typeLabel = i18n.t(`checkpoints.modelTypes.${otherType}`);
moveOtherItem.innerHTML = `<i class="fas fa-exchange-alt"></i> ${i18n.t('checkpoints.contextMenu.moveToOtherTypeFolder', { otherType: typeLabel })}`;
}
}
handleMenuAction(action) { handleMenuAction(action) {
// First try to handle with common actions // First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -32,7 +42,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();
// Otherwise handle checkpoint-specific actions // Otherwise handle checkpoint-specific actions
switch(action) { switch (action) {
case 'details': case 'details':
// Show checkpoint details // Show checkpoint details
this.currentCard.click(); this.currentCard.click();
@@ -57,6 +67,13 @@ export class CheckpointContextMenu extends BaseContextMenu {
case 'move': case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.model_type); moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.model_type);
break; break;
case 'move-other':
{
const currentType = this.currentCard.dataset.model_type || 'checkpoint';
const otherType = currentType === 'checkpoint' ? 'diffusion_model' : 'checkpoint';
moveManager.showMoveModal(this.currentCard.dataset.filepath, otherType);
}
break;
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);
break; break;
@@ -65,4 +82,4 @@ export class CheckpointContextMenu extends BaseContextMenu {
} }
// Mix in shared methods // Mix in shared methods
Object.assign(CheckpointContextMenu.prototype, ModelContextMenuMixin); Object.assign(CheckpointContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -11,11 +11,7 @@ export class EmbeddingContextMenu extends BaseContextMenu {
this.modelType = 'embedding'; this.modelType = 'embedding';
this.resetAndReload = resetAndReload; this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events only if not already initialized this.initNSFWSelector();
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
} }
// Implementation needed by the mixin // Implementation needed by the mixin

View File

@@ -15,6 +15,29 @@ export class GlobalContextMenu extends BaseContextMenu {
showMenu(x, y, origin = null) { showMenu(x, y, origin = null) {
const contextOrigin = origin || { type: 'global' }; const contextOrigin = origin || { type: 'global' };
// Conditional visibility for recipes page
const isRecipesPage = state.currentPageType === 'recipes';
const modelUpdateItem = this.menu.querySelector('[data-action="check-model-updates"]');
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
if (isRecipesPage) {
modelUpdateItem?.classList.add('hidden');
licenseRefreshItem?.classList.add('hidden');
downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
licenseRefreshItem?.classList.remove('hidden');
downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden');
}
super.showMenu(x, y, contextOrigin); super.showMenu(x, y, contextOrigin);
} }
@@ -40,6 +63,11 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to refresh missing license metadata:', error); console.error('Failed to refresh missing license metadata:', error);
}); });
break; break;
case 'repair-recipes':
this.repairRecipes(menuItem).catch((error) => {
console.error('Failed to repair recipes:', error);
});
break;
default: default:
console.warn(`Unhandled global context menu action: ${action}`); console.warn(`Unhandled global context menu action: ${action}`);
break; break;
@@ -47,13 +75,6 @@ export class GlobalContextMenu extends BaseContextMenu {
} }
async downloadExampleImages(menuItem) { async downloadExampleImages(menuItem) {
const exampleImagesManager = window.exampleImagesManager;
if (!exampleImagesManager) {
showToast('globalContextMenu.downloadExampleImages.unavailable', {}, 'error');
return;
}
const downloadPath = state?.global?.settings?.example_images_path; const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) { if (!downloadPath) {
showToast('globalContextMenu.downloadExampleImages.missingPath', {}, 'warning'); showToast('globalContextMenu.downloadExampleImages.missingPath', {}, 'warning');
@@ -63,7 +84,48 @@ export class GlobalContextMenu extends BaseContextMenu {
menuItem?.classList.add('disabled'); menuItem?.classList.add('disabled');
try { try {
await exampleImagesManager.handleDownloadButton(); const optimize = state.global.settings.optimize_example_images;
const response = await fetch('/api/lm/download-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
force: true,
optimize,
model_types: ['lora', 'checkpoint', 'embedding']
})
});
const data = await response.json();
if (data.success) {
showToast('toast.exampleImages.downloadStarted', {}, 'success');
const exampleImagesManager = window.exampleImagesManager;
if (exampleImagesManager) {
exampleImagesManager.isDownloading = true;
exampleImagesManager.isPaused = false;
exampleImagesManager.isStopping = false;
exampleImagesManager.hasShownCompletionToast = false;
exampleImagesManager.startTime = new Date();
exampleImagesManager.updateUI(data.status);
exampleImagesManager.showProgressPanel();
exampleImagesManager.startProgressUpdates();
exampleImagesManager.updateDownloadButtonText();
const stopButton = document.getElementById('stopExampleDownloadBtn');
if (stopButton) {
stopButton.disabled = false;
}
}
} else {
showToast('toast.exampleImages.downloadStartFailed', { error: data.error }, 'error');
}
} catch (error) {
console.error('Failed to trigger example images download:', error);
showToast('toast.exampleImages.downloadStartFailed', {}, 'error');
} finally { } finally {
menuItem?.classList.remove('disabled'); menuItem?.classList.remove('disabled');
} }
@@ -235,4 +297,97 @@ export class GlobalContextMenu extends BaseContextMenu {
return `${displayName}s`; return `${displayName}s`;
} }
async repairRecipes(menuItem) {
if (this._repairInProgress) {
return;
}
this._repairInProgress = true;
menuItem?.classList.add('disabled');
const loadingMessage = translate(
'globalContextMenu.repairRecipes.loading',
{},
'Repairing recipe data...'
);
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
progressUI?.showCancelButton(() => this.cancelRepair());
try {
const response = await fetch('/api/lm/recipes/repair', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to start repair');
}
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
let isComplete = false;
while (!isComplete && this._repairInProgress) {
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
if (progressResponse.ok) {
const progressResult = await progressResponse.json();
if (progressResult.success && progressResult.progress) {
const p = progressResult.progress;
if (p.status === 'processing') {
const percent = (p.current / p.total) * 100;
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
} else if (p.status === 'completed') {
isComplete = true;
progressUI?.complete(translate(
'globalContextMenu.repairRecipes.success',
{ count: p.repaired },
`Repaired ${p.repaired} recipes.`
));
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
// Refresh recipes page if active
if (window.recipesPage) {
window.recipesPage.refresh();
}
} else if (p.status === 'error') {
throw new Error(p.error || 'Repair failed');
} else if (p.status === 'cancelled') {
isComplete = true;
progressUI?.complete(translate(
'globalContextMenu.repairRecipes.cancelled',
{ count: p.repaired },
`Repair cancelled. ${p.repaired} recipes were repaired.`
));
showToast('globalContextMenu.repairRecipes.cancelled', { count: p.repaired }, 'info');
}
} else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up
isComplete = true;
progressUI?.complete();
}
}
if (!isComplete) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
} catch (error) {
console.error('Recipe repair failed:', error);
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
} finally {
this._repairInProgress = false;
menuItem?.classList.remove('disabled');
}
}
async cancelRepair() {
try {
await fetch('/api/lm/recipes/cancel-repair', {
method: 'POST',
});
} catch (error) {
console.error('Failed to cancel recipe repair:', error);
}
}
} }

View File

@@ -12,11 +12,7 @@ export class LoraContextMenu extends BaseContextMenu {
this.modelType = 'lora'; this.modelType = 'lora';
this.resetAndReload = resetAndReload; this.resetAndReload = resetAndReload;
// Initialize NSFW Level Selector events only if not already initialized this.initNSFWSelector();
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
} }
// Use the saveModelMetadata implementation from loraApi // Use the saveModelMetadata implementation from loraApi
@@ -78,4 +74,4 @@ export class LoraContextMenu extends BaseContextMenu {
} }
// Mix in shared methods // Mix in shared methods
Object.assign(LoraContextMenu.prototype, ModelContextMenuMixin); Object.assign(LoraContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -5,96 +5,38 @@ import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js'
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js'; import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu // Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = { export const ModelContextMenuMixin = {
// NSFW Selector methods // NSFW Selector methods
initNSFWSelector() { initNSFWSelector() {
// Remove any existing event listeners by cloning and replacing elements if (this._nsfwSelectorInitialized) {
// This is a simple way to ensure we don't have duplicate event listeners return;
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
const newCloseBtn = closeBtn.cloneNode(true);
closeBtn.parentNode.replaceChild(newCloseBtn, closeBtn);
newCloseBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
});
// Level buttons
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
levelButtons.forEach(btn => {
// Remove any existing event listeners by cloning and replacing the button
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.addEventListener('click', async () => {
const level = parseInt(newBtn.dataset.level);
const mode = this.nsfwSelector.dataset.mode || 'single';
if (mode === 'bulk') {
let bulkFilePaths = [];
if (this.nsfwSelector.dataset.bulkFilePaths) {
try {
bulkFilePaths = JSON.parse(this.nsfwSelector.dataset.bulkFilePaths);
} catch (error) {
console.warn('Failed to parse bulk file paths for content rating', error);
}
}
const success = await bulkManager.setBulkContentRating(level, bulkFilePaths);
if (success) {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
}
return;
}
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
} catch (error) {
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
}
});
});
// Close when clicking outside - use a named function so we can remove it later
const outsideClickListener = (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"], .context-menu-item[data-action="set-content-rating"]')) {
this.nsfwSelector.style.display = 'none';
this.resetNSFWSelectorState();
}
};
// Remove previous listener if it exists
if (this._outsideClickListener) {
document.removeEventListener('click', this._outsideClickListener);
} }
// Store and add new listener const selector = getNsfwLevelSelector();
this._outsideClickListener = outsideClickListener; if (!selector) {
document.addEventListener('click', this._outsideClickListener); console.warn('NSFW selector element not found');
return;
}
this._nsfwSelectorInitialized = true;
this._nsfwSelector = selector;
}, },
resetNSFWSelectorState() { resetNSFWSelectorState() {
if (!this.nsfwSelector) return; // maintained for compatibility; no-op with shared selector
delete this.nsfwSelector.dataset.bulkFilePaths;
delete this.nsfwSelector.dataset.mode;
delete this.nsfwSelector.dataset.cardPath;
}, },
showNSFWLevelSelector(x, y, card) { showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector'); this.initNSFWSelector();
const currentLevelEl = document.getElementById('currentNSFWLevel'); const selector = this._nsfwSelector || getNsfwLevelSelector();
if (!selector) {
console.warn('NSFW selector not available');
return;
}
// Get current NSFW level // Get current NSFW level
let currentLevel = 0; let currentLevel = 0;
@@ -104,44 +46,29 @@ export const ModelContextMenuMixin = {
// Update if we have no recorded level but have a dataset attribute // Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) { if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0; currentLevel = parseInt(card.dataset.nsfwLevel, 10) || 0;
} }
} catch (err) { } catch (err) {
console.error('Error parsing metadata:', err); console.error('Error parsing metadata:', err);
} }
currentLevelEl.textContent = getNSFWLevelName(currentLevel); const filePath = card.dataset.filepath;
selector.show({
// Position the selector currentLevel,
if (x && y) { cardPath: filePath,
const viewportWidth = document.documentElement.clientWidth; onSelect: async (level) => {
const viewportHeight = document.documentElement.clientHeight; if (!filePath) return false;
const selectorRect = selector.getBoundingClientRect(); try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
// Center the selector if no coordinates provided showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
let finalX = (viewportWidth - selectorRect.width) / 2; return true;
let finalY = (viewportHeight - selectorRect.height) / 2; } catch (error) {
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
selector.style.left = `${finalX}px`; return false;
selector.style.top = `${finalY}px`; }
} },
onClose: () => this.resetNSFWSelectorState(),
// Highlight current level button
selector.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}); });
// Store reference to current card
selector.dataset.mode = 'single';
selector.dataset.cardPath = card.dataset.filepath;
delete selector.dataset.bulkFilePaths;
// Show selector
selector.style.display = 'block';
}, },
// Civitai re-linking methods // Civitai re-linking methods

View File

@@ -4,18 +4,15 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
export class RecipeContextMenu extends BaseContextMenu { export class RecipeContextMenu extends BaseContextMenu {
constructor() { constructor() {
super('recipeContextMenu', '.model-card'); super('recipeContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector'); this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'recipe'; this.modelType = 'recipe';
// Initialize NSFW Level Selector events only if not already initialized this.initNSFWSelector();
if (this.nsfwSelector && !this.nsfwSelector.dataset.initialized) {
this.initNSFWSelector();
this.nsfwSelector.dataset.initialized = 'true';
}
} }
// Use the updateRecipeMetadata implementation from recipeApi // Use the updateRecipeMetadata implementation from recipeApi
@@ -28,20 +25,20 @@ export class RecipeContextMenu extends BaseContextMenu {
const { resetAndReload } = await import('../../api/recipeApi.js'); const { resetAndReload } = await import('../../api/recipeApi.js');
return resetAndReload(); return resetAndReload();
} }
showMenu(x, y, card) { showMenu(x, y, card) {
// Call the parent method first to handle basic positioning // Call the parent method first to handle basic positioning
super.showMenu(x, y, card); super.showMenu(x, y, card);
// Get recipe data to check for missing LoRAs // Get recipe data to check for missing LoRAs
const recipeId = card.dataset.id; const recipeId = card.dataset.id;
const missingLorasItem = this.menu.querySelector('.download-missing-item'); const missingLorasItem = this.menu.querySelector('.download-missing-item');
if (recipeId && missingLorasItem) { if (recipeId && missingLorasItem) {
// Check if this card has missing LoRAs // Check if this card has missing LoRAs
const loraCountElement = card.querySelector('.lora-count'); const loraCountElement = card.querySelector('.lora-count');
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing'); const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
// Show/hide the download missing LoRAs option based on missing status // Show/hide the download missing LoRAs option based on missing status
if (hasMissingLoras) { if (hasMissingLoras) {
missingLorasItem.style.display = 'flex'; missingLorasItem.style.display = 'flex';
@@ -50,7 +47,7 @@ export class RecipeContextMenu extends BaseContextMenu {
} }
} }
} }
handleMenuAction(action) { handleMenuAction(action) {
// First try to handle with common actions from ModelContextMenuMixin // First try to handle with common actions from ModelContextMenuMixin
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -59,8 +56,8 @@ export class RecipeContextMenu extends BaseContextMenu {
// Handle recipe-specific actions // Handle recipe-specific actions
const recipeId = this.currentCard.dataset.id; const recipeId = this.currentCard.dataset.id;
switch(action) { switch (action) {
case 'details': case 'details':
// Show recipe details // Show recipe details
this.currentCard.click(); this.currentCard.click();
@@ -81,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
// Share recipe // Share recipe
this.currentCard.querySelector('.fa-share-alt')?.click(); this.currentCard.querySelector('.fa-share-alt')?.click();
break; break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'delete': case 'delete':
// Delete recipe // Delete recipe
this.currentCard.querySelector('.fa-trash')?.click(); this.currentCard.querySelector('.fa-trash')?.click();
@@ -93,9 +93,13 @@ export class RecipeContextMenu extends BaseContextMenu {
// Download missing LoRAs // Download missing LoRAs
this.downloadMissingLoRAs(recipeId); this.downloadMissingLoRAs(recipeId);
break; break;
case 'repair':
// Repair recipe metadata
this.repairRecipe(recipeId);
break;
} }
} }
// New method to copy recipe syntax to clipboard // New method to copy recipe syntax to clipboard
copyRecipeSyntax() { copyRecipeSyntax() {
const recipeId = this.currentCard.dataset.id; const recipeId = this.currentCard.dataset.id;
@@ -118,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error'); showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
}); });
} }
// New method to send recipe to workflow // New method to send recipe to workflow
sendRecipeToWorkflow(replaceMode) { sendRecipeToWorkflow(replaceMode) {
const recipeId = this.currentCard.dataset.id; const recipeId = this.currentCard.dataset.id;
@@ -141,14 +145,14 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error'); showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
}); });
} }
// View all LoRAs in the recipe // View all LoRAs in the recipe
viewRecipeLoRAs(recipeId) { viewRecipeLoRAs(recipeId) {
if (!recipeId) { if (!recipeId) {
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error'); showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
return; return;
} }
// First get the recipe details to access its LoRAs // First get the recipe details to access its LoRAs
fetch(`/api/lm/recipe/${recipeId}`) fetch(`/api/lm/recipe/${recipeId}`)
.then(response => response.json()) .then(response => response.json())
@@ -158,17 +162,17 @@ export class RecipeContextMenu extends BaseContextMenu {
removeSessionItem('recipe_to_lora_filterLoraHashes'); removeSessionItem('recipe_to_lora_filterLoraHashes');
removeSessionItem('filterRecipeName'); removeSessionItem('filterRecipeName');
removeSessionItem('viewLoraDetail'); removeSessionItem('viewLoraDetail');
// Collect all hashes from the recipe's LoRAs // Collect all hashes from the recipe's LoRAs
const loraHashes = recipe.loras const loraHashes = recipe.loras
.filter(lora => lora.hash) .filter(lora => lora.hash)
.map(lora => lora.hash.toLowerCase()); .map(lora => lora.hash.toLowerCase());
if (loraHashes.length > 0) { if (loraHashes.length > 0) {
// Store the LoRA hashes and recipe name in session storage // Store the LoRA hashes and recipe name in session storage
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes)); setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
setSessionItem('filterRecipeName', recipe.title); setSessionItem('filterRecipeName', recipe.title);
// Navigate to the LoRAs page // Navigate to the LoRAs page
window.location.href = '/loras'; window.location.href = '/loras';
} else { } else {
@@ -180,34 +184,34 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error'); showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
}); });
} }
// Download missing LoRAs // Download missing LoRAs
async downloadMissingLoRAs(recipeId) { async downloadMissingLoRAs(recipeId) {
if (!recipeId) { if (!recipeId) {
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error'); showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
return; return;
} }
try { try {
// First get the recipe details // First get the recipe details
const response = await fetch(`/api/lm/recipe/${recipeId}`); const response = await fetch(`/api/lm/recipe/${recipeId}`);
const recipe = await response.json(); const recipe = await response.json();
// Get missing LoRAs // Get missing LoRAs
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted); const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
if (missingLoras.length === 0) { if (missingLoras.length === 0) {
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info'); showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
return; return;
} }
// Show loading toast // Show loading toast
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...'); state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
// Get version info for each missing LoRA // Get version info for each missing LoRA
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => { const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
let endpoint; let endpoint;
// Determine which endpoint to use based on available data // Determine which endpoint to use based on available data
if (lora.modelVersionId) { if (lora.modelVersionId) {
endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`; endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
@@ -217,52 +221,52 @@ export class RecipeContextMenu extends BaseContextMenu {
console.error("Missing both hash and modelVersionId for lora:", lora); console.error("Missing both hash and modelVersionId for lora:", lora);
return null; return null;
} }
const versionResponse = await fetch(endpoint); const versionResponse = await fetch(endpoint);
const versionInfo = await versionResponse.json(); const versionInfo = await versionResponse.json();
// Return original lora data combined with version info // Return original lora data combined with version info
return { return {
...lora, ...lora,
civitaiInfo: versionInfo civitaiInfo: versionInfo
}; };
}); });
// Wait for all API calls to complete // Wait for all API calls to complete
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises); const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
// Filter out null values (failed requests) // Filter out null values (failed requests)
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null); const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
if (validLoras.length === 0) { if (validLoras.length === 0) {
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error'); showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
return; return;
} }
// Prepare data for import manager using the retrieved information // Prepare data for import manager using the retrieved information
const recipeData = { const recipeData = {
loras: validLoras.map(lora => { loras: validLoras.map(lora => {
const civitaiInfo = lora.civitaiInfo; const civitaiInfo = lora.civitaiInfo;
const modelFile = civitaiInfo.files ? const modelFile = civitaiInfo.files ?
civitaiInfo.files.find(file => file.type === 'Model') : null; civitaiInfo.files.find(file => file.type === 'Model') : null;
return { return {
// Basic lora info // Basic lora info
name: civitaiInfo.model?.name || lora.name, name: civitaiInfo.model?.name || lora.name,
version: civitaiInfo.name || '', version: civitaiInfo.name || '',
strength: lora.strength || 1.0, strength: lora.strength || 1.0,
// Model identifiers // Model identifiers
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash, hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
modelVersionId: civitaiInfo.id || lora.modelVersionId, modelVersionId: civitaiInfo.id || lora.modelVersionId,
// Metadata // Metadata
thumbnailUrl: civitaiInfo.images?.[0]?.url || '', thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
baseModel: civitaiInfo.baseModel || '', baseModel: civitaiInfo.baseModel || '',
downloadUrl: civitaiInfo.downloadUrl || '', downloadUrl: civitaiInfo.downloadUrl || '',
size: modelFile ? (modelFile.sizeKB * 1024) : 0, size: modelFile ? (modelFile.sizeKB * 1024) : 0,
file_name: modelFile ? modelFile.name.split('.')[0] : '', file_name: modelFile ? modelFile.name.split('.')[0] : '',
// Status flags // Status flags
existsLocally: false, existsLocally: false,
isDeleted: civitaiInfo.error === "Model not found", isDeleted: civitaiInfo.error === "Model not found",
@@ -271,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
}; };
}) })
}; };
// Call ImportManager's download missing LoRAs method // Call ImportManager's download missing LoRAs method
window.importManager.downloadMissingLoras(recipeData, recipeId); window.importManager.downloadMissingLoras(recipeData, recipeId);
} catch (error) { } catch (error) {
@@ -283,7 +287,39 @@ export class RecipeContextMenu extends BaseContextMenu {
} }
} }
} }
// Repair recipe metadata
async repairRecipe(recipeId) {
if (!recipeId) {
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
return;
}
try {
showToast('recipes.contextMenu.repair.starting', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
if (result.repaired > 0) {
showToast('recipes.contextMenu.repair.success', {}, 'success');
// Refresh the current card or reload
this.resetAndReload();
} else {
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
}
} else {
throw new Error(result.error || 'Repair failed');
}
} catch (error) {
console.error('Error repairing recipe:', error);
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
}
}
} }
// Mix in shared methods from ModelContextMenuMixin // Mix in shared methods from ModelContextMenuMixin
Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin); Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin);

Some files were not shown because too many files have changed in this diff Show More