Compare commits

...

164 Commits

Author SHA1 Message Date
Will Miao
9150718edb feat: bump version to 0.9.13
Update the project version in pyproject.toml from 0.9.12 to 0.9.13 to reflect the latest changes and prepare for a new release.
2026-01-21 21:20:34 +08:00
Will Miao
50abd85fae fix(previews): temporarily bypass path validation to restore preview functionality
Temporary workaround for issues #772 and #774 where valid previews
are rejected. Path validation is disabled until proper fix for
preview root path handling is implemented.
2026-01-21 11:33:42 +08:00
Will Miao
7b4607bed7 feat(standalone): add --verbose flag for DEBUG logging
Add --verbose command line argument that enables DEBUG level logging, equivalent to --log-level DEBUG
2026-01-21 09:35:28 +08:00
Will Miao
6f74186498 feat(config): add debug logging for preview root operations, see #772 and #774
- Log preview root rebuilding with counts of different root types
- Add detailed debug output when preview paths are rejected
- Improve visibility into path mapping and validation processes
2026-01-21 09:22:42 +08:00
Will Miao
eb8b95176b fix(config): return normalized path in link mapping methods
Previously, `map_path_to_link` and `map_link_to_path` returned the original input path when no mapping was found, instead of the normalized version. This could cause inconsistencies when paths with different representations (e.g., trailing slashes) were used. Now both methods consistently return the normalized path, ensuring uniform path handling throughout the application.
2026-01-21 09:09:02 +08:00
Will Miao
091d8aba39 feat(tests): add case-insensitive path validation tests for Windows
Add two new test cases to verify preview path validation behavior on Windows:

1. `test_is_preview_path_allowed_case_insensitive_on_windows`: Ensures path validation is case-insensitive on Windows, addressing issues where drive letters and paths with different cases should match. This resolves GitHub issues #772 and #774.

2. `test_is_preview_path_allowed_rejects_prefix_without_separator`: Prevents false positives by ensuring paths are only allowed when they match the root path exactly followed by a separator, not just sharing a common prefix.
2026-01-21 08:49:41 +08:00
Will Miao
379e3ce2f6 feat(config): normalize paths for case-insensitive comparison on Windows, see #774 and #772
Use os.path.normcase to ensure case-insensitive path matching on Windows, addressing issues where drive letter case mismatches (e.g., 'a:/folder' vs 'A:/folder') prevented correct detection of paths under preview roots. Replace Path.relative_to() with string-based comparison for consistent behavior across platforms.
2026-01-21 08:32:22 +08:00
Will Miao
1b7b598f7a feat(sliders): adjust value label positioning and line height
- Move slider handle value labels 6px upward in both DualRangeSlider and SingleSlider components
- Add consistent line-height of 14px to ensure proper text alignment
- Improves visual spacing and readability of value labels during slider interaction
2026-01-21 01:05:15 +08:00
Will Miao
fd06086a05 feat(lora_randomizer): implement dual seed mechanism for batch queue synchronization, fixes #773
- Add execution_seed and next_seed parameters to support deterministic randomization across batch executions
- Separate UI display generation from execution stack generation to maintain consistency in batch queues
- Update LoraService to accept optional seed parameter for reproducible randomization
- Ensure each execution with a different seed produces unique results without affecting global random state
2026-01-21 00:52:08 +08:00
Will Miao
50c012ae33 fix(ui): unify Lora Randomizer widget styles with Loras widget
Align visual design of Lora Randomizer widget with Loras widget for
consistent UI/UX across the node interface.

Changes:
- Unified border-radius system (4px→6px for containers, 6px for inputs)
- Standardized padding (12px→6px for widget container)
- Reduced slider height (32px→24px) following desktop tool best practices
- Aligned font sizes (12px→13px for labels, 11px→12px for buttons)
- Unified spacing system (16px→6px for sections, 8px→6px for gaps)
- Adjusted widget minimum height (510px→448px) to reflect layout changes
2026-01-20 20:38:24 +08:00
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
Will Miao
f09224152a feat: bump version to 0.9.11 2025-11-29 17:46:06 +08:00
Will Miao
df93670598 feat: add checkpoint metadata to EXIF recipe data
Add support for storing checkpoint information in image EXIF metadata. The checkpoint data is simplified and includes fields like model ID, version, name, hash, and base model. This allows for better tracking of AI model checkpoints used in image generation workflows.
2025-11-29 08:46:38 +08:00
Will Miao
073fb3a94a feat(recipe-parser): enhance LoRA metadata with local file matching
Add comprehensive local file matching for LoRA entries in recipe metadata:
- Add modelVersionId-based lookup via new _get_lora_from_version_index method
- Extend LoRA entry with additional fields: existsLocally, inLibrary, localPath, thumbnailUrl, size
- Improve local file detection by checking both SHA256 hash and modelVersionId
- Set default thumbnail URL and size values for missing LoRA files
- Add proper typing with Optional imports for better code clarity

This provides more accurate local file status and metadata for LoRA entries in recipes.
2025-11-29 08:29:05 +08:00
Will Miao
53c4165d82 feat(parser): enhance model metadata extraction in Automatic1111 parser
- Add MODEL_NAME_PATTERN regex to extract model names from parameters
- Extract model hash from parsed hashes when available in metadata
- Add checkpoint model hash and name extraction from parameters section
- Implement checkpoint resource processing from Civitai metadata
- Improve model information completeness for better recipe tracking
2025-11-29 08:13:55 +08:00
Will Miao
8cd4550189 feat: add Flux.2 D and ZImageTurbo model constants
Add new model constants for Flux.2 D and ZImageTurbo to the BASE_MODELS object,
along with their corresponding abbreviations in BASE_MODEL_ABBREVIATIONS. Also
include these new models in the appropriate categories within BASE_MODEL_CATEGORIES.

This update ensures the application can properly recognize and handle these
newly supported AI models in the system.
2025-11-28 11:42:46 +08:00
Will Miao
2b2e4fefab feat(tests): restructure test HTML to nest elements under model modal
Refactor the test HTML structure to properly nest all model metadata elements within the model modal container. This improves test accuracy by matching the actual DOM structure used in the application, ensuring that element selection and event handling work correctly during testing.
2025-11-27 20:44:05 +08:00
Will Miao
5f93648297 feat: scope DOM queries to modal element in ModelMetadata
Refactor updateModalFilePathReferences function to scope all DOM queries within the modal element. This prevents potential conflicts with other elements on the page that might have the same CSS selectors. Added helper functions scopedQuery and scopedQueryAll to limit element selection to the modal context, improving reliability and preventing unintended side effects.
2025-11-27 20:33:04 +08:00
pixelpaws
8a628f0bd0 Merge pull request #703 from willmiao/fix/showcase-listener-leaks
fix(showcase): tear down modal listeners
2025-11-27 20:09:45 +08:00
Will Miao
b67c8598d6 feat(metadata): clear stale cache entries when metadata is empty
Update metadata registry to remove cache entries when node metadata becomes empty instead of keeping stale data. This prevents accumulation of unused cache entries and ensures cache only contains valid metadata. Added test case to verify cache behavior when LoRA configurations are removed.
2025-11-27 20:04:38 +08:00
Will Miao
0254c9d0e9 fix(showcase): tear down modal listeners 2025-11-27 18:00:59 +08:00
Will Miao
ecb512995c feat(civitai): expand image metadata detection criteria, see #700
Add additional CivitAI image metadata fields to detection logic including generation parameters (prompt, steps, sampler, etc.) and model information. Also improve LoRA hash detection by checking both main metadata and nested meta objects. This ensures more comprehensive identification of CivitAI image metadata across different response formats.
2025-11-27 10:28:04 +08:00
Will Miao
f8b9fa9b20 fix(civitai): improve metadata parsing for nested structures, see #700
- Refactor metadata detection to handle nested "meta" objects
- Add support for lowercase "lora:" hash keys
- Extract metadata from nested "meta" field when present
- Update tests to verify nested metadata parsing
- Handle case-insensitive LORA hash detection

The changes ensure proper parsing of Civitai image metadata that may be wrapped in nested structures, improving compatibility with different API response formats.
2025-11-26 13:46:08 +08:00
256 changed files with 40990 additions and 5459 deletions

View File

@@ -47,6 +47,30 @@ jobs:
python -m pip install --upgrade pip
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
env:
COVERAGE_FILE: coverage/backend/.coverage

9
.gitignore vendored
View File

@@ -1,4 +1,5 @@
__pycache__/
.pytest_cache/
settings.json
path_mappings.yaml
output/*
@@ -10,3 +11,11 @@ node_modules/
coverage/
.coverage
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
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.
This file provides guidance for agentic coding assistants working in this repository.
## Build, Test, and 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.
## Development Commands
## Coding Style & Naming Conventions
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.
### Backend Development
## Testing Guidelines
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.
```bash
# Install dependencies
pip install -r requirements.txt
pip install -r requirements-dev.txt
## Commit & Pull Request Guidelines
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.
# Run standalone server (port 8188 by default)
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
### 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
* **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.

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.prompt import PromptLoraManager
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.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.lora_pool import LoraPoolNode
from .py.nodes.lora_randomizer import LoraRandomizerNode
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 pathlib
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
LoraManager = importlib.import_module("py.lora_manager").LoraManager
LoraManagerLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerLoader
LoraManagerTextLoader = importlib.import_module("py.nodes.lora_loader").LoraManagerTextLoader
TriggerWordToggle = importlib.import_module("py.nodes.trigger_word_toggle").TriggerWordToggle
LoraManagerLoader = importlib.import_module(
"py.nodes.lora_loader"
).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
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
WanVideoLoraSelect = importlib.import_module("py.nodes.wanvideo_lora_select").WanVideoLoraSelect
WanVideoLoraSelectFromText = importlib.import_module("py.nodes.wanvideo_lora_select_from_text").WanVideoLoraSelectFromText
WanVideoLoraSelectLM = importlib.import_module(
"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
NODE_CLASS_MAPPINGS = {
@@ -36,17 +54,38 @@ NODE_CLASS_MAPPINGS = {
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker,
SaveImage.NAME: SaveImage,
SaveImageLM.NAME: SaveImageLM,
DebugMetadata.NAME: DebugMetadata,
WanVideoLoraSelect.NAME: WanVideoLoraSelect,
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText
WanVideoLoraSelectLM.NAME: WanVideoLoraSelectLM,
WanVideoLoraSelectFromText.NAME: WanVideoLoraSelectFromText,
LoraPoolNode.NAME: LoraPoolNode,
LoraRandomizerNode.NAME: LoraRandomizerNode,
}
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
init_metadata_collector()
# Register routes on import
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": {
"update": "Update",
"updateAvailable": "Update verfügbar"
},
"usage": {
"timesUsed": "Verwendungsanzahl"
}
},
"globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata",
"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": {
@@ -188,7 +198,8 @@
"creator": "Ersteller",
"title": "Rezept-Titel",
"loraName": "LoRA-Dateiname",
"loraModel": "LoRA-Modellname"
"loraModel": "LoRA-Modellname",
"prompt": "Prompt"
}
},
"filter": {
@@ -199,6 +210,7 @@
"license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags",
"clearAll": "Alle Filter löschen"
},
"theme": {
@@ -221,7 +233,9 @@
"label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen",
"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": {
"contentFiltering": "Inhaltsfilterung",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultCheckpointRoot": "Standard-Checkpoint-Stammordner",
"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",
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"noDefault": "Kein Standard"
@@ -443,7 +459,10 @@
"dateAsc": "Älteste",
"size": "Dateigröße",
"sizeDesc": "Größte",
"sizeAsc": "Kleinste"
"sizeAsc": "Kleinste",
"usage": "Anzahl Nutzung",
"usageDesc": "Meiste",
"usageAsc": "Wenigste"
},
"refresh": {
"title": "Modelliste aktualisieren",
@@ -518,6 +537,7 @@
"replacePreview": "Vorschau ersetzen",
"setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren",
"excludeModel": "Modell ausschließen",
"deleteModel": "Modell löschen",
"shareRecipe": "Rezept teilen",
@@ -588,10 +608,26 @@
"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": {
"title": "Rezeptliste aktualisieren"
},
"filteredByLora": "Gefiltert nach LoRA"
"filteredByLora": "Gefiltert nach LoRA",
"favorites": {
"title": "Nur Favoriten anzeigen",
"action": "Favoriten"
}
},
"duplicates": {
"found": "{count} Duplikat-Gruppen gefunden",
@@ -617,11 +653,25 @@
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"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": {
"title": "Checkpoint-Modelle"
"title": "Checkpoint-Modelle",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "In {otherType}-Ordner verschieben"
}
},
"embeddings": {
"title": "Embedding-Modelle"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"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": {
@@ -848,7 +899,9 @@
},
"openFileLocation": {
"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": {
"version": "Version",
@@ -871,11 +924,13 @@
"addPresetParameter": "Voreingestellten Parameter hinzufügen...",
"strengthMin": "Stärke Min",
"strengthMax": "Stärke Max",
"strengthRange": "Stärkenbereich",
"strength": "Stärke",
"clipStrength": "Clip-Stärke",
"clipSkip": "Clip Skip",
"valuePlaceholder": "Wert",
"add": "Hinzufügen"
"add": "Hinzufügen",
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y"
},
"triggerWords": {
"label": "Trigger Words",
@@ -914,6 +969,13 @@
"recipes": "Rezepte",
"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": {
"noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Verifikation abgeschlossen. Alle Dateien sind bestätigte Duplikate.",
"verificationFailed": "Fehler beim Verifizieren der Hashes: {message}",
"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",
"tagsReplacedSuccessfully": "Tags für {count} {type}(s) erfolgreich durch {tagCount} Tag(s) ersetzt",
"tagsAddFailed": "Fehler beim Hinzufügen von Tags zu {count} Modell(en)",
@@ -1330,6 +1393,7 @@
"settings": {
"loraRootsFailed": "Fehler beim Laden der LoRA-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}",
"mappingsUpdated": "Basis-Modell-Pfad-Zuordnungen aktualisiert ({count} Zuordnung{plural})",
"mappingsCleared": "Basis-Modell-Pfad-Zuordnungen gelöscht",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
"metadataRefreshFailed": "Fehler beim Aktualisieren der Metadaten: {message}",
"metadataUpdateComplete": "Metadaten-Update abgeschlossen",
"operationCancelled": "Vorgang vom Benutzer abgebrochen",
"operationCancelledPartial": "Vorgang abgebrochen. {success} Elemente verarbeitet.",
"metadataFetchFailed": "Fehler beim Abrufen der Metadaten: {message}",
"bulkMetadataCompleteAll": "Alle {count} {type}s erfolgreich aktualisiert",
"bulkMetadataCompletePartial": "{success} von {total} {type}s aktualisiert",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
"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": {

View File

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

View File

@@ -131,6 +131,9 @@
"badges": {
"update": "Actualización",
"updateAvailable": "Actualización disponible"
},
"usage": {
"timesUsed": "Veces usado"
}
},
"globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata",
"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": {
@@ -188,7 +198,8 @@
"creator": "Creador",
"title": "Título de la receta",
"loraName": "Nombre de archivo LoRA",
"loraModel": "Nombre del modelo LoRA"
"loraModel": "Nombre del modelo LoRA",
"prompt": "Prompt"
}
},
"filter": {
@@ -199,6 +210,7 @@
"license": "Licencia",
"noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas",
"clearAll": "Limpiar todos los filtros"
},
"theme": {
@@ -221,7 +233,9 @@
"label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json",
"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": {
"contentFiltering": "Filtrado de contenido",
@@ -305,6 +319,8 @@
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
"defaultCheckpointRoot": "Raíz predeterminada de checkpoint",
"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",
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"noDefault": "Sin predeterminado"
@@ -443,7 +459,10 @@
"dateAsc": "Más antiguo",
"size": "Tamaño de archivo",
"sizeDesc": "Mayor",
"sizeAsc": "Menor"
"sizeAsc": "Menor",
"usage": "Número de usos",
"usageDesc": "Más",
"usageAsc": "Menos"
},
"refresh": {
"title": "Actualizar lista de modelos",
@@ -518,6 +537,7 @@
"replacePreview": "Reemplazar vista previa",
"setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos",
"excludeModel": "Excluir modelo",
"deleteModel": "Eliminar modelo",
"shareRecipe": "Compartir receta",
@@ -588,10 +608,26 @@
"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": {
"title": "Actualizar lista de recetas"
},
"filteredByLora": "Filtrado por LoRA"
"filteredByLora": "Filtrado por LoRA",
"favorites": {
"title": "Mostrar solo favoritos",
"action": "Favoritos"
}
},
"duplicates": {
"found": "Se encontraron {count} grupos de duplicados",
@@ -617,11 +653,25 @@
"noMissingLoras": "No hay LoRAs faltantes para descargar",
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
"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": {
"title": "Modelos checkpoint"
"title": "Modelos checkpoint",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "Mover a la carpeta {otherType}"
}
},
"embeddings": {
"title": "Modelos embedding"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista",
"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": {
@@ -848,7 +899,9 @@
},
"openFileLocation": {
"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": {
"version": "Versión",
@@ -871,11 +924,13 @@
"addPresetParameter": "Añadir parámetro preestablecido...",
"strengthMin": "Fuerza mínima",
"strengthMax": "Fuerza máxima",
"strengthRange": "Rango de fuerza",
"strength": "Fuerza",
"clipStrength": "Fuerza de Clip",
"clipSkip": "Clip Skip",
"valuePlaceholder": "Valor",
"add": "Añadir"
"add": "Añadir",
"invalidRange": "Formato de rango inválido. Use x.x-y.y"
},
"triggerWords": {
"label": "Palabras clave",
@@ -914,6 +969,13 @@
"recipes": "Recetas",
"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": {
"noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Verificación completa. Todos los archivos son confirmados duplicados.",
"verificationFailed": "Error al verificar hashes: {message}",
"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)",
"tagsReplacedSuccessfully": "Se reemplazaron exitosamente las etiquetas de {count} {type}(s) con {tagCount} etiqueta(s)",
"tagsAddFailed": "Error al añadir etiquetas a {count} modelo(s)",
@@ -1330,6 +1393,7 @@
"settings": {
"loraRootsFailed": "Error al cargar raíces de LoRA: {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}",
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Metadatos actualizados exitosamente",
"metadataRefreshFailed": "Error al actualizar metadatos: {message}",
"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}",
"bulkMetadataCompleteAll": "Actualizados exitosamente todos los {count} {type}s",
"bulkMetadataCompletePartial": "Actualizados {success} de {total} {type}s",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
"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": {

View File

@@ -131,6 +131,9 @@
"badges": {
"update": "Mise à jour",
"updateAvailable": "Mise à jour disponible"
},
"usage": {
"timesUsed": "Nombre d'utilisations"
}
},
"globalContextMenu": {
@@ -159,6 +162,13 @@
"success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata",
"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": {
@@ -188,7 +198,8 @@
"creator": "Créateur",
"title": "Titre de la recipe",
"loraName": "Nom de fichier LoRA",
"loraModel": "Nom du modèle LoRA"
"loraModel": "Nom du modèle LoRA",
"prompt": "Prompt"
}
},
"filter": {
@@ -199,6 +210,7 @@
"license": "Licence",
"noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag",
"clearAll": "Effacer tous les filtres"
},
"theme": {
@@ -221,7 +233,9 @@
"label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json",
"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": {
"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",
"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",
"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",
"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"
@@ -443,7 +459,10 @@
"dateAsc": "Plus ancien",
"size": "Taille du fichier",
"sizeDesc": "Plus grand",
"sizeAsc": "Plus petit"
"sizeAsc": "Plus petit",
"usage": "Nombre d'utilisations",
"usageDesc": "Plus",
"usageAsc": "Moins"
},
"refresh": {
"title": "Actualiser la liste des modèles",
@@ -518,6 +537,7 @@
"replacePreview": "Remplacer l'aperçu",
"setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées",
"excludeModel": "Exclure le modèle",
"deleteModel": "Supprimer le modèle",
"shareRecipe": "Partager la recipe",
@@ -588,10 +608,26 @@
"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": {
"title": "Actualiser la liste des recipes"
},
"filteredByLora": "Filtré par LoRA"
"filteredByLora": "Filtré par LoRA",
"favorites": {
"title": "Afficher uniquement les favoris",
"action": "Favoris"
}
},
"duplicates": {
"found": "Trouvé {count} groupes de doublons",
@@ -617,11 +653,25 @@
"noMissingLoras": "Aucun LoRA manquant à télécharger",
"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}"
},
"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": {
"title": "Modèles Checkpoint"
"title": "Modèles Checkpoint",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
},
"contextMenu": {
"moveToOtherTypeFolder": "Déplacer vers le dossier {otherType}"
}
},
"embeddings": {
"title": "Modèles Embedding"
@@ -638,7 +688,8 @@
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste",
"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": {
@@ -848,7 +899,9 @@
},
"openFileLocation": {
"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": {
"version": "Version",
@@ -871,11 +924,13 @@
"addPresetParameter": "Ajouter un paramètre prédéfini...",
"strengthMin": "Force Min",
"strengthMax": "Force Max",
"strengthRange": "Gamme de force",
"strength": "Force",
"clipStrength": "Force Clip",
"clipSkip": "Clip Skip",
"valuePlaceholder": "Valeur",
"add": "Ajouter"
"add": "Ajouter",
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y"
},
"triggerWords": {
"label": "Mots-clés",
@@ -914,6 +969,13 @@
"recipes": "Recipes",
"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": {
"noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation",
@@ -1317,6 +1379,7 @@
"verificationCompleteSuccess": "Vérification terminée. Tous les fichiers sont confirmés comme doublons.",
"verificationFailed": "Échec de la vérification des hash : {message}",
"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)",
"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)",
@@ -1330,6 +1393,7 @@
"settings": {
"loraRootsFailed": "Échec du chargement des racines LoRA : {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}",
"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",
@@ -1437,6 +1501,8 @@
"metadataRefreshed": "Métadonnées actualisées avec succès",
"metadataRefreshFailed": "Échec de l'actualisation des métadonnées : {message}",
"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}",
"bulkMetadataCompleteAll": "Actualisation réussie de tous les {count} {type}s",
"bulkMetadataCompletePartial": "{success} sur {total} {type}s actualisés",
@@ -1453,7 +1519,8 @@
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s déplacés 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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,15 @@
import os
import platform
import threading
from pathlib import Path
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 json
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
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] = {}
# Normalized preview root directories used to validate preview access
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.checkpoints_roots = None
self.unet_roots = None
@@ -87,8 +91,7 @@ class Config:
self.base_models_roots = self._init_checkpoint_paths()
self.embeddings_roots = self._init_embedding_paths()
# Scan symbolic links during initialization
self._scan_symbolic_links()
self._rebuild_preview_roots()
self._initialize_symlink_mappings()
if not standalone_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}")
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):
"""Scan all symbolic links in LoRA, Checkpoint, and Embedding root directories"""
for root in self.loras_roots:
self._scan_directory_links(root)
start = time.perf_counter()
for root in self.base_models_roots:
self._scan_directory_links(root)
for root in self.embeddings_roots:
self._scan_directory_links(root)
# Reset mappings before rescanning to avoid stale entries
self._path_mappings.clear()
self._seed_root_symlink_mappings()
visited_dirs: Set[str] = set()
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):
"""Recursively scan symbolic links in a directory"""
def _scan_directory_links(self, root: str, visited_dirs: Set[str]):
"""Iteratively scan directory symlinks to avoid deep recursion."""
try:
with os.scandir(root) as it:
for entry in it:
if self._is_link(entry.path):
target_path = os.path.realpath(entry.path)
if os.path.isdir(target_path):
self.add_path_mapping(entry.path, target_path)
self._scan_directory_links(target_path)
elif entry.is_dir(follow_symlinks=False):
self._scan_directory_links(entry.path)
except Exception as e:
logger.error(f"Error scanning links in {root}: {e}")
# Note: We only use realpath for the initial root if it's not already resolved
# to ensure we have a valid entry point.
root_real = self._normalize_path(os.path.realpath(root))
except OSError:
root_real = self._normalize_path(root)
if root_real in visited_dirs:
return
visited_dirs.add(root_real)
# 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):
"""Add a symbolic link path mapping
target_path: actual target path
link_path: symbolic link path
"""
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
normalized_target = os.path.normpath(target_path).replace(os.sep, '/')
normalized_link = self._normalize_path(link_path)
normalized_target = self._normalize_path(target_path)
# Keep the original mapping: target path -> link path
self._path_mappings[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_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]:
"""Return normalized ``Path`` objects representing a preview root."""
@@ -315,28 +490,44 @@ class Config:
preview_roots.update(self._expand_preview_root(link))
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d symlink mappings",
len(self._preview_root_paths),
len(self.loras_roots or []),
len(self.base_models_roots or []),
len(self.embeddings_roots or []),
len(self._path_mappings),
)
def map_path_to_link(self, path: str) -> str:
"""Map a target path back to its symbolic link path"""
normalized_path = os.path.normpath(path).replace(os.sep, '/')
# Check if the path is contained in any mapped target path
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
mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path
return path
return normalized_path
def map_link_to_path(self, link_path: str) -> str:
"""Map a symbolic link path back to the actual path"""
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
# Check if the path is contained in any mapped target path
for target_path, link_path in self._path_mappings.items():
if normalized_link.startswith(target_path):
# If the path starts with the target path, replace with actual path
mapped_path = normalized_link.replace(target_path, link_path, 1)
for target_path, link_path_mapped in self._path_mappings.items():
# Match whole path components
if normalized_link == link_path_mapped:
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 link_path
return normalized_link
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
dedup: Dict[str, str] = {}
@@ -411,8 +602,7 @@ class Config:
self.base_models_roots = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
self._scan_symbolic_links()
self._rebuild_preview_roots()
self._initialize_symlink_mappings()
def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings"""
@@ -483,12 +673,29 @@ class Config:
except Exception:
return False
# Use os.path.normcase for case-insensitive comparison on Windows.
# On Windows, Path.relative_to() is case-sensitive for drive letters,
# causing paths like 'a:/folder' to not match 'A:/folder'.
candidate_str = os.path.normcase(str(candidate))
for root in self._preview_root_paths:
try:
candidate.relative_to(root)
root_str = os.path.normcase(str(root))
# Check if candidate is equal to or under the root directory
if candidate_str == root_str or candidate_str.startswith(root_str + os.sep):
return True
except ValueError:
continue
if self._preview_root_paths:
logger.debug(
"Preview path rejected: %s (candidate=%s, num_roots=%d, first_root=%s)",
preview_path,
candidate_str,
len(self._preview_root_paths),
os.path.normcase(str(next(iter(self._preview_root_paths)))),
)
else:
logger.debug(
"Preview path rejected (no roots configured): %s",
preview_path,
)
return False

View File

@@ -2,6 +2,15 @@ import asyncio
import sys
import os
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 .config import config
@@ -17,12 +26,10 @@ from .services.settings_manager import get_settings_manager
from .utils.example_images_migration import ExampleImagesMigration
from .services.websocket_manager import ws_manager
from .services.example_images_cleanup_service import ExampleImagesCleanupService
from .middleware.csp_middleware import relax_csp_for_remote_media
logger = logging.getLogger(__name__)
# Check if we're in standalone mode
STANDALONE_MODE = 'nodes' not in sys.modules
HEADER_SIZE_LIMIT = 16384
@@ -62,6 +69,23 @@ class LoraManager:
"""Initialize and register all routes using the new refactored architecture"""
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
# 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
@@ -140,8 +164,6 @@ class LoraManager:
# Add 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
async def _initialize_services(cls):
"""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):
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
# If we found candidate samplers, apply primary sampler logic to these candidates only
if candidate_samplers:
# If we found candidate samplers, apply primary sampler logic to these candidates only
# 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
custom_advanced_samplers = []
advanced_add_noise_samplers = []
@@ -49,7 +80,6 @@ class MetadataProcessor:
high_denoise_id = None
# First, check for SamplerCustomAdvanced among candidates
prompt = metadata.get("current_prompt")
if prompt and prompt.original_prompt:
for node_id in candidate_samplers:
node_info = prompt.original_prompt.get(node_id, {})
@@ -77,15 +107,16 @@ class MetadataProcessor:
# Combine all potential primary samplers
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
# Find the most recent potential primary sampler (closest to downstream node)
for i in range(downstream_index - 1, -1, -1):
# Find the first potential primary sampler (prefer base sampler over refine)
# Use forward search to prioritize the first one in execution order
for i in range(downstream_index):
node_id = execution_order[i]
if node_id in potential_samplers:
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:
for i in range(downstream_index - 1, -1, -1):
for i in range(downstream_index):
node_id = execution_order[i]
if node_id in candidate_samplers:
return node_id, candidate_samplers[node_id]
@@ -176,8 +207,11 @@ class MetadataProcessor:
found_node_id = input_value[0] # Connected node_id
# If we're looking for a specific node class
if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class:
return found_node_id
if target_class:
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 not target_class:
@@ -185,11 +219,19 @@ class MetadataProcessor:
# Continue tracing through intermediate nodes
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"
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
return found_node_id if not target_class else None
else:
@@ -202,12 +244,89 @@ class MetadataProcessor:
return last_valid_node if not target_class else None
@staticmethod
def find_primary_checkpoint(metadata):
"""Find the primary checkpoint model in the workflow"""
if not metadata.get(MODELS):
def trace_model_path(metadata, prompt, start_node_id):
"""
Trace the model connection path upstream to find the checkpoint
"""
if not prompt or not prompt.original_prompt:
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():
if model_info.get("type") == "checkpoint":
return model_info.get("name")
@@ -311,7 +430,8 @@ class MetadataProcessor:
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
# 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:
params["checkpoint"] = checkpoint
@@ -445,6 +565,7 @@ class MetadataProcessor:
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
params["steps"] = scheduler_params.get("steps")
params["scheduler"] = scheduler_params.get("scheduler")
params["denoise"] = scheduler_params.get("denoise")
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
if "sampler" in sampler_inputs:

View File

@@ -196,9 +196,11 @@ class MetadataRegistry:
node_metadata[category] = {}
node_metadata[category][node_id] = current_metadata[category][node_id]
# Save to cache if we have any metadata for this node
# Save new metadata or clear stale cache entries when metadata is empty
if any(node_metadata.values()):
self.node_cache[cache_key] = node_metadata
else:
self.node_cache.pop(cache_key, None)
def clear_unused_cache(self):
"""Clean up node_cache entries that are no longer in use"""

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
from server import PromptServer # type: ignore
from ..metadata_collector.metadata_processor import MetadataProcessor
logger = logging.getLogger(__name__)
class DebugMetadata:
NAME = "Debug Metadata (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Debug node to verify metadata_processor functionality"
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
@@ -25,21 +25,37 @@ class DebugMetadata:
FUNCTION = "process_metadata"
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:
# Get the current execution context's metadata
from ..metadata_collector import get_metadata
metadata = get_metadata()
# Use the MetadataProcessor to convert it to JSON string
metadata_json = MetadataProcessor.to_json(metadata, id)
# Send metadata to frontend for display
PromptServer.instance.send_sync("metadata_update", {
"id": id,
"metadata": metadata_json
})
# Use the MetadataProcessor to convert it to dict
metadata_dict = MetadataProcessor.to_dict(metadata, id)
return {
"result": (),
# ComfyUI expects ui values to be lists, wrap the dict in a list
"ui": {"metadata": [metadata_dict]},
}
except Exception as 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},
}

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

@@ -0,0 +1,206 @@
"""
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}")
# Dual seed mechanism for batch queue synchronization
# execution_seed: seed for generating execution_stack (= previous next_seed)
# next_seed: seed for generating ui_loras (= what will be displayed after execution)
execution_seed = randomizer_config.get("execution_seed", None)
next_seed = randomizer_config.get("next_seed", None)
if roll_mode == "fixed":
ui_loras = loras
execution_loras = loras
else:
scanner = await ServiceRegistry.get_lora_scanner()
# Generate execution_loras from execution_seed (if available)
if execution_seed is not None:
# Use execution_seed to regenerate the same loras that were shown to user
execution_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config, seed=execution_seed
)
else:
# First execution: use loras input (what user sees in the widget)
execution_loras = loras
# Generate ui_loras from next_seed (for display after execution)
ui_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config, seed=next_seed
)
execution_stack = self._build_execution_stack_from_input(execution_loras)
return {
"result": (execution_stack,),
"ui": {"loras": ui_loras, "last_used": execution_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, seed=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
seed: Optional seed for deterministic randomization
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,
seed=seed,
)
return result_loras

View File

@@ -9,7 +9,7 @@ from ..metadata_collector import get_metadata
from PIL import Image, PngImagePlugin
import piexif
class SaveImage:
class SaveImageLM:
NAME = "Save Image (LoraManager)"
CATEGORY = "Lora Manager/utils"
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
if isinstance(trigger_data, str):
trigger_data = json.loads(trigger_data)
# Create dictionaries to track active state of words or groups
# Also track strength values for each trigger word
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 isinstance(trigger_data, list):
if group_mode:
if allow_strength_adjustment:
strength_map[original_word] = strength
else:
active_state[text.strip()] = active
if group_mode:
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(
parsed_items = [
self._parse_trigger_item(item, allow_strength_adjustment)
for item in trigger_data
]
filtered_groups = [
self._format_word_output(
word_comparison,
strength_map,
item["text"],
item["strength"],
allow_strength_adjustment,
)
)
if filtered_words:
filtered_triggers = ', '.join(filtered_words)
for item in parsed_items
if item["text"] and item["active"]
]
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:
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:
logger.error(f"Error processing trigger words: {e}")
return (filtered_triggers,)
def _format_word_output(self, base_word, strength_map, allow_strength_adjustment):
if allow_strength_adjustment and base_word in strength_map:
return f"({base_word}:{strength_map[base_word]:.2f})"
def _parse_trigger_item(self, item, allow_strength_adjustment):
text = (item.get('text') or "").strip()
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

View File

@@ -36,6 +36,7 @@ any_type = AnyType("*")
import os
import logging
import copy
import sys
import folder_paths
logger = logging.getLogger(__name__)
@@ -98,25 +99,37 @@ def to_diffusers(input_lora):
def nunchaku_load_lora(model, lora_name, lora_strength):
"""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.
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):
logger.warning("Skipping LoRA '%s' because it could not be found", lora_name)
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
sd = to_diffusers(lora_path)

View File

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

View File

@@ -37,7 +37,8 @@ class RecipeMetadataParser(ABC):
"""
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]]:
"""
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}")
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
@@ -187,6 +189,7 @@ class RecipeMetadataParser(ABC):
checkpoint['downloadUrl'] = civitai_data.get('downloadUrl', '')
checkpoint['modelId'] = civitai_data.get('modelId', checkpoint.get('modelId', 0))
checkpoint['id'] = civitai_data.get('id', 0)
if 'files' in civitai_data:
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

@@ -1,6 +1,7 @@
"""Parser for Automatic1111 metadata format."""
import re
import os
import json
import logging
from typing import Dict, Any
@@ -22,6 +23,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
EXTRANETS_REGEX = r'<(lora|hypernet):([^:]+):(-?[0-9.]+)>'
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
MODEL_NAME_PATTERN = r'Model: ([^,]+)'
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
def is_metadata_matching(self, user_comment: str) -> bool:
@@ -115,6 +117,12 @@ class AutomaticMetadataParser(RecipeMetadataParser):
except json.JSONDecodeError:
logger.error("Error parsing hashes JSON")
# Pick up model hash from parsed hashes if available
if "hashes" in metadata and not metadata.get("model_hash"):
model_hash_from_hashes = metadata["hashes"].get("model")
if model_hash_from_hashes:
metadata["model_hash"] = model_hash_from_hashes
# Extract Lora hashes in alternative format
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
if not hashes_match and lora_hashes_match:
@@ -137,6 +145,17 @@ class AutomaticMetadataParser(RecipeMetadataParser):
params_section = params_section.replace(lora_hashes_match.group(0), '')
except Exception as e:
logger.error(f"Error parsing Lora hashes: {e}")
# Extract checkpoint model hash/name when provided outside Civitai resources
model_hash_match = re.search(self.MODEL_HASH_PATTERN, params_section)
if model_hash_match:
metadata["model_hash"] = model_hash_match.group(1).strip()
params_section = params_section.replace(model_hash_match.group(0), '')
model_name_match = re.search(self.MODEL_NAME_PATTERN, params_section)
if model_name_match:
metadata["model_name"] = model_name_match.group(1).strip()
params_section = params_section.replace(model_name_match.group(0), '')
# Extract basic parameters
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
@@ -178,9 +197,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
metadata["gen_params"] = gen_params
# Extract LoRA information
# Extract LoRA and checkpoint information
loras = []
base_model_counts = {}
checkpoint = None
# First use Civitai resources if available (more reliable source)
if metadata.get("civitai_resources"):
@@ -202,6 +222,50 @@ class AutomaticMetadataParser(RecipeMetadataParser):
resource["modelVersionId"] = air_modelVersionId
# --- End added ---
if resource.get("type") == "checkpoint" and resource.get("modelVersionId"):
version_id = resource.get("modelVersionId")
version_id_str = str(version_id)
checkpoint_entry = {
'id': version_id,
'modelId': resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown Checkpoint"),
'version': resource.get("modelVersionName", resource.get("versionName", "")),
'type': resource.get("type", "checkpoint"),
'existsLocally': False,
'localPath': None,
'file_name': resource.get("modelName", ""),
'hash': resource.get("hash", "") or "",
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
if metadata_provider:
try:
civitai_info = await metadata_provider.get_model_version_info(version_id_str)
checkpoint_entry = await self.populate_checkpoint_from_civitai(
checkpoint_entry,
civitai_info
)
except Exception as e:
logger.error(
"Error fetching Civitai info for checkpoint version %s: %s",
version_id,
e,
)
# Prefer the first checkpoint found
if checkpoint_entry.get("baseModel"):
base_model_value = checkpoint_entry["baseModel"]
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
if checkpoint is None:
checkpoint = checkpoint_entry
continue
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
# Initialize lora entry
lora_entry = {
@@ -237,6 +301,52 @@ class AutomaticMetadataParser(RecipeMetadataParser):
loras.append(lora_entry)
# Fallback checkpoint parsing from generic "Model" and "Model hash" fields
if checkpoint is None:
model_hash = metadata.get("model_hash")
if not model_hash and metadata.get("hashes"):
model_hash = metadata["hashes"].get("model")
model_name = metadata.get("model_name")
file_name = ""
if model_name:
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
file_name = os.path.splitext(cleaned_name)[0]
if model_hash or model_name:
checkpoint_entry = {
'id': 0,
'modelId': 0,
'name': model_name or "Unknown Checkpoint",
'version': '',
'type': 'checkpoint',
'hash': model_hash or "",
'existsLocally': False,
'localPath': None,
'file_name': file_name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
checkpoint_entry = await self.populate_checkpoint_from_civitai(
checkpoint_entry,
civitai_info
)
except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
if checkpoint_entry.get("baseModel"):
base_model_value = checkpoint_entry["baseModel"]
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
checkpoint = checkpoint_entry
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
if not loras or len(loras) == 0:
# Extract lora weights from extranet tags in prompt (for later use)
@@ -300,7 +410,9 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Try to get base model from resources or make educated guess
base_model = None
if base_model_counts:
if checkpoint and checkpoint.get("baseModel"):
base_model = checkpoint.get("baseModel")
elif base_model_counts:
# Use the most common base model from the loras
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
@@ -317,6 +429,10 @@ class AutomaticMetadataParser(RecipeMetadataParser):
'gen_params': filtered_gen_params,
'from_automatic_metadata': True
}
if checkpoint:
result['checkpoint'] = checkpoint
result['model'] = checkpoint
return result

View File

@@ -23,13 +23,48 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
"""
if not metadata or not isinstance(metadata, dict):
return False
# Check for key markers specific to Civitai image metadata
return any([
"resources" in metadata,
"civitaiResources" in metadata,
"additionalResources" in metadata
])
def has_markers(payload: Dict[str, Any]) -> bool:
# Check for common CivitAI image metadata fields
civitai_image_fields = (
"resources",
"civitaiResources",
"additionalResources",
"hashes",
"prompt",
"negativePrompt",
"steps",
"sampler",
"cfgScale",
"seed",
"width",
"height",
"Model",
"Model hash"
)
return any(key in payload for key in civitai_image_fields)
# Check the main metadata object
if has_markers(metadata):
return True
# Check for LoRA hash patterns
hashes = metadata.get("hashes")
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
return True
# Check nested meta object (common in CivitAI image responses)
nested_meta = metadata.get("meta")
if isinstance(nested_meta, dict):
if has_markers(nested_meta):
return True
# Also check for LoRA hash patterns in nested meta
hashes = nested_meta.get("hashes")
if isinstance(hashes, dict) and any(str(key).lower().startswith("lora:") for key in hashes):
return True
return False
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
"""Parse metadata from Civitai image format
@@ -45,6 +80,26 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
try:
# Get metadata provider instead of using civitai_client directly
metadata_provider = await get_default_metadata_provider()
# Civitai image responses may wrap the actual metadata inside a "meta" key
if (
isinstance(metadata, dict)
and "meta" in metadata
and isinstance(metadata["meta"], dict)
):
inner_meta = metadata["meta"]
if any(
key in inner_meta
for key in (
"resources",
"civitaiResources",
"additionalResources",
"hashes",
"prompt",
"negativePrompt",
)
):
metadata = inner_meta
# Initialize result structure
result = {
@@ -62,8 +117,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
lora_hashes = {}
if "hashes" in metadata and isinstance(metadata["hashes"], dict):
for key, hash_value in metadata["hashes"].items():
if key.startswith("LORA:"):
lora_name = key.replace("LORA:", "")
key_str = str(key)
if key_str.lower().startswith("lora:"):
lora_name = key_str.split(":", 1)[1]
lora_hashes[lora_name] = hash_value
# Extract prompt and negative prompt

View File

@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
# Find all LoraLoader nodes
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
for node_id, node in lora_nodes.items():
if 'inputs' not in node or 'lora_name' not in node['inputs']:

View File

@@ -1,5 +1,6 @@
"""Parser for meta format (Lora_N Model hash) metadata."""
import os
import re
import logging
from typing import Dict, Any
@@ -145,14 +146,53 @@ class MetaFormatParser(RecipeMetadataParser):
loras.append(lora_entry)
# Extract model information
model = None
if 'model' in metadata:
model = metadata['model']
# Extract checkpoint information from generic Model/Model hash fields
checkpoint = None
model_hash = metadata.get("model_hash")
model_name = metadata.get("model")
if model_hash or model_name:
cleaned_name = None
if model_name:
cleaned_name = re.split(r"[\\\\/]", model_name)[-1]
cleaned_name = os.path.splitext(cleaned_name)[0]
checkpoint_entry = {
'id': 0,
'modelId': 0,
'name': model_name or "Unknown Checkpoint",
'version': '',
'type': 'checkpoint',
'hash': model_hash or "",
'existsLocally': False,
'localPath': None,
'file_name': cleaned_name or (model_name or ""),
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
if metadata_provider and model_hash:
try:
civitai_info = await metadata_provider.get_model_by_hash(model_hash)
checkpoint_entry = await self.populate_checkpoint_from_civitai(
checkpoint_entry,
civitai_info
)
except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint hash {model_hash}: {e}")
if checkpoint_entry.get("baseModel"):
base_model_value = checkpoint_entry["baseModel"]
base_model_counts[base_model_value] = base_model_counts.get(base_model_value, 0) + 1
checkpoint = checkpoint_entry
# Set base_model to the most common one from civitai_info
base_model = None
if base_model_counts:
# Set base_model to the most common one from civitai_info or checkpoint
base_model = checkpoint["baseModel"] if checkpoint and checkpoint.get("baseModel") else None
if not base_model and base_model_counts:
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
# Extract generation parameters for recipe metadata
@@ -170,7 +210,8 @@ class MetaFormatParser(RecipeMetadataParser):
'loras': loras,
'gen_params': gen_params,
'raw_metadata': metadata,
'from_meta_format': True
'from_meta_format': True,
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
}
except Exception as e:

View File

@@ -3,7 +3,7 @@
import re
import json
import logging
from typing import Dict, Any
from typing import Dict, Any, Optional
from ...config import config
from ..base import RecipeMetadataParser
from ..constants import GEN_PARAM_KEYS
@@ -16,6 +16,28 @@ class RecipeFormatParser(RecipeMetadataParser):
# Regular expression pattern for extracting recipe metadata
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
async def _get_lora_from_version_index(self, recipe_scanner, model_version_id: Any) -> Optional[Dict[str, Any]]:
"""Return a cached LoRA entry by modelVersionId if available."""
if not recipe_scanner or not getattr(recipe_scanner, "_lora_scanner", None):
return None
try:
normalized_id = int(model_version_id)
except (TypeError, ValueError):
return None
try:
cache = await recipe_scanner._lora_scanner.get_cached_data()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Unable to load lora cache for version lookup: %s", exc)
return None
if not cache or not getattr(cache, "version_index", None):
return None
return cache.version_index.get(normalized_id)
def is_metadata_matching(self, user_comment: str) -> bool:
"""Check if the user comment matches the metadata format"""
@@ -53,49 +75,110 @@ class RecipeFormatParser(RecipeMetadataParser):
'type': 'lora',
'weight': lora.get('strength', 1.0),
'file_name': lora.get('file_name', ''),
'hash': lora.get('hash', '')
'hash': lora.get('hash', ''),
'existsLocally': False,
'inLibrary': False,
'localPath': None,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'size': 0
}
# Check if this LoRA exists locally by SHA256 hash
if lora.get('hash') and recipe_scanner:
if recipe_scanner:
lora_scanner = recipe_scanner._lora_scanner
exists_locally = lora_scanner.has_hash(lora['hash'])
if exists_locally:
lora_cache = await lora_scanner.get_cached_data()
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
if lora_item:
if lora.get('hash'):
exists_locally = lora_scanner.has_hash(lora['hash'])
if exists_locally:
lora_cache = await lora_scanner.get_cached_data()
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
if lora_item:
lora_entry['existsLocally'] = True
lora_entry['inLibrary'] = True
lora_entry['localPath'] = lora_item['file_path']
lora_entry['file_name'] = lora_item['file_name']
lora_entry['size'] = lora_item['size']
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
else:
lora_entry['existsLocally'] = False
lora_entry['inLibrary'] = False
lora_entry['localPath'] = None
# If we still don't have a local match, try matching by modelVersionId
if not lora_entry['existsLocally'] and lora.get('modelVersionId') is not None:
cached_lora = await self._get_lora_from_version_index(recipe_scanner, lora.get('modelVersionId'))
if cached_lora:
lora_entry['existsLocally'] = True
lora_entry['localPath'] = lora_item['file_path']
lora_entry['file_name'] = lora_item['file_name']
lora_entry['size'] = lora_item['size']
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
else:
lora_entry['existsLocally'] = False
lora_entry['localPath'] = None
# Try to get additional info from Civitai if we have a model version ID
if lora.get('modelVersionId') and metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(lora['modelVersionId'])
# Populate lora entry with Civitai info
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info_tuple,
recipe_scanner,
None, # No need to track base model counts
lora['hash']
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA: {e}")
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
lora_entry['inLibrary'] = True
lora_entry['localPath'] = cached_lora.get('file_path')
lora_entry['file_name'] = cached_lora.get('file_name') or lora_entry['file_name']
lora_entry['size'] = cached_lora.get('size', lora_entry['size'])
if cached_lora.get('sha256'):
lora_entry['hash'] = cached_lora['sha256']
preview_url = cached_lora.get('preview_url')
if preview_url:
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
# Try to get additional info from Civitai if we have a model version ID and still missing locally
if not lora_entry['existsLocally'] and lora.get('modelVersionId') and metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(lora['modelVersionId'])
# Populate lora entry with Civitai info
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info_tuple,
recipe_scanner,
None, # No need to track base model counts
lora_entry.get('hash', '')
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA: {e}")
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
loras.append(lora_entry)
logger.info(f"Found {len(loras)} loras in recipe metadata")
# Process checkpoint information if present
checkpoint = None
checkpoint_data = recipe_metadata.get('checkpoint') or {}
if isinstance(checkpoint_data, dict) and checkpoint_data:
version_id = checkpoint_data.get('modelVersionId') or checkpoint_data.get('id')
checkpoint_entry = {
'id': version_id or 0,
'modelId': checkpoint_data.get('modelId', 0),
'name': checkpoint_data.get('name', 'Unknown Checkpoint'),
'version': checkpoint_data.get('version', ''),
'type': checkpoint_data.get('type', 'checkpoint'),
'hash': checkpoint_data.get('hash', ''),
'existsLocally': False,
'localPath': None,
'file_name': checkpoint_data.get('file_name', ''),
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
if metadata_provider:
try:
civitai_info = None
if version_id:
civitai_info = await metadata_provider.get_model_version_info(str(version_id))
elif checkpoint_entry.get('hash'):
civitai_info = await metadata_provider.get_model_by_hash(checkpoint_entry['hash'])
if civitai_info:
checkpoint_entry = await self.populate_checkpoint_from_civitai(checkpoint_entry, civitai_info)
except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint in recipe metadata: {e}")
checkpoint = checkpoint_entry
# Filter gen_params to only include recognized keys
filtered_gen_params = {}
@@ -105,12 +188,13 @@ class RecipeFormatParser(RecipeMetadataParser):
filtered_gen_params[key] = value
return {
'base_model': recipe_metadata.get('base_model', ''),
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
'loras': loras,
'gen_params': filtered_gen_params,
'tags': recipe_metadata.get('tags', []),
'title': recipe_metadata.get('title', ''),
'from_recipe_metadata': True
'from_recipe_metadata': True,
**({'checkpoint': checkpoint, 'model': checkpoint} if checkpoint else {})
}
except Exception as e:

View File

@@ -120,7 +120,7 @@ class BaseModelRoutes(ABC):
self.service = service
self.model_type = 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(
scanner=service.scanner,
metadata_manager=MetadataManager,
@@ -270,7 +270,7 @@ class BaseModelRoutes(ABC):
def _ensure_move_service(self) -> ModelMoveService:
if self.model_move_service is None:
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
def _ensure_lifecycle_service(self) -> ModelLifecycleService:

View File

@@ -79,26 +79,8 @@ class BaseRecipeRoutes:
return
app.on_startup.append(self.attach_dependencies)
app.on_startup.append(self.prewarm_cache)
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]:
"""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/force-download-example-images", "force_download_example_images"),
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:
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:
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,
"import_example_images": self.management.import_example_images,
"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,
"open_example_images_folder": self.files.open_example_images_folder,
"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__)
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):
"""Subset of PromptServer used by the handlers."""
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):
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")
if not isinstance(raw_widget_names, list):
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] = []
if isinstance(raw_widget_names, list):
@@ -175,6 +224,7 @@ class SettingsHandler:
"civitai_api_key",
"default_lora_root",
"default_checkpoint_root",
"default_unet_root",
"default_embedding_root",
"base_model_path_mappings",
"download_path_templates",
@@ -205,14 +255,25 @@ class SettingsHandler:
"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__(
self,
*,
settings_service=None,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
downloader_factory: Callable[[], Awaitable[DownloaderProtocol]] = get_downloader,
metadata_provider_updater: Callable[
[], Awaitable[None]
] = update_metadata_providers,
downloader_factory: Callable[
[], Awaitable[DownloaderProtocol]
] = get_downloader,
) -> None:
self._settings = settings_service or get_settings_manager()
self._metadata_provider_updater = metadata_provider_updater
@@ -248,11 +309,13 @@ class SettingsHandler:
response_data["settings_file"] = settings_file
messages_getter = getattr(self._settings, "get_startup_messages", None)
messages = list(messages_getter()) if callable(messages_getter) else []
return web.json_response({
"success": True,
"settings": response_data,
"messages": messages,
})
return web.json_response(
{
"success": True,
"settings": response_data,
"messages": messages,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting settings: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -271,8 +334,12 @@ class SettingsHandler:
try:
data = await request.json()
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error parsing activate library request: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400)
logger.error(
"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")
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)
return web.json_response({"success": False, "error": str(exc)}, status=404)
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)
async def update_settings(self, request: web.Request) -> web.Response:
@@ -312,9 +381,14 @@ class SettingsHandler:
if key == "example_images_path" and value:
validation_error = self._validate_example_images_path(value)
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)
else:
self._settings.set(key, value)
@@ -356,7 +430,9 @@ class UsageStatsHandler:
data = await request.json()
prompt_id = data.get("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()
await usage_stats.process_execution(prompt_id)
return web.json_response({"success": True})
@@ -387,18 +463,24 @@ class LoraCodeHandler:
mode = data.get("mode", "append")
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 = []
if node_ids is None:
try:
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})
except Exception as exc: # pragma: no cover - defensive logging
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:
for entry in node_ids:
node_identifier = entry
@@ -471,11 +553,19 @@ class TrainedWordsHandler:
try:
file_path = request.query.get("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):
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"):
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)
return web.json_response(
@@ -495,10 +585,15 @@ class ModelExampleFilesHandler:
try:
model_path = request.query.get("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)
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]
files = []
@@ -510,7 +605,10 @@ class ModelExampleFilesHandler:
if not os.path.isfile(file_full_path):
continue
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
try:
index = int(file[len(pattern) :].split(".")[0])
@@ -545,7 +643,13 @@ class ServiceRegistryAdapter:
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._metadata_provider_factory = metadata_provider_factory
@@ -554,11 +658,17 @@ class ModelLibraryHandler:
model_id_str = request.query.get("modelId")
model_version_id_str = request.query.get("modelVersionId")
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:
model_id = int(model_id_str)
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()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
@@ -568,29 +678,55 @@ class ModelLibraryHandler:
try:
model_version_id = int(model_version_id_str)
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
model_type = None
if await lora_scanner.check_model_version_exists(model_version_id):
exists = True
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
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
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)
checkpoint_versions = []
embedding_versions = []
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:
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
versions = []
@@ -604,7 +740,9 @@ class ModelLibraryHandler:
model_type = "embedding"
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
logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -613,22 +751,35 @@ class ModelLibraryHandler:
try:
model_id_str = request.query.get("modelId")
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:
model_id = int(model_id_str)
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()
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:
response = await metadata_provider.get_model_versions(model_id)
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"):
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", [])
model_name = response.get("name", "")
@@ -646,10 +797,22 @@ class ModelLibraryHandler:
scanner = await self._service_registry.get_embedding_scanner()
normalized_type = "embedding"
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:
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_version_ids = {version["versionId"] for version in local_versions}
@@ -661,7 +824,9 @@ class ModelLibraryHandler:
{
"id": version_id,
"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,
}
)
@@ -683,19 +848,34 @@ class ModelLibraryHandler:
try:
username = request.query.get("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()
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:
models = await metadata_provider.get_user_models(username)
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:
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):
models = []
@@ -704,7 +884,9 @@ class ModelLibraryHandler:
checkpoint_scanner = await self._service_registry.get_checkpoint_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}
type_scanner_map: Dict[str, object | None] = {
@@ -724,7 +906,13 @@ class ModelLibraryHandler:
scanner = type_scanner_map.get(model_type)
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 = tags_value if isinstance(tags_value, list) else []
@@ -759,7 +947,9 @@ class ModelLibraryHandler:
rewritten_url, _ = rewrite_preview_url(raw_url, media_type)
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(
{
@@ -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
logger.error("Failed to get Civitai user models: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -785,9 +977,13 @@ class MetadataArchiveHandler:
def __init__(
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,
metadata_provider_updater: Callable[[], Awaitable[None]] = update_metadata_providers,
metadata_provider_updater: Callable[
[], Awaitable[None]
] = update_metadata_providers,
) -> None:
self._metadata_archive_manager_factory = metadata_archive_manager_factory
self._settings = settings_service or get_settings_manager()
@@ -799,18 +995,37 @@ class MetadataArchiveHandler:
download_id = request.query.get("download_id")
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:
asyncio.create_task(ws_manager.broadcast_download_progress(download_id, data))
asyncio.create_task(
ws_manager.broadcast_download_progress(download_id, data)
)
else:
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:
self._settings.set("enable_metadata_archive_db", True)
await self._metadata_provider_updater()
return web.json_response({"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)
return web.json_response(
{
"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
logger.error("Error downloading metadata archive: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -822,8 +1037,19 @@ class MetadataArchiveHandler:
if success:
self._settings.set("enable_metadata_archive_db", False)
await self._metadata_provider_updater()
return web.json_response({"success": True, "message": "Metadata archive database removed successfully"})
return web.json_response({"success": False, "error": "Failed to remove metadata archive database"}, status=500)
return web.json_response(
{
"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
logger.error("Error removing metadata archive: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -844,39 +1070,136 @@ class MetadataArchiveHandler:
"isAvailable": is_available,
"isEnabled": is_enabled,
"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
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)
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:
try:
data = await request.json()
file_path = data.get("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)
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":
subprocess.Popen(["explorer", "/select,", file_path])
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])
else:
folder = os.path.dirname(file_path)
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
logger.error("Failed to open file location: %s", exc, exc_info=True)
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:
def __init__(
@@ -895,21 +1218,44 @@ class NodeRegistryHandler:
data = await request.json()
nodes = data.get("nodes", [])
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):
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")
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")
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")
try:
node["node_id"] = int(node_id)
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)
if graph_name is None:
node["graph_name"] = None
@@ -919,7 +1265,12 @@ class NodeRegistryHandler:
node["graph_name"] = str(graph_name)
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
logger.error("Failed to register nodes: %s", exc, exc_info=True)
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})
except Exception as exc: # pragma: no cover - defensive logging
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:
try:
@@ -977,10 +1331,15 @@ class NodeRegistryHandler:
node_ids = data.get("node_ids")
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:
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:
return web.json_response(
@@ -1080,7 +1439,9 @@ class MiscHandlerSet:
self.metadata_archive = metadata_archive
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 {
"health_check": self.health.health_check,
"get_settings": self.settings.get_settings,
@@ -1103,6 +1464,7 @@ class MiscHandlerSet:
"get_metadata_archive_status": self.metadata_archive.get_metadata_archive_status,
"get_model_versions_status": self.model_library.get_model_versions_status,
"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

@@ -41,9 +41,10 @@ class PreviewHandler:
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
resolved_str = str(resolved)
if not self._config.is_preview_path_allowed(resolved_str):
logger.debug("Rejected preview outside allowed roots: %s", resolved_str)
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
# TODO: Temporarily disabled path validation due to issues #772 and #774
# Re-enable after fixing preview root path handling
# if not self._config.is_preview_path_allowed(resolved_str):
# raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
if not resolved.is_file():
logger.debug("Preview file not found at %s", resolved_str)

View File

@@ -5,6 +5,7 @@ import json
import logging
import os
import re
import asyncio
import tempfile
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
@@ -23,6 +24,11 @@ from ...services.recipes import (
RecipeValidationError,
)
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
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
@@ -55,16 +61,26 @@ class RecipeHandlerSet:
"delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags,
"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,
"download_shared_recipe": self.sharing.download_shared_recipe,
"get_recipe_syntax": self.query.get_recipe_syntax,
"update_recipe": self.management.update_recipe,
"reconnect_lora": self.management.reconnect_lora,
"find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
"bulk_delete": self.management.bulk_delete,
"save_recipe_from_widget": self.management.save_recipe_from_widget,
"get_recipes_for_lora": self.query.get_recipes_for_lora,
"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"))
sort_by = request.query.get("sort_by", "date")
search = request.query.get("search")
folder = request.query.get("folder")
recursive = request.query.get("recursive", "true").lower() == "true"
search_options = {
"title": request.query.get("search_title", "true").lower() == "true",
"tags": request.query.get("search_tags", "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",
"prompt": request.query.get("search_prompt", "true").lower() == "true",
}
filters: Dict[str, Any] = {}
@@ -161,6 +180,9 @@ class RecipeListingHandler:
if base_models:
filters["base_model"] = base_models.split(",")
if request.query.get("favorite", "false").lower() == "true":
filters["favorite"] = True
tag_filters: Dict[str, str] = {}
legacy_tags = request.query.get("tags")
if legacy_tags:
@@ -192,6 +214,8 @@ class RecipeListingHandler:
filters=filters,
search_options=search_options,
lora_hash=lora_hash,
folder=folder,
recursive=recursive,
)
for item in result.get("items", []):
@@ -298,6 +322,58 @@ class RecipeQueryHandler:
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
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:
try:
await self._ensure_dependencies_ready()
@@ -410,6 +486,7 @@ class RecipeManagementHandler:
analysis_service: RecipeAnalysisService,
downloader_factory,
civitai_client_getter: CivitaiClientGetter,
ws_manager=default_ws_manager,
) -> None:
self._ensure_dependencies_ready = ensure_dependencies_ready
self._recipe_scanner_getter = recipe_scanner_getter
@@ -418,6 +495,7 @@ class RecipeManagementHandler:
self._analysis_service = analysis_service
self._downloader_factory = downloader_factory
self._civitai_client_getter = civitai_client_getter
self._ws_manager = ws_manager
async def save_recipe(self, request: web.Request) -> web.Response:
try:
@@ -436,6 +514,7 @@ class RecipeManagementHandler:
name=payload["name"],
tags=payload["tags"],
metadata=payload["metadata"],
extension=payload.get("extension"),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
@@ -444,17 +523,101 @@ class RecipeManagementHandler:
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
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:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
# 1. Parse Parameters
params = request.rel_url.query
image_url = params.get("image_url")
name = params.get("name")
resources_raw = params.get("resources")
if not image_url:
raise RecipeValidationError("Missing required field: image_url")
if not name:
@@ -463,27 +626,93 @@ class RecipeManagementHandler:
raise RecipeValidationError("Missing required field: resources")
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] = {
"base_model": params.get("base_model", "") or "",
"loras": lora_entries,
"gen_params": gen_params_request or {},
"source_url": image_url
}
source_path = params.get("source_path")
if source_path:
metadata["source_path"] = source_path
if gen_params is not None:
metadata["gen_params"] = gen_params
# Checkpoint handling
if checkpoint_entry:
metadata["checkpoint"] = checkpoint_entry
gen_params_ref = metadata.setdefault("gen_params", {})
if "checkpoint" not in gen_params_ref:
gen_params_ref["checkpoint"] = checkpoint_entry
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
# Ensure checkpoint is also in gen_params for consistency if needed by enricher?
# Actually enricher looks at metadata['checkpoint'], so this is fine.
# Try to resolve base model from checkpoint if not explicitly provided
if not metadata["base_model"]:
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"))
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(
recipe_scanner=recipe_scanner,
@@ -492,6 +721,7 @@ class RecipeManagementHandler:
name=name,
tags=tags,
metadata=metadata,
extension=extension,
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
@@ -541,6 +771,64 @@ class RecipeManagementHandler:
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
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:
try:
await self._ensure_dependencies_ready()
@@ -622,6 +910,7 @@ class RecipeManagementHandler:
name: Optional[str] = None
tags: list[str] = []
metadata: Optional[Dict[str, Any]] = None
extension: Optional[str] = None
while True:
field = await reader.next()
@@ -652,6 +941,8 @@ class RecipeManagementHandler:
metadata = json.loads(metadata_text)
except Exception:
metadata = {}
elif field.name == "extension":
extension = await field.text()
return {
"image_bytes": image_bytes,
@@ -659,6 +950,7 @@ class RecipeManagementHandler:
"name": name,
"tags": tags,
"metadata": metadata,
"extension": extension,
}
def _parse_tags(self, tag_text: Optional[str]) -> list[str]:
@@ -729,7 +1021,7 @@ class RecipeManagementHandler:
"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()
downloader = await self._downloader_factory()
temp_path = None
@@ -744,15 +1036,31 @@ class RecipeManagementHandler:
image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info:
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")
# 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)
if not success:
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:
return file_obj.read()
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
except RecipeDownloadError:
raise
except RecipeValidationError:
@@ -766,6 +1074,7 @@ class RecipeManagementHandler:
except FileNotFoundError:
pass
def _safe_int(self, value: Any) -> int:
try:
return int(value)

View File

@@ -12,14 +12,15 @@ from ..utils.utils import get_lora_info
logger = logging.getLogger(__name__)
class LoraRoutes(BaseModelRoutes):
"""LoRA-specific route controller"""
def __init__(self):
"""Initialize LoRA routes with LoRA service"""
super().__init__()
self.template_name = "loras.html"
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
lora_scanner = await ServiceRegistry.get_lora_scanner()
@@ -29,207 +30,276 @@ class LoraRoutes(BaseModelRoutes):
# Attach service dependencies
self.attach_service(self.service)
def setup_routes(self, app: web.Application):
"""Setup LoRA routes"""
# Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services())
# 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):
"""Setup LoRA-specific routes"""
# LoRA-specific query routes
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/letter-counts', prefix, self.get_letter_counts)
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)
registrar.add_prefixed_route(
"GET", "/api/lm/{prefix}/letter-counts", prefix, self.get_letter_counts
)
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
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:
"""Parse LoRA-specific parameters"""
params = {}
# LoRA-specific parameters
if 'first_letter' in request.query:
params['first_letter'] = request.query.get('first_letter')
if "first_letter" in request.query:
params["first_letter"] = request.query.get("first_letter")
# Handle fuzzy search parameter name variation
if request.query.get('fuzzy') == 'true':
params['fuzzy_search'] = True
if request.query.get("fuzzy") == "true":
params["fuzzy_search"] = True
# Handle additional filter parameters for LoRAs
if 'lora_hash' in request.query:
if not params.get('hash_filters'):
params['hash_filters'] = {}
params['hash_filters']['single_hash'] = request.query['lora_hash'].lower()
elif 'lora_hashes' in request.query:
if not params.get('hash_filters'):
params['hash_filters'] = {}
params['hash_filters']['multiple_hashes'] = [h.lower() for h in request.query['lora_hashes'].split(',')]
if "lora_hash" in request.query:
if not params.get("hash_filters"):
params["hash_filters"] = {}
params["hash_filters"]["single_hash"] = request.query["lora_hash"].lower()
elif "lora_hashes" in request.query:
if not params.get("hash_filters"):
params["hash_filters"] = {}
params["hash_filters"]["multiple_hashes"] = [
h.lower() for h in request.query["lora_hashes"].split(",")
]
return params
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for LoRA"""
from ..utils.constants import VALID_LORA_TYPES
return model_type.lower() in VALID_LORA_TYPES
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
return "LORA, LoCon, or DORA"
# LoRA-specific route handlers
async def get_letter_counts(self, request: web.Request) -> web.Response:
"""Get count of LoRAs for each letter of the alphabet"""
try:
letter_counts = await self.service.get_letter_counts()
return web.json_response({
'success': True,
'letter_counts': letter_counts
})
return web.json_response({"success": True, "letter_counts": letter_counts})
except Exception as e:
logger.error(f"Error getting letter counts: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_notes(self, request: web.Request) -> web.Response:
"""Get notes for a specific LoRA file"""
try:
lora_name = request.query.get('name')
lora_name = request.query.get("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)
if notes is not None:
return web.json_response({
'success': True,
'notes': notes
})
return web.json_response({"success": True, "notes": notes})
else:
return web.json_response({
'success': False,
'error': 'LoRA not found in cache'
}, status=404)
return web.json_response(
{"success": False, "error": "LoRA not found in cache"}, status=404
)
except Exception as e:
logger.error(f"Error getting lora notes: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for a specific LoRA file"""
try:
lora_name = request.query.get('name')
lora_name = request.query.get("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)
return web.json_response({
'success': True,
'trigger_words': trigger_words
})
return web.json_response({"success": True, "trigger_words": trigger_words})
except Exception as e:
logger.error(f"Error getting lora trigger words: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response:
"""Get usage tips for a LoRA by its relative path"""
try:
relative_path = request.query.get('relative_path')
relative_path = request.query.get("relative_path")
if not relative_path:
return web.Response(text='Relative path is required', status=400)
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(relative_path)
return web.json_response({
'success': True,
'usage_tips': usage_tips or ''
})
return web.Response(text="Relative path is required", status=400)
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(
relative_path
)
return web.json_response({"success": True, "usage_tips": usage_tips or ""})
except Exception as e:
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
"""Get the static preview URL for a LoRA file"""
try:
lora_name = request.query.get('name')
lora_name = request.query.get("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)
if preview_url:
return web.json_response({
'success': True,
'preview_url': preview_url
})
return web.json_response({"success": True, "preview_url": preview_url})
else:
return web.json_response({
'success': False,
'error': 'No preview URL found for the specified lora'
}, status=404)
return web.json_response(
{
"success": False,
"error": "No preview URL found for the specified lora",
},
status=404,
)
except Exception as e:
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_lora_civitai_url(self, request: web.Request) -> web.Response:
"""Get the Civitai URL for a LoRA file"""
try:
lora_name = request.query.get('name')
lora_name = request.query.get("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)
if result['civitai_url']:
return web.json_response({
'success': True,
**result
})
if result["civitai_url"]:
return web.json_response({"success": True, **result})
else:
return web.json_response({
'success': False,
'error': 'No Civitai data found for the specified lora'
}, status=404)
return web.json_response(
{
"success": False,
"error": "No Civitai data found for the specified lora",
},
status=404,
)
except Exception as e:
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return web.json_response({"success": False, "error": str(e)}, status=500)
async def get_random_loras(self, request: web.Request) -> web.Response:
"""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:
"""Get trigger words for specified LoRA models"""
try:
json_data = await request.json()
lora_names = json_data.get("lora_names", [])
node_ids = json_data.get("node_ids", [])
all_trigger_words = []
for lora_name in lora_names:
_, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(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
for entry in node_ids:
node_identifier = entry
@@ -243,21 +313,15 @@ class LoraRoutes(BaseModelRoutes):
except (TypeError, ValueError):
parsed_node_id = node_identifier
payload = {
"id": parsed_node_id,
"message": trigger_words_text
}
payload = {"id": parsed_node_id, "message": trigger_words_text}
if graph_identifier is not None:
payload["graph_id"] = str(graph_identifier)
PromptServer.instance.send_sync("trigger_word_update", payload)
return web.json_response({"success": True})
except Exception as e:
logger.error(f"Error getting trigger words: {e}")
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
return web.json_response({"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("GET", "/api/lm/metadata-archive-status", "get_metadata_archive_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,
metadata_provider_updater=self._metadata_provider_updater,
)
filesystem = FileSystemHandler()
filesystem = FileSystemHandler(settings_service=self._settings)
node_registry_handler = NodeRegistryHandler(
node_registry=self._node_registry,
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/resume-download", "resume_download_get"),
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"),
)

View File

@@ -27,16 +27,26 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
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/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/download", "download_shared_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
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("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
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/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
import logging
import os
import time
from ..utils.constants import VALID_LORA_TYPES
from ..utils.models import BaseModelMetadata
from ..utils.metadata_manager import MetadataManager
from ..utils.usage_stats import UsageStats
from .model_query import (
FilterCriteria,
ModelCacheRepository,
@@ -23,9 +25,10 @@ logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from .model_update_service import ModelUpdateService
class BaseModelService(ABC):
"""Base service class for all model types"""
def __init__(
self,
model_type: str,
@@ -58,13 +61,15 @@ class BaseModelService(ABC):
self.filter_set = filter_set or ModelFilterSet(self.settings)
self.search_strategy = search_strategy or SearchStrategy()
self.update_service = update_service
async def get_paginated_data(
self,
page: int,
page_size: int,
sort_by: str = 'name',
sort_by: str = "name",
folder: str = None,
folder_include: list = None,
folder_exclude: list = None,
search: str = None,
fuzzy_search: bool = False,
base_models: list = None,
@@ -79,16 +84,26 @@ class BaseModelService(ABC):
**kwargs,
) -> Dict:
"""Get paginated and filtered model data"""
overall_start = time.perf_counter()
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:
filtered_data = await self._apply_hash_filters(sorted_data, hash_filters)
else:
filtered_data = await self._apply_common_filters(
sorted_data,
folder=folder,
folder_include=folder_include,
folder_exclude=folder_exclude,
base_models=base_models,
model_types=model_types,
tags=tags,
@@ -108,57 +123,119 @@ class BaseModelService(ABC):
# Apply license-based filters
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:
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
t2 = time.perf_counter()
if update_available_only:
annotated_for_filter = await self._annotate_update_flags(filtered_data)
filtered_data = [
item for item in annotated_for_filter
if item.get('update_available')
item for item in annotated_for_filter 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)
pagination_duration = time.perf_counter() - t3
t4 = time.perf_counter()
if update_available_only:
# Items already include update flags thanks to the pre-filter annotation.
paginated['items'] = list(paginated['items'])
paginated["items"] = list(paginated["items"])
else:
paginated['items'] = await self._annotate_update_flags(
paginated['items'],
paginated["items"] = await self._annotate_update_flags(
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
async def _apply_hash_filters(self, data: List[Dict], hash_filters: Dict) -> List[Dict]:
async def _fetch_with_usage_sort(self, sort_params):
"""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"""
single_hash = hash_filters.get('single_hash')
multiple_hashes = hash_filters.get('multiple_hashes')
single_hash = hash_filters.get("single_hash")
multiple_hashes = hash_filters.get("multiple_hashes")
if single_hash:
# Filter by single hash
single_hash = single_hash.lower()
return [
item for item in data
if item.get('sha256', '').lower() == single_hash
item for item in data if item.get("sha256", "").lower() == single_hash
]
elif multiple_hashes:
# Filter by multiple hashes
hash_set = set(hash.lower() for hash in multiple_hashes)
return [
item for item in data
if item.get('sha256', '').lower() in hash_set
]
return [item for item in data if item.get("sha256", "").lower() in hash_set]
return data
async def _apply_common_filters(
self,
data: List[Dict],
folder: str = None,
folder_include: list = None,
folder_exclude: list = None,
base_models: list = None,
model_types: list = None,
tags: Optional[Dict[str, str]] = None,
@@ -169,6 +246,8 @@ class BaseModelService(ABC):
normalized_options = self.search_strategy.normalize_options(search_options)
criteria = FilterCriteria(
folder=folder,
folder_include=folder_include,
folder_exclude=folder_exclude,
base_models=base_models,
model_types=model_types,
tags=tags,
@@ -176,7 +255,7 @@ class BaseModelService(ABC):
search_options=normalized_options,
)
return self.filter_set.apply(data, criteria)
async def _apply_search_filters(
self,
data: List[Dict],
@@ -186,28 +265,34 @@ class BaseModelService(ABC):
) -> List[Dict]:
"""Apply search filtering"""
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]:
"""Apply model-specific filters - to be overridden by subclasses if needed"""
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.
Args:
data: List of model data items
credit_required:
credit_required:
- True: Return items where credit is required (allowNoCredit=False)
- False: Return items where credit is not required (allowNoCredit=True)
"""
filtered_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)
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 False, we want items where allowNoCredit is True (no credit required)
if credit_required:
@@ -216,26 +301,30 @@ class BaseModelService(ABC):
else:
if allow_no_credit: # Credit is not required
filtered_data.append(item)
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.
Args:
data: List of model data items
allow_selling:
allow_selling:
- 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)
"""
filtered_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
# Bit 1 specifically represents Image permission (allowCommercialUse contains Image)
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 False, we want items where Image permission is not granted
if allow_selling:
@@ -244,7 +333,7 @@ class BaseModelService(ABC):
else:
if not has_image_permission: # Selling generated content is not allowed
filtered_data.append(item)
return filtered_data
async def _annotate_update_flags(
@@ -262,7 +351,7 @@ class BaseModelService(ABC):
if self.update_service is None:
for item in annotated:
item['update_available'] = False
item["update_available"] = False
return annotated
id_to_items: Dict[int, List[Dict]] = {}
@@ -270,7 +359,7 @@ class BaseModelService(ABC):
for item in annotated:
model_id = self._extract_model_id(item)
if model_id is None:
item['update_available'] = False
item["update_available"] = False
continue
if model_id not in id_to_items:
id_to_items[model_id] = []
@@ -346,13 +435,19 @@ class BaseModelService(ABC):
default_flag = bool(resolved.get(model_id, False)) if resolved else False
record = records.get(model_id) if records else None
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:
if same_base_mode and record is not None:
base_model = self._extract_base_model(item)
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:
threshold_version = self._extract_version_id(item)
flag = record.has_update_for_base(
@@ -361,17 +456,17 @@ class BaseModelService(ABC):
)
else:
flag = default_flag
item['update_available'] = flag
item["update_available"] = flag
return annotated
@staticmethod
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):
return None
try:
value = civitai.get('modelId')
value = civitai.get("modelId")
if value is None:
return None
return int(value)
@@ -380,10 +475,10 @@ class BaseModelService(ABC):
@staticmethod
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):
return None
value = civitai.get('id')
value = civitai.get("id")
if value is None:
return None
try:
@@ -393,7 +488,7 @@ class BaseModelService(ABC):
@staticmethod
def _extract_base_model(item: Dict) -> Optional[str]:
value = item.get('base_model')
value = item.get("base_model")
if value is None:
return None
if isinstance(value, str):
@@ -430,7 +525,9 @@ class BaseModelService(ABC):
for version in getattr(record, "versions", []):
if not getattr(version, "is_in_library", False):
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:
continue
version_id = getattr(version, "version_id", None)
@@ -447,25 +544,25 @@ class BaseModelService(ABC):
total_items = len(data)
start_idx = (page - 1) * page_size
end_idx = min(start_idx + page_size, total_items)
return {
'items': data[start_idx:end_idx],
'total': total_items,
'page': page,
'page_size': page_size,
'total_pages': (total_items + page_size - 1) // page_size
"items": data[start_idx:end_idx],
"total": total_items,
"page": page,
"page_size": page_size,
"total_pages": (total_items + page_size - 1) // page_size,
}
@abstractmethod
async def format_response(self, model_data: Dict) -> Dict:
"""Format model data for API response - must be implemented by subclasses"""
pass
# Common service methods that delegate to scanner
async def get_top_tags(self, limit: int = 20) -> List[Dict]:
"""Get top tags sorted by frequency"""
return await self.scanner.get_top_tags(limit)
async def get_base_models(self, limit: int = 20) -> List[Dict]:
"""Get base models sorted by frequency"""
return await self.scanner.get_base_models(limit)
@@ -476,62 +573,85 @@ class BaseModelService(ABC):
type_counts: Dict[str, int] = {}
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:
continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
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"],
reverse=True,
)
return sorted_types[:limit]
def has_hash(self, sha256: str) -> bool:
"""Check if a model with given hash exists"""
return self.scanner.has_hash(sha256)
def get_path_by_hash(self, sha256: str) -> Optional[str]:
"""Get file path for a model by its hash"""
return self.scanner.get_path_by_hash(sha256)
def get_hash_by_path(self, file_path: str) -> Optional[str]:
"""Get hash for a model by its 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"""
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):
"""Get model information by name"""
return await self.scanner.get_model_info_by_name(name)
def get_model_roots(self) -> List[str]:
"""Get model root directories"""
return self.scanner.get_model_roots()
def filter_civitai_data(self, data: Dict, minimal: bool = False) -> Dict:
"""Filter relevant fields from CivitAI data"""
if not data:
return {}
fields = ["id", "modelId", "name", "trainedWords"] if minimal else [
"id", "modelId", "name", "createdAt", "updatedAt",
"publishedAt", "trainedWords", "baseModel", "description",
"model", "images", "customImages", "creator"
]
fields = (
["id", "modelId", "name", "trainedWords"]
if minimal
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}
async def get_folder_tree(self, model_root: str) -> Dict:
"""Get hierarchical folder tree for a specific model root"""
cache = await self.scanner.get_cached_data()
# Build tree structure from folders
tree = {}
for folder in cache.folders:
# Check if this folder belongs to the specified model root
folder_belongs_to_root = False
@@ -539,95 +659,96 @@ class BaseModelService(ABC):
if root == model_root:
folder_belongs_to_root = True
break
if not folder_belongs_to_root:
continue
# Split folder path into components
parts = folder.split('/') if folder else []
parts = folder.split("/") if folder else []
current_level = tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return tree
async def get_unified_folder_tree(self) -> Dict:
"""Get unified folder tree across all model roots"""
cache = await self.scanner.get_cached_data()
# Build unified tree structure by analyzing all relative paths
unified_tree = {}
# Get all model roots for path normalization
model_roots = self.scanner.get_model_roots()
for folder in cache.folders:
if not folder: # Skip empty folders
continue
# 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
relative_path = folder
# Split folder path into components
parts = relative_path.split('/')
parts = relative_path.split("/")
current_level = unified_tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return unified_tree
async def get_model_notes(self, model_name: str) -> Optional[str]:
"""Get notes for a specific model file"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model['file_name'] == model_name:
return model.get('notes', '')
if model["file_name"] == model_name:
return model.get("notes", "")
return None
async def get_model_preview_url(self, model_name: str) -> Optional[str]:
"""Get the static preview URL for a model file"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model['file_name'] == model_name:
preview_url = model.get('preview_url')
if model["file_name"] == model_name:
preview_url = model.get("preview_url")
if preview_url:
from ..config import config
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]]:
"""Get the Civitai URL for a model file"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model['file_name'] == model_name:
civitai_data = model.get('civitai', {})
model_id = civitai_data.get('modelId')
version_id = civitai_data.get('id')
if model["file_name"] == model_name:
civitai_data = model.get("civitai", {})
model_id = civitai_data.get("modelId")
version_id = civitai_data.get("id")
if model_id:
civitai_url = f"https://civitai.com/models/{model_id}"
if version_id:
civitai_url += f"?modelVersionId={version_id}"
return {
'civitai_url': civitai_url,
'model_id': str(model_id),
'version_id': str(version_id) if version_id else None
"civitai_url": civitai_url,
"model_id": str(model_id),
"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]:
"""Load full metadata for a single model.
@@ -635,18 +756,21 @@ class BaseModelService(ABC):
Listing/search endpoints return lightweight cache entries; this method performs
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:
return None
return self.filter_civitai_data(metadata.to_dict().get("civitai", {}))
async def get_model_description(self, file_path: str) -> Optional[str]:
"""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:
return None
return metadata.modelDescription or ''
return metadata.modelDescription or ""
@staticmethod
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:
"""Sort paths by how well they satisfy the include tokens."""
path_lower = relative_path.lower()
prefix_hits = sum(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]
prefix_hits = sum(
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
return (-prefix_hits, first_match_index, len(relative_path), path_lower)
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
async def search_relative_paths(
self, search_term: str, limit: int = 15
) -> List[str]:
"""Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data()
include_terms, exclude_terms = self._parse_search_tokens(search_term)
matching_paths = []
# Get model roots for path calculation
model_roots = self.scanner.get_model_roots()
for model in cache.raw_data:
file_path = model.get('file_path', '')
file_path = model.get("file_path", "")
if not file_path:
continue
# Calculate relative path from model root
relative_path = None
for root in model_roots:
# Normalize paths for comparison
normalized_root = os.path.normpath(root)
normalized_file = os.path.normpath(file_path)
if normalized_file.startswith(normalized_root):
# 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
if not relative_path:
continue
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)
if len(matching_paths) >= limit * 2: # Get more for better sorting
break
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
)
return matching_paths[:limit]

View File

@@ -35,6 +35,7 @@ class CheckpointService(BaseModelService):
"modified": checkpoint_data.get("modified", ""),
"tags": checkpoint_data.get("tags", []),
"from_civitai": checkpoint_data.get("from_civitai", True),
"usage_count": checkpoint_data.get("usage_count", 0),
"notes": checkpoint_data.get("notes", ""),
"model_type": checkpoint_data.get("model_type", "checkpoint"),
"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_created_at = None
self._proxy_url = None # Store proxy URL for current session
self._session_lock = asyncio.Lock()
# Configuration
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better throughput
@@ -148,7 +149,10 @@ class Downloader:
async def session(self) -> aiohttp.ClientSession:
"""Get or create the global aiohttp session with optimized settings"""
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
@property
@@ -197,10 +201,18 @@ class Downloader:
return False
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
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
proxy_url = None
@@ -808,7 +820,8 @@ class Downloader:
async def refresh_session(self):
"""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")
@staticmethod

View File

@@ -35,6 +35,7 @@ class EmbeddingService(BaseModelService):
"modified": embedding_data.get("modified", ""),
"tags": embedding_data.get("tags", []),
"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", ""),
"model_type": embedding_data.get("model_type", "embedding"),
"favorite": embedding_data.get("favorite", False),

View File

@@ -8,24 +8,27 @@ from ..config import config
logger = logging.getLogger(__name__)
class LoraService(BaseModelService):
"""LoRA-specific service implementation"""
def __init__(self, scanner, update_service=None):
"""Initialize LoRA service
Args:
scanner: LoRA scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("lora", scanner, LoraMetadata, update_service=update_service)
async def format_response(self, lora_data: Dict) -> Dict:
"""Format LoRA data for API response"""
return {
"model_name": lora_data["model_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),
"base_model": lora_data.get("base_model", ""),
"folder": lora_data["folder"],
@@ -35,149 +38,444 @@ class LoraService(BaseModelService):
"modified": lora_data.get("modified", ""),
"tags": lora_data.get("tags", []),
"from_civitai": lora_data.get("from_civitai", True),
"usage_count": lora_data.get("usage_count", 0),
"usage_tips": lora_data.get("usage_tips", ""),
"notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", 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]:
"""Apply LoRA-specific filters"""
# Handle first_letter filter for LoRAs
first_letter = kwargs.get('first_letter')
first_letter = kwargs.get("first_letter")
if first_letter:
data = self._filter_by_first_letter(data, first_letter)
return data
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
"""Filter data by first letter of model name
Special handling:
- '#': Numbers (0-9)
- '@': Special characters (not alphanumeric)
- '': CJK characters
"""
filtered_data = []
for lora in data:
model_name = lora.get('model_name', '')
model_name = lora.get("model_name", "")
if not model_name:
continue
first_char = model_name[0].upper()
if letter == '#' and first_char.isdigit():
if letter == "#" and first_char.isdigit():
filtered_data.append(lora)
elif letter == '@' and not first_char.isalnum():
elif letter == "@" and not first_char.isalnum():
# Special characters (not alphanumeric)
filtered_data.append(lora)
elif letter == '' and self._is_cjk_character(first_char):
elif letter == "" and self._is_cjk_character(first_char):
# CJK characters
filtered_data.append(lora)
elif letter.upper() == first_char:
# Regular alphabet matching
filtered_data.append(lora)
return filtered_data
def _is_cjk_character(self, char: str) -> bool:
"""Check if character is a CJK character"""
# Define Unicode ranges for CJK characters
cjk_ranges = [
(0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
(0x3300, 0x33FF), # CJK Compatibility
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
(0x3100, 0x312F), # Bopomofo
(0x31A0, 0x31BF), # Bopomofo Extended
(0x3040, 0x309F), # Hiragana
(0x30A0, 0x30FF), # Katakana
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
(0xAC00, 0xD7AF), # Hangul Syllables
(0x1100, 0x11FF), # Hangul Jamo
(0xA960, 0xA97F), # Hangul Jamo Extended-A
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
(0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
(0x3300, 0x33FF), # CJK Compatibility
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
(0x3100, 0x312F), # Bopomofo
(0x31A0, 0x31BF), # Bopomofo Extended
(0x3040, 0x309F), # Hiragana
(0x30A0, 0x30FF), # Katakana
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
(0xAC00, 0xD7AF), # Hangul Syllables
(0x1100, 0x11FF), # Hangul Jamo
(0xA960, 0xA97F), # Hangul Jamo Extended-A
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
]
code_point = ord(char)
return any(start <= code_point <= end for start, end in cjk_ranges)
# LoRA-specific methods
async def get_letter_counts(self) -> Dict[str, int]:
"""Get count of LoRAs for each letter of the alphabet"""
cache = await self.scanner.get_cached_data()
data = cache.raw_data
# Define letter categories
letters = {
'#': 0, # Numbers
'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0, '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
"#": 0, # Numbers
"A": 0,
"B": 0,
"C": 0,
"D": 0,
"E": 0,
"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
for lora in data:
model_name = lora.get('model_name', '')
model_name = lora.get("model_name", "")
if not model_name:
continue
first_char = model_name[0].upper()
if first_char.isdigit():
letters['#'] += 1
letters["#"] += 1
elif first_char in letters:
letters[first_char] += 1
elif self._is_cjk_character(first_char):
letters[''] += 1
letters[""] += 1
elif not first_char.isalnum():
letters['@'] += 1
letters["@"] += 1
return letters
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
"""Get trigger words for a specific LoRA file"""
cache = await self.scanner.get_cached_data()
for lora in cache.raw_data:
if lora['file_name'] == lora_name:
civitai_data = lora.get('civitai', {})
return civitai_data.get('trainedWords', [])
if lora["file_name"] == lora_name:
civitai_data = lora.get("civitai", {})
return civitai_data.get("trainedWords", [])
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"""
cache = await self.scanner.get_cached_data()
for lora in cache.raw_data:
file_path = lora.get('file_path', '')
file_path = lora.get("file_path", "")
if file_path:
# Convert to forward slashes and extract relative path
file_path_normalized = file_path.replace('\\', '/')
relative_path = relative_path.replace('\\', '/')
file_path_normalized = file_path.replace("\\", "/")
relative_path = relative_path.replace("\\", "/")
# 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:
return lora.get('usage_tips', '')
if (
file_path_normalized.endswith(relative_path)
or relative_path in file_path_normalized
):
return lora.get("usage_tips", "")
return None
def find_duplicate_hashes(self) -> Dict:
"""Find LoRAs with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict:
"""Find LoRAs with conflicting 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,
seed: Optional[int] = None,
) -> 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
seed: Optional random seed for reproducible/unique randomization per execution
Returns:
List of LoRA dicts with randomized strengths
"""
import random
import json
# Use a local Random instance to avoid affecting global random state
# This ensures each execution with a different seed produces different results
rng = random.Random(seed)
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 = rng.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 = rng.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 = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max
)
model_str = round(recommended_strength * scale, 2)
else:
model_str = round(
rng.uniform(model_strength_min, model_strength_max), 2
)
else:
model_str = round(
rng.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 = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max
)
clip_str = round(recommended_clip_strength * scale, 2)
else:
clip_str = round(
rng.uniform(clip_strength_min, clip_strength_max), 2
)
else:
clip_str = round(
rng.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")
images = meta.get("images")
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(
self,
@@ -90,11 +90,11 @@ class MetadataSyncService:
existing_civitai = local_metadata.get("civitai") or {}
if (
civitai_metadata.get("source") == "archive_db"
not self.is_civitai_api_metadata(civitai_metadata)
and self.is_civitai_api_metadata(existing_civitai)
):
logger.info(
"Skip civitai update for %s (%s)",
"Skip civitai update for %s (%s) - existing metadata is higher quality",
local_metadata.get("model_name", ""),
existing_civitai.get("name", ""),
)

View File

@@ -1,4 +1,8 @@
import asyncio
import time
import logging
logger = logging.getLogger(__name__)
from typing import Any, Dict, List, Optional, Tuple
from dataclasses import dataclass, field
from operator import itemgetter
@@ -13,7 +17,10 @@ SUPPORTED_SORT_MODES = [
('date', 'desc'),
('size', 'asc'),
('size', 'desc'),
('usage', 'asc'),
('usage', 'desc'),
]
# Is this in use?
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]:
"""Sort data by sort_key and order"""
start_time = time.perf_counter()
reverse = (order == 'desc')
if sort_key == 'name':
# Natural sort by configured display name, case-insensitive
return natsorted(
result = natsorted(
data,
key=lambda x: self._get_display_name(x).lower(),
reverse=reverse
)
elif sort_key == 'date':
# Sort by modified timestamp
return sorted(
result = sorted(
data,
key=itemgetter('modified'),
reverse=reverse
)
elif sort_key == 'size':
# Sort by file size
return sorted(
result = sorted(
data,
key=itemgetter('size'),
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:
# 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]:
"""Get sorted data by sort_key and order, using cache if possible"""
async with self._lock:
if (sort_key, order) == self._last_sort:
return self._last_sorted_data
start_time = time.perf_counter()
sorted_data = self._sort_data(self.raw_data, sort_key, order)
self._last_sort = (sort_key, order)
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
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.sample_results: List[Dict[str, Any]] = []
self.is_flat_structure: bool = False
self.status: str = 'success'
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary"""
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',
'summary': {
'total': self.total,
@@ -98,6 +100,8 @@ class ModelFileService:
result = AutoOrganizeResult()
source_directories: Set[str] = set()
self.scanner.reset_cancellation()
try:
# Get all models from cache
cache = await self.scanner.get_cached_data()
@@ -186,6 +190,21 @@ class ModelFileService:
progress_callback,
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
if progress_callback:
@@ -246,9 +265,15 @@ class ModelFileService:
"""Process models in batches to avoid overwhelming the system"""
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]
for model in batch:
if self.scanner.is_cancelled():
break
await self._process_single_model(model, model_roots, result, source_directories)
result.processed += 1
@@ -446,25 +471,46 @@ class ModelFileService:
class ModelMoveService:
"""Service for handling individual model moves"""
def __init__(self, scanner):
def __init__(self, scanner, model_type: str):
"""Initialize the service
Args:
scanner: Model scanner instance
model_type: Type of model (e.g., 'lora', 'checkpoint')
"""
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
Args:
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:
Dictionary with move result
"""
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)
if os.path.normpath(source_dir) == os.path.normpath(target_path):
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 = await self.scanner.move_model(file_path, target_path)
if new_file_path:
move_result = await self.scanner.move_model(file_path, target_path)
if move_result:
new_file_path = move_result.get("new_path")
cache_entry = move_result.get("cache_entry")
return {
'success': True,
'original_file_path': file_path,
'new_file_path': new_file_path
'new_file_path': new_file_path,
'cache_entry': cache_entry
}
else:
return {
@@ -498,26 +547,32 @@ class ModelMoveService:
'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
Args:
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:
Dictionary with bulk move results
"""
try:
results = []
self.scanner.reset_cancellation()
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({
"original_file_path": file_path,
"new_file_path": result.get('new_file_path'),
"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"])

View File

@@ -1,10 +1,25 @@
from __future__ import annotations
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.utils import fuzzy_match as default_fuzzy_match
import time
import logging
logger = logging.getLogger(__name__)
DEFAULT_CIVITAI_MODEL_TYPE = "LORA"
@@ -47,8 +62,7 @@ def resolve_civitai_model_type(entry: Mapping[str, Any]) -> str:
class SettingsProvider(Protocol):
"""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)
@@ -64,6 +78,8 @@ class FilterCriteria:
"""Container for model list filtering options."""
folder: Optional[str] = None
folder_include: Optional[Sequence[str]] = None
folder_exclude: Optional[Sequence[str]] = None
base_models: Optional[Sequence[str]] = None
tags: Optional[Dict[str, str]] = None
favorites_only: bool = False
@@ -109,82 +125,222 @@ class ModelCacheRepository:
class ModelFilterSet:
"""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._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."""
overall_start = time.perf_counter()
items = list(data)
initial_count = len(items)
if self._settings.get("show_only_sfw", False):
t0 = time.perf_counter()
threshold = self._nsfw_levels.get("R", 0)
items = [
item for item in items
if not item.get("preview_nsfw_level") or item.get("preview_nsfw_level") < threshold
item
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:
t0 = time.perf_counter()
items = [item for item in items if item.get("favorite", False)]
favorites_duration = time.perf_counter() - t0
folder_duration = 0
folder = criteria.folder
folder_include = criteria.folder_include or []
folder_exclude = criteria.folder_exclude or []
options = criteria.search_options or {}
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:
t0 = time.perf_counter()
if recursive:
if folder:
folder_with_sep = f"{folder}/"
items = [
item for item in items
if item.get("folder") == folder or item.get("folder", "").startswith(folder_with_sep)
item
for item in items
if item.get("folder") == folder
or item.get("folder", "").startswith(folder_with_sep)
]
else:
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 []
if base_models:
t0 = time.perf_counter()
base_model_set = set(base_models)
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 {}
include_tags = set()
exclude_tags = set()
if isinstance(tag_filters, dict):
for tag, state in tag_filters.items():
if not tag:
continue
if state == "exclude":
exclude_tags.add(tag)
else:
include_tags.add(tag)
else:
include_tags = {tag for tag in tag_filters if tag}
if tag_filters:
t0 = time.perf_counter()
include_tags = set()
exclude_tags = set()
if isinstance(tag_filters, dict):
for tag, state in tag_filters.items():
if not tag:
continue
if state == "exclude":
exclude_tags.add(tag)
else:
include_tags.add(tag)
else:
include_tags = {tag for tag in tag_filters if tag}
if include_tags:
items = [
item for item in items
if any(tag in include_tags for tag in (item.get("tags", []) or []))
]
if include_tags:
if exclude_tags:
items = [
item for item in items
if not any(tag in exclude_tags for tag in (item.get("tags", []) or []))
]
def matches_include(item_tags):
if not item_tags and "__no_tags__" in include_tags:
return True
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 []
normalized_model_types = {
model_type for model_type in (
normalize_civitai_model_type(value) for value in model_types
)
if model_type
}
if normalized_model_types:
items = [
item for item in items
if normalize_civitai_model_type(resolve_civitai_model_type(item)) in normalized_model_types
]
if model_types:
t0 = time.perf_counter()
normalized_model_types = {
model_type
for model_type in (
normalize_civitai_model_type(value) for value in model_types
)
if model_type
}
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
@@ -199,7 +355,9 @@ class SearchStrategy:
"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
def normalize_options(self, options: Optional[Dict[str, Any]]) -> Dict[str, Any]:
@@ -238,7 +396,9 @@ class SearchStrategy:
if options.get("tags", False):
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)
continue
@@ -249,13 +409,17 @@ class SearchStrategy:
creator = civitai.get("creator")
if isinstance(creator, dict):
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)
continue
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):
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._persistent_cache = get_persistent_cache()
self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation
try:
loop = asyncio.get_running_loop()
except RuntimeError:
@@ -653,6 +654,11 @@ class ModelScanner:
self._is_initializing = True # Set flag
try:
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
# Scan for new data
scan_result = await self._gather_model_data()
@@ -678,6 +684,7 @@ class ModelScanner:
async def _reconcile_cache(self) -> None:
"""Fast cache reconciliation - only process differences between cache and filesystem"""
self.reset_cancellation()
self._is_initializing = True # Set flag for reconciliation duration
try:
start_time = time.time()
@@ -737,6 +744,9 @@ class ModelScanner:
# Yield control periodically
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
total_added = 0
@@ -784,6 +794,10 @@ class ModelScanner:
logger.error(f"Could not determine root path for {path}")
except Exception as 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)
missing_files = cached_paths - found_paths
@@ -838,6 +852,19 @@ class ModelScanner:
"""Check if the scanner is currently 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]:
"""Get model root directories"""
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.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
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:
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
else:
@@ -1030,6 +1057,8 @@ class ModelScanner:
except Exception as exc: # pragma: no cover - defensive logging
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:
nonlocal processed_files
@@ -1073,6 +1102,8 @@ class ModelScanner:
await handle_progress()
await asyncio.sleep(0)
if self.is_cancelled():
return
elif entry.is_dir(follow_symlinks=True):
await scan_recursive(entry.path, root_path, visited_paths)
except Exception as entry_error:
@@ -1080,6 +1111,9 @@ class ModelScanner:
except Exception as scan_error:
logger.error(f"Error scanning {current_path}: {scan_error}")
if self.is_cancelled():
return
for model_root in self.get_model_roots():
if not os.path.exists(model_root):
continue
@@ -1216,9 +1250,12 @@ class ModelScanner:
except Exception as 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:
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)
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"""
cache = await self.get_cached_data()
@@ -1287,6 +1324,9 @@ class ModelScanner:
file_path_override=normalized_new_path,
)
if recalculate_type:
cache_entry = self.adjust_cached_entry(cache_entry)
cache.raw_data.append(cache_entry)
cache.add_to_version_index(cache_entry)
@@ -1307,7 +1347,7 @@ class ModelScanner:
if cache_modified:
await self._persist_current_cache()
return True
return cache_entry if metadata else True
def has_hash(self, sha256: str) -> bool:
"""Check if a model with given hash exists"""
@@ -1442,6 +1482,10 @@ class ModelScanner:
deleted_models = []
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:
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
@@ -1482,6 +1526,7 @@ class ModelScanner:
return {
'success': True,
'status': 'cancelled' if self.is_cancelled() else 'success',
'total_deleted': total_deleted,
'total_attempted': len(file_paths),
'cache_updated': cache_updated,

View File

@@ -22,7 +22,6 @@ class ModelServiceFactory:
"""
cls._services[model_type] = service_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
def get_service_class(cls, model_type: str) -> Type:
@@ -80,13 +79,10 @@ class ModelServiceFactory:
Args:
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():
try:
routes_instance = cls.get_route_instance(model_type)
routes_instance.setup_routes(app)
logger.info(f"Successfully set up routes for {model_type}")
except Exception as e:
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)
# Register Embedding model type
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
logger.info("Registered default model types: lora, checkpoint, embedding")
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)

View File

@@ -466,6 +466,7 @@ class ModelUpdateService:
target_model_ids: Optional[Sequence[int]] = None,
) -> Dict[int, ModelUpdateRecord]:
"""Refresh update information for every model present in the cache."""
scanner.reset_cancellation()
normalized_targets = (
self._normalize_sequence(target_model_ids)
@@ -542,6 +543,9 @@ class ModelUpdateService:
force_refresh=force_refresh,
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:
results[model_id] = record
if index % progress_interval == 0 or index == total_models:
@@ -585,6 +589,8 @@ class ModelUpdateService:
model_type: str,
model_id: int,
version_ids: Sequence[int],
*,
version_info: Optional[Mapping] = None,
) -> ModelUpdateRecord:
"""Persist a new set of in-library version identifiers."""
@@ -596,6 +602,7 @@ class ModelUpdateService:
normalized_versions,
model_type=model_type,
model_id=model_id,
version_info=version_info,
)
self._upsert_record(record)
return record
@@ -940,6 +947,7 @@ class ModelUpdateService:
model_type: Optional[str] = None,
model_id: Optional[int] = None,
last_checked_at: Optional[float] = None,
version_info: Optional[Mapping] = None,
) -> ModelUpdateRecord:
local_set = set(normalized_local)
versions: List[ModelVersionRecord] = []
@@ -961,19 +969,26 @@ class ModelUpdateService:
seen_ids = {version.version_id for version in versions}
for missing_id in sorted(local_set - seen_ids):
versions.append(
ModelVersionRecord(
version_id=missing_id,
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),
new_version: Optional[ModelVersionRecord] = None
if version_info and _normalize_int(version_info.get("id")) == missing_id:
new_version = self._extract_single_version(version_info, index=len(versions))
if new_version:
versions.append(replace(new_version, is_in_library=True))
else:
versions.append(
ModelVersionRecord(
version_id=missing_id,
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(
model_type=model_type,
@@ -1079,33 +1094,45 @@ class ModelUpdateService:
return []
if not isinstance(versions, Iterable):
return None
extracted: List[ModelVersionRecord] = []
for index, entry in enumerate(versions):
if not isinstance(entry, Mapping):
continue
version_id = _normalize_int(entry.get("id"))
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,
)
)
version_record = self._extract_single_version(entry, index)
if version_record:
extracted.append(version_record)
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]:
if not isinstance(files, Iterable):
return None

View File

@@ -7,12 +7,18 @@ from natsort import natsorted
@dataclass
class RecipeCache:
"""Cache structure for Recipe data"""
raw_data: List[Dict]
sorted_by_name: List[Dict]
sorted_by_date: List[Dict]
folders: List[str] | None = None
folder_tree: Dict | None = None
def __post_init__(self):
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):
"""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 ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import rewrite_preview_url
from .errors import (
RecipeDownloadError,
RecipeNotFoundError,
@@ -94,36 +95,72 @@ class RecipeAnalysisService:
if civitai_client is None:
raise RecipeServiceError("Civitai client unavailable")
temp_path = self._create_temp_path()
temp_path = None
metadata: Optional[dict[str, Any]] = None
is_video = False
extension = ".jpg" # Default
try:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1))
if not image_info:
raise RecipeDownloadError("Failed to fetch image information from Civitai")
image_url = image_info.get("url")
if not image_url:
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)
metadata = image_info.get("meta") if "meta" in image_info else None
if (
isinstance(metadata, dict)
and "meta" in metadata
and isinstance(metadata["meta"], dict)
):
metadata = metadata["meta"]
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)
if metadata is None:
if metadata is None and not is_video:
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(
metadata,
metadata or {},
recipe_scanner=recipe_scanner,
image_path=temp_path,
include_image_base64=True,
is_video=is_video,
extension=extension,
)
finally:
self._safe_cleanup(temp_path)
if temp_path:
self._safe_cleanup(temp_path)
async def analyze_local_image(
self,
@@ -192,12 +229,16 @@ class RecipeAnalysisService:
recipe_scanner,
image_path: Optional[str],
include_image_base64: bool,
is_video: bool = False,
extension: str = ".jpg",
) -> AnalysisResult:
parser = self._recipe_parser_factory.create_parser(metadata)
if parser is None:
payload = {"error": "No parser found for this image", "loras": []}
if include_image_base64 and image_path:
payload["image_base64"] = self._encode_file(image_path)
payload["is_video"] = is_video
payload["extension"] = extension
return AnalysisResult(payload)
result = await parser.parse_metadata(metadata, recipe_scanner=recipe_scanner)
@@ -205,6 +246,9 @@ class RecipeAnalysisService:
if include_image_base64 and 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"):
return AnalysisResult(result)
@@ -235,8 +279,8 @@ class RecipeAnalysisService:
temp_file.write(data)
return temp_file.name
def _create_temp_path(self) -> str:
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as temp_file:
def _create_temp_path(self, suffix: str = ".jpg") -> str:
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file:
return temp_file.name
def _safe_cleanup(self, path: Optional[str]) -> None:

View File

@@ -5,6 +5,7 @@ import base64
import json
import os
import re
import shutil
import time
import uuid
from dataclasses import dataclass
@@ -46,6 +47,7 @@ class RecipePersistenceService:
name: str | None,
tags: Iterable[str],
metadata: Optional[dict[str, Any]],
extension: str | None = None,
) -> PersistenceResult:
"""Persist a user uploaded recipe."""
@@ -64,13 +66,21 @@ class RecipePersistenceService:
os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4())
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,
)
# Handle video formats by bypassing optimization and metadata embedding
is_video = extension in [".mp4", ".webm"]
if is_video:
optimized_image = resolved_image_bytes
# 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_path = os.path.join(recipes_dir, image_filename)
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:
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)
await recipe_scanner.add_recipe(recipe_data)
@@ -144,12 +155,8 @@ class RecipePersistenceService:
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
"""Delete an existing recipe."""
recipes_dir = recipe_scanner.recipes_dir
if not recipes_dir or not os.path.exists(recipes_dir):
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):
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")
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:
"""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(
"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)
@@ -177,6 +184,163 @@ class RecipePersistenceService:
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(
self,
*,
@@ -187,8 +351,8 @@ class RecipePersistenceService:
) -> PersistenceResult:
"""Reconnect a LoRA entry within an existing recipe."""
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json")
if not os.path.exists(recipe_path):
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found")
target_lora = await recipe_scanner.get_local_lora(target_name)
@@ -233,16 +397,12 @@ class RecipePersistenceService:
if not recipe_ids:
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] = []
failed_recipes: list[dict[str, Any]] = []
for recipe_id in recipe_ids:
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
if not os.path.exists(recipe_json_path):
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):
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
continue

View File

@@ -44,6 +44,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"proxy_type": "http",
"default_lora_root": "",
"default_checkpoint_root": "",
"default_unet_root": "",
"default_embedding_root": "",
"base_model_path_mappings": {},
"download_path_templates": {},
@@ -215,6 +216,7 @@ class SettingsManager:
folder_paths=merged.get("folder_paths", {}),
default_lora_root=merged.get("default_lora_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"),
)
}
@@ -300,6 +302,7 @@ class SettingsManager:
folder_paths=normalized_top_level_paths,
default_lora_root=self.settings.get("default_lora_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", ""),
)
libraries = {library_name: library_payload}
@@ -342,6 +345,7 @@ class SettingsManager:
folder_paths=candidate_folder_paths,
default_lora_root=data.get("default_lora_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"),
metadata=data.get("metadata"),
base=data,
@@ -380,6 +384,7 @@ class SettingsManager:
self.settings["folder_paths"] = folder_paths
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_unet_root"] = active_library.get("default_unet_root", "")
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "")
if save:
@@ -394,6 +399,7 @@ class SettingsManager:
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
base: Optional[Mapping[str, Any]] = None,
@@ -416,6 +422,11 @@ class SettingsManager:
else:
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:
payload["default_embedding_root"] = default_embedding_root
else:
@@ -517,6 +528,7 @@ class SettingsManager:
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
) -> bool:
libraries = self.settings.get("libraries", {})
@@ -541,6 +553,10 @@ class SettingsManager:
library["default_checkpoint_root"] = default_checkpoint_root
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:
library["default_embedding_root"] = default_embedding_root
changed = True
@@ -596,7 +612,11 @@ class SettingsManager:
logger.info("Migration completed")
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', {})
updated = False
# loras
@@ -613,6 +633,14 @@ class SettingsManager:
if current_checkpoint_root not in checkpoints:
self.settings['default_checkpoint_root'] = checkpoints[0]
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 = folder_paths.get('embeddings', [])
if isinstance(embeddings, list) and len(embeddings) == 1:
@@ -624,6 +652,7 @@ class SettingsManager:
self._update_active_library_entry(
default_lora_root=self.settings.get('default_lora_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'),
)
if self._bootstrap_reason == "missing":
@@ -851,6 +880,8 @@ class SettingsManager:
self._update_active_library_entry(default_lora_root=str(value))
elif key == 'default_checkpoint_root':
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':
self._update_active_library_entry(default_embedding_root=str(value))
elif key == 'model_name_display':
@@ -883,6 +914,7 @@ class SettingsManager:
if os.path.abspath(previous_path) != os.path.abspath(target_path):
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.settings_file = target_path
@@ -929,7 +961,12 @@ class SettingsManager:
and os.path.abspath(source_cache_dir) != os.path.abspath(target_cache_dir)
):
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:
logger.warning(
"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,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
@@ -1149,6 +1187,11 @@ class SettingsManager:
if default_checkpoint_root is not None
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
if default_embedding_root is not None
@@ -1178,6 +1221,7 @@ class SettingsManager:
folder_paths: Mapping[str, Iterable[str]],
default_lora_root: str = "",
default_checkpoint_root: str = "",
default_unet_root: str = "",
default_embedding_root: str = "",
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
@@ -1193,6 +1237,7 @@ class SettingsManager:
folder_paths=folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
metadata=metadata,
activate=activate,
@@ -1250,6 +1295,7 @@ class SettingsManager:
*,
default_lora_root: Optional[str] = None,
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
) -> None:
"""Update folder paths for the active library."""
@@ -1260,6 +1306,7 @@ class SettingsManager:
folder_paths=folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
activate=True,
)

View File

@@ -59,6 +59,8 @@ class BulkMetadataRefreshUseCase:
success = 0
needs_resort = False
self._service.scanner.reset_cancellation()
async def emit(status: str, **extra: Any) -> None:
if progress_callback is None:
return
@@ -69,6 +71,10 @@ class BulkMetadataRefreshUseCase:
await emit("started")
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:
original_name = model.get("model_name")
await MetadataManager.hydrate_model_data(model)

View File

@@ -20,6 +20,8 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict] = {}
# Add auto-organize progress tracking
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()
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
@@ -189,6 +191,14 @@ class WebSocketManager:
# Broadcast via WebSocket
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]:
"""Get current auto-organize progress"""
return self._auto_organize_progress
@@ -197,6 +207,22 @@ class WebSocketManager:
"""Clear auto-organize progress data"""
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:
"""Check if auto-organize is currently running"""
if not self._auto_organize_progress:

View File

@@ -20,11 +20,25 @@ _COMMERCIAL_SHIFT = 1
def _normalize_commercial_values(value: Any) -> Sequence[str]:
"""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:
return list(_DEFAULT_ALLOW_COMMERCIAL_USE)
if isinstance(value, str):
return [value]
return _split_aggregate(value)
if isinstance(value, Iterable):
result = []
@@ -32,7 +46,7 @@ def _normalize_commercial_values(value: Any) -> Sequence[str]:
if item is None:
continue
if isinstance(item, str):
result.append(item)
result.extend(_split_aggregate(item))
continue
result.append(str(item))
if result:

View File

@@ -4,14 +4,14 @@ NSFW_LEVELS = {
"R": 4,
"X": 8,
"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_TYPES = {
"Lora Loader (LoraManager)": 1,
"Lora Stacker (LoraManager)": 2,
"WanVideo Lora Select (LoraManager)": 3
"WanVideo Lora Select (LoraManager)": 3,
}
# Default ComfyUI node color when bgcolor is null
@@ -19,18 +19,18 @@ DEFAULT_NODE_COLOR = "#353535"
# preview extensions
PREVIEW_EXTENSIONS = [
'.webp',
'.preview.webp',
'.preview.png',
'.preview.jpeg',
'.preview.jpg',
'.preview.mp4',
'.png',
'.jpeg',
'.jpg',
'.mp4',
'.gif',
'.webm'
".webp",
".preview.webp",
".preview.png",
".preview.jpeg",
".preview.jpg",
".preview.mp4",
".png",
".jpeg",
".jpg",
".mp4",
".gif",
".webm",
]
# Card preview image width
@@ -41,37 +41,70 @@ EXAMPLE_IMAGE_WIDTH = 832
# Supported media extensions for example downloads
SUPPORTED_MEDIA_EXTENSIONS = {
'images': ['.jpg', '.jpeg', '.png', '.webp', '.gif'],
'videos': ['.mp4', '.webm']
"images": [".jpg", ".jpeg", ".png", ".webp", ".gif"],
"videos": [".mp4", ".webm"],
}
# 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)
CIVITAI_USER_MODEL_TYPES = [
*VALID_LORA_TYPES,
'textualinversion',
'checkpoint',
"textualinversion",
"checkpoint",
]
# Default chunk size in megabytes used for hashing large files.
DEFAULT_HASH_CHUNK_SIZE_MB = 4
# 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 = [
'character', 'concept', 'clothing',
'realistic', 'anime', 'toon', 'furry', 'style',
'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action'
"character",
"concept",
"clothing",
"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_CONFIG = {
'lora': ', '.join(CIVITAI_MODEL_TAGS),
'checkpoint': ', '.join(CIVITAI_MODEL_TAGS),
'embedding': ', '.join(CIVITAI_MODEL_TAGS),
"lora": ", ".join(CIVITAI_MODEL_TAGS),
"checkpoint": ", ".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)
}, 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
"""
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
with Image.open(image_path) as img:
# Method 1: Check for parameters in image info
@@ -80,6 +86,12 @@ class ExifUtils:
str: Path to the updated image
"""
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
with Image.open(image_path) as img:
img_format = img.format
@@ -133,6 +145,12 @@ class ExifUtils:
def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data"""
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
metadata = ExifUtils.extract_image_metadata(image_path)
@@ -140,6 +158,28 @@ class ExifUtils:
if metadata:
# Remove any existing recipe metadata
metadata = ExifUtils.remove_recipe_metadata(metadata)
# Prepare checkpoint data
checkpoint_data = recipe_data.get("checkpoint") or {}
simplified_checkpoint = None
if isinstance(checkpoint_data, dict) and checkpoint_data:
simplified_checkpoint = {
"type": checkpoint_data.get("type", "checkpoint"),
"modelId": checkpoint_data.get("modelId", 0),
"modelVersionId": checkpoint_data.get("modelVersionId")
or checkpoint_data.get("id", 0),
"modelName": checkpoint_data.get(
"modelName", checkpoint_data.get("name", "")
),
"modelVersionName": checkpoint_data.get(
"modelVersionName", checkpoint_data.get("version", "")
),
"hash": checkpoint_data.get("hash", "").lower()
if checkpoint_data.get("hash")
else "",
"file_name": checkpoint_data.get("file_name", ""),
"baseModel": checkpoint_data.get("baseModel", ""),
}
# Prepare simplified loras data
simplified_loras = []
@@ -160,7 +200,8 @@ class ExifUtils:
'base_model': recipe_data.get('base_model', ''),
'loras': simplified_loras,
'gen_params': recipe_data.get('gen_params', {}),
'tags': recipe_data.get('tags', [])
'tags': recipe_data.get('tags', []),
**({'checkpoint': simplified_checkpoint} if simplified_checkpoint else {})
}
# Convert to JSON string
@@ -219,6 +260,16 @@ class ExifUtils:
Tuple of (optimized_image_data, extension)
"""
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
img = None
if isinstance(image_data, str) and os.path.exists(image_data):
@@ -359,4 +410,4 @@ class ExifUtils:
return f.read(), os.path.splitext(image_data)[1]
except Exception:
return image_data, '.jpg' # Last resort fallback
return image_data, '.jpg'
return image_data, '.jpg'

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 json
import logging
import time
from typing import Any, Dict, Optional, Type, Union
from .models import BaseModelMetadata, LoraMetadata
@@ -203,7 +204,11 @@ class MetadataManager:
preview_url = find_preview_file(base_name, dir_path)
# Calculate file hash
start_hash_time = time.perf_counter()
logger.debug(f"Calculating SHA256 hash for {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
if model_class.__name__ == "CheckpointMetadata":
@@ -255,6 +260,7 @@ class MetadataManager:
# await MetadataManager._enrich_metadata(metadata, real_path)
# 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)
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]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.10"
version = "0.9.13"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -1,82 +1,33 @@
{
"id": "0448c06d-de1b-46ab-975c-c5aa60d90dbc",
"file_path": "D:/Workspace/ComfyUI/models/loras/recipes/0448c06d-de1b-46ab-975c-c5aa60d90dbc.jpg",
"title": "a mysterious, steampunk-inspired character standing in a dramatic pose",
"modified": 1741837612.3931093,
"created_date": 1741492786.5581934,
"base_model": "Flux.1 D",
"id": "42803a29-02dc-49e1-b798-27da70e8b408",
"file_path": "/home/miao/workspace/ComfyUI/models/loras/recipes/test/42803a29-02dc-49e1-b798-27da70e8b408.webp",
"title": "masterpiece, best quality, amazing quality, very aesthetic, detailed eyes, perfect",
"modified": 1754897325.0507245,
"created_date": 1754897325.0507245,
"base_model": "Illustrious",
"loras": [
{
"file_name": "ChronoDivinitiesFlux_r1",
"hash": "ddbc5abd00db46ad464f5e3ca85f8f7121bc14b594d6785f441d9b002fffe66a",
"strength": 0.8,
"modelVersionId": 1438879,
"modelName": "Chrono Divinities - By HailoKnight",
"modelVersionName": "Flux"
},
{
"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"
"file_name": "",
"hash": "1b5b763d83961bb5745f3af8271ba83f1d4fd69c16278dae6d5b4e194bdde97a",
"strength": 1.0,
"modelVersionId": 2007092,
"modelName": "Pony: People's Works +",
"modelVersionName": "v8_Illusv1.0",
"isDeleted": false,
"exclude": false
}
],
"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",
"negative_prompt": "",
"checkpoint": {
"type": "checkpoint",
"modelVersionId": 691639,
"modelName": "FLUX",
"modelVersionName": "Dev"
},
"steps": "30",
"sampler": "Undefined",
"cfg_scale": "3.5",
"seed": "1472903449",
"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": "shiny skin,",
"steps": 19,
"sampler": "Euler a",
"cfg_scale": 5,
"seed": 1765271748,
"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.verbose = verbose
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'):
"""Log a message if verbose mode is enabled."""

View File

@@ -7,26 +7,31 @@ from py.utils.settings_paths import ensure_settings_file
# Set environment variable to indicate standalone mode
os.environ["LORA_MANAGER_STANDALONE"] = "1"
# Create mock modules for py/nodes directory - add this before any other imports
def mock_nodes_directory():
"""Create mock modules for all Python files in the py/nodes directory"""
nodes_dir = os.path.join(os.path.dirname(__file__), 'py', 'nodes')
nodes_dir = os.path.join(os.path.dirname(__file__), "py", "nodes")
if os.path.exists(nodes_dir):
# Create a mock module for the nodes package itself
sys.modules['py.nodes'] = type('MockNodesModule', (), {})
sys.modules["py.nodes"] = type("MockNodesModule", (), {})
# Create mock modules for all Python files in the nodes directory
for file in os.listdir(nodes_dir):
if file.endswith('.py') and file != '__init__.py':
if file.endswith(".py") and file != "__init__.py":
module_name = file[:-3] # Remove .py extension
full_module_name = f'py.nodes.{module_name}'
full_module_name = f"py.nodes.{module_name}"
# Create empty module object
sys.modules[full_module_name] = type(f'Mock{module_name.capitalize()}Module', (), {})
sys.modules[full_module_name] = type(
f"Mock{module_name.capitalize()}Module", (), {}
)
print(f"Created mock module for: {full_module_name}")
# Run the mocking function before any other imports
mock_nodes_directory()
# Create mock folder_paths module BEFORE any other imports
class MockFolderPaths:
@staticmethod
@@ -35,17 +40,17 @@ class MockFolderPaths:
settings_path = ensure_settings_file()
try:
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
with open(settings_path, "r", encoding="utf-8") as f:
settings = json.load(f)
# For diffusion_models, combine unet and diffusers paths
if folder_name == "diffusion_models":
paths = []
if 'folder_paths' in settings:
if 'unet' in settings['folder_paths']:
paths.extend(settings['folder_paths']['unet'])
if 'diffusers' in settings['folder_paths']:
paths.extend(settings['folder_paths']['diffusers'])
if "folder_paths" in settings:
if "unet" in settings["folder_paths"]:
paths.extend(settings["folder_paths"]["unet"])
if "diffusers" in settings["folder_paths"]:
paths.extend(settings["folder_paths"]["diffusers"])
# Filter out paths that don't exist
valid_paths = [p for p in paths if os.path.exists(p)]
if valid_paths:
@@ -53,8 +58,11 @@ class MockFolderPaths:
else:
print(f"Warning: No valid paths found for {folder_name}")
# For other folder names, return their paths directly
elif 'folder_paths' in settings and folder_name in settings['folder_paths']:
paths = settings['folder_paths'][folder_name]
elif (
"folder_paths" in settings
and folder_name in settings["folder_paths"]
):
paths = settings["folder_paths"][folder_name]
valid_paths = [p for p in paths if os.path.exists(p)]
if valid_paths:
return valid_paths
@@ -62,39 +70,42 @@ class MockFolderPaths:
print(f"Warning: No valid paths found for {folder_name}")
except Exception as e:
print(f"Error loading folder paths from settings: {e}")
# Fallback to empty list if no paths found
return []
@staticmethod
def get_temp_directory():
return os.path.join(os.path.dirname(__file__), 'temp')
return os.path.join(os.path.dirname(__file__), "temp")
@staticmethod
def set_temp_directory(path):
os.makedirs(path, exist_ok=True)
return path
# Create mock server module with PromptServer
class MockPromptServer:
def __init__(self):
self.app = None
def send_sync(self, *args, **kwargs):
pass
# Create mock metadata_collector module
class MockMetadataCollector:
def init(self):
pass
def get_metadata(self, prompt_id=None):
return {}
# Initialize basic mocks before any imports
sys.modules['folder_paths'] = MockFolderPaths()
sys.modules['server'] = type('server', (), {'PromptServer': MockPromptServer()})
sys.modules['py.metadata_collector'] = MockMetadataCollector()
sys.modules["folder_paths"] = MockFolderPaths()
sys.modules["server"] = type("server", (), {"PromptServer": MockPromptServer()})
sys.modules["py.metadata_collector"] = MockMetadataCollector()
# Now we can safely import modules that depend on folder_paths and server
import argparse
@@ -106,12 +117,14 @@ from aiohttp import web
HEADER_SIZE_LIMIT = 16384
# Setup logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("lora-manager-standalone")
# Configure aiohttp access logger to be less verbose
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
# Add specific suppression for connection reset errors
class ConnectionResetFilter(logging.Filter):
@@ -125,6 +138,7 @@ class ConnectionResetFilter(logging.Filter):
return False
return True
# Apply the filter to asyncio logger
asyncio_logger = logging.getLogger("asyncio")
asyncio_logger.addFilter(ConnectionResetFilter())
@@ -132,9 +146,10 @@ asyncio_logger.addFilter(ConnectionResetFilter())
# Now we can import the global config from our local modules
from py.config import config
class StandaloneServer:
"""Server implementation for standalone mode"""
def __init__(self):
self.app = web.Application(
logger=logger,
@@ -145,45 +160,49 @@ class StandaloneServer:
},
)
self.instance = self # Make it compatible with PromptServer.instance pattern
# Ensure the app's access logger is configured to reduce verbosity
self.app._subapps = [] # Ensure this exists to avoid AttributeError
async def setup(self):
"""Set up the standalone server"""
# Create placeholders for compatibility with ComfyUI's implementation
self.last_prompt_id = None
self.last_node_id = None
self.client_id = None
# Set up routes
self.setup_routes()
# Add startup and shutdown handlers
self.app.on_startup.append(self.on_startup)
self.app.on_shutdown.append(self.on_shutdown)
def setup_routes(self):
"""Set up basic routes"""
# Add a simple status endpoint
self.app.router.add_get('/', self.handle_status)
self.app.router.add_get("/", self.handle_status)
# Add static route for example images if the path exists in settings
settings_path = ensure_settings_file(logger)
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
with open(settings_path, "r", encoding="utf-8") as f:
settings = json.load(f)
example_images_path = settings.get('example_images_path')
example_images_path = settings.get("example_images_path")
logger.info(f"Example images path: {example_images_path}")
if example_images_path and os.path.exists(example_images_path):
self.app.router.add_static('/example_images_static', example_images_path)
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
self.app.router.add_static(
"/example_images_static", example_images_path
)
logger.info(
f"Added static route for example images: /example_images_static -> {example_images_path}"
)
async def handle_status(self, request):
"""Handle status request by redirecting to loras page"""
# Redirect to loras page instead of showing status
raise web.HTTPFound('/loras')
raise web.HTTPFound("/loras")
# Original JSON response (commented out)
# return web.json_response({
# "status": "running",
@@ -191,21 +210,21 @@ class StandaloneServer:
# "loras_roots": config.loras_roots,
# "checkpoints_roots": config.checkpoints_roots
# })
async def on_startup(self, app):
"""Startup handler"""
logger.info("LoRA Manager standalone server starting...")
async def on_shutdown(self, app):
"""Shutdown handler"""
logger.info("LoRA Manager standalone server shutting down...")
def send_sync(self, event_type, data, sid=None):
"""Stub for compatibility with PromptServer"""
# In standalone mode, we don't have the same websocket system
pass
async def start(self, host='127.0.0.1', port=8188):
async def start(self, host="127.0.0.1", port=8188):
"""Start the server"""
runner = web.AppRunner(self.app)
await runner.setup()
@@ -214,19 +233,21 @@ class StandaloneServer:
# Log the server address with a clickable localhost URL regardless of the actual binding
logger.info(f"Server started at http://127.0.0.1:{port}")
# Keep the server running
while True:
await asyncio.sleep(3600) # Sleep for a long time
async def publish_loop(self):
"""Stub for compatibility with PromptServer"""
# This method exists in ComfyUI's server but we don't need it
pass
# After all mocks are in place, import LoraManager
from py.lora_manager import LoraManager
def validate_settings():
"""Initialize settings and log any startup warnings."""
try:
@@ -267,28 +288,33 @@ def validate_settings():
return True
class StandaloneLoraManager(LoraManager):
"""Extended LoraManager for standalone mode"""
@classmethod
def add_routes(cls, server_instance):
"""Initialize and register all routes for standalone mode"""
app = server_instance.app
# Store app in a global-like location for compatibility
sys.modules['server'].PromptServer.instance = server_instance
sys.modules["server"].PromptServer.instance = server_instance
# Add static route for locales JSON files
if os.path.exists(config.i18n_path):
app.router.add_static('/locales', config.i18n_path)
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
app.router.add_static("/locales", config.i18n_path)
logger.info(
f"Added static route for locales: /locales -> {config.i18n_path}"
)
# Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path)
app.router.add_static("/loras_static", config.static_path)
# Setup feature routes
from py.services.model_service_factory import ModelServiceFactory, register_default_model_types
from py.services.model_service_factory import (
ModelServiceFactory,
register_default_model_types,
)
from py.routes.recipe_routes import RecipeRoutes
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
@@ -296,7 +322,6 @@ class StandaloneLoraManager(LoraManager):
from py.routes.preview_routes import PreviewRoutes
from py.routes.stats_routes import StatsRoutes
from py.services.websocket_manager import ws_manager
register_default_model_types()
@@ -304,7 +329,7 @@ class StandaloneLoraManager(LoraManager):
ModelServiceFactory.setup_all_routes(app)
stats_routes = StatsRoutes()
# Initialize routes
stats_routes.setup_routes(app)
RecipeRoutes.setup_routes(app)
@@ -314,55 +339,78 @@ class StandaloneLoraManager(LoraManager):
PreviewRoutes.setup_routes(app)
# Setup WebSocket routes that are shared across all model types
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
app.router.add_get('/ws/download-progress', ws_manager.handle_download_connection)
app.router.add_get('/ws/init-progress', ws_manager.handle_init_connection)
app.router.add_get("/ws/fetch-progress", ws_manager.handle_connection)
app.router.add_get(
"/ws/download-progress", ws_manager.handle_download_connection
)
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())
# Add cleanup
app.on_shutdown.append(cls._cleanup)
def parse_args():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description="LoRA Manager Standalone Server")
parser.add_argument("--host", type=str, default="0.0.0.0",
help="Host address to bind the server to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8188,
help="Port to bind the server to (default: 8188, access via http://localhost:8188/loras)")
# parser.add_argument("--loras", type=str, nargs="+",
parser.add_argument(
"--host",
type=str,
default="0.0.0.0",
help="Host address to bind the server to (default: 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=8188,
help="Port to bind the server to (default: 8188, access via http://localhost:8188/loras)",
)
# parser.add_argument("--loras", type=str, nargs="+",
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
# parser.add_argument("--checkpoints", type=str, nargs="+",
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
parser.add_argument("--log-level", type=str, default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose logging (equivalent to --log-level DEBUG)",
)
return parser.parse_args()
async def main():
"""Main entry point for standalone mode"""
args = parse_args()
# Set log level
logging.getLogger().setLevel(getattr(logging, args.log_level))
# Set log level (verbose flag overrides to DEBUG)
log_level = "DEBUG" if args.verbose else args.log_level
logging.getLogger().setLevel(getattr(logging, log_level))
# Validate settings before proceeding
if not validate_settings():
logger.error("Cannot start server due to configuration issues.")
logger.error("Please fix the settings.json file and try again.")
return
# Create the server instance
server = StandaloneServer()
# Initialize routes via the standalone lora manager
StandaloneLoraManager.add_routes(server)
# Set up and start the server
await server.setup()
await server.start(host=args.host, port=args.port)
if __name__ == "__main__":
try:
# Run the main function

View File

@@ -1,8 +1,10 @@
html, body {
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; /* Disable default scrolling */
overflow: hidden;
/* Disable default scrolling */
}
/* 针对Firefox */
@@ -58,12 +60,12 @@ html, body {
--badge-update-bg: oklch(72% 0.2 220);
--badge-update-text: oklch(28% 0.03 220);
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
/* Spacing Scale */
--space-1: calc(8px * 1);
--space-2: calc(8px * 2);
--space-3: calc(8px * 3);
/* Z-index Scale */
--z-base: 10;
--z-header: 100;
@@ -75,8 +77,9 @@ html, body {
--border-radius-sm: 8px;
--border-radius-xs: 4px;
--scrollbar-width: 8px; /* 添加滚动条宽度变量 */
--scrollbar-width: 8px;
/* 添加滚动条宽度变量 */
/* Shortcut styles */
--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);
@@ -104,7 +107,8 @@ html[data-theme="light"] {
--lora-surface: oklch(25% 0.02 256 / 0.98);
--lora-border: oklch(90% 0.02 256 / 0.15);
--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-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
--badge-update-bg: oklch(62% 0.18 220);
@@ -118,5 +122,10 @@ body {
color: var(--text-color);
display: flex;
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;
top: 0;
z-index: var(--z-header);
height: 48px; /* Reduced height */
height: 48px;
/* Reduced height */
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 {
@@ -25,6 +27,7 @@
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.header-container {
max-width: 2400px;
@@ -43,7 +46,7 @@
align-items: center;
text-decoration: none;
color: var(--text-color);
gap: 2px;
gap: 8px;
}
.app-logo {
@@ -153,7 +156,7 @@
flex-shrink: 0;
}
.header-controls > div {
.header-controls>div {
width: 32px;
height: 32px;
border-radius: 50%;
@@ -168,14 +171,15 @@
position: relative;
}
.header-controls > div:hover {
.header-controls>div:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.theme-toggle {
position: relative; /* Ensure relative positioning for the container */
position: relative;
/* Ensure relative positioning for the container */
}
.theme-toggle .light-icon,
@@ -184,7 +188,8 @@
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Center perfectly */
transform: translate(-50%, -50%);
/* Center perfectly */
opacity: 0;
transition: opacity 0.3s ease;
}
@@ -246,23 +251,24 @@
/* Mobile adjustments */
@media (max-width: 768px) {
.app-title {
display: none; /* Hide text title on mobile */
display: none;
/* Hide text title on mobile */
}
.header-controls {
gap: 4px;
}
.header-controls > div {
.header-controls>div {
width: 28px;
height: 28px;
}
.header-search {
max-width: none;
margin: 0 0.5rem;
}
.main-nav {
margin-right: 0.5rem;
}
@@ -273,12 +279,13 @@
.header-container {
padding: 0 8px;
}
.main-nav {
display: none; /* Hide navigation on very small screens */
display: none;
/* Hide navigation on very small screens */
}
.header-search {
flex: 1;
}
}
}

View File

@@ -1,7 +1,8 @@
/* Import Modal Styles */
.import-step {
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 */
@@ -107,7 +108,8 @@
justify-content: center;
}
.recipe-image img {
.recipe-image img,
.recipe-preview-video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
@@ -379,7 +381,7 @@
.recipe-details-layout {
grid-template-columns: 1fr;
}
.recipe-image-container {
height: 150px;
}
@@ -512,14 +514,17 @@
/* Prevent layout shift with scrollbar */
.modal-content {
overflow-y: scroll; /* Always show scrollbar */
scrollbar-gutter: stable; /* Reserve space for scrollbar */
overflow-y: scroll;
/* Always show scrollbar */
scrollbar-gutter: stable;
/* Reserve space for scrollbar */
}
/* For browsers that don't support scrollbar-gutter */
@supports not (scrollbar-gutter: stable) {
.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 */
.warning-message {
display: none; /* Hide the old style */
display: none;
/* Hide the old style */
}
/* Update deleted badge to be more prominent */
@@ -613,7 +619,8 @@
color: var(--lora-error);
font-size: 0.9em;
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;
}
@@ -662,8 +669,15 @@
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.duplicate-warning {
@@ -779,6 +793,7 @@
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
@@ -793,9 +808,9 @@
opacity: 0.8;
}
.duplicate-recipe-date,
.duplicate-recipe-date,
.duplicate-recipe-lora-count {
display: flex;
align-items: center;
gap: 4px;
}
}

View File

@@ -41,7 +41,7 @@
padding: 8px;
position: absolute;
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 */
transform: translateY(-15%); /* Vertically center */
opacity: 0;
@@ -56,11 +56,11 @@
content: "";
position: absolute;
top: 50%; /* Vertically center arrow */
right: 100%; /* Arrow on the left side */
left: 100%; /* Arrow on the right side */
margin-top: -5px;
border-width: 5px;
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 {

View File

@@ -7,6 +7,7 @@
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
position: relative;
}
.modal-header-actions {
@@ -18,6 +19,55 @@
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 {
margin-left: auto;
}

View File

@@ -129,6 +129,12 @@
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) {
background: var(--lora-error);
color: white;
@@ -475,4 +481,4 @@
/* For dark theme */
[data-theme="dark"] .import-container {
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);
overflow-y: auto;
overflow-x: hidden; /* 防止水平滚动条 */
scrollbar-gutter: stable both-edges; /* Reserve space to prevent layout shift when scrollbar toggles */
}
.modal-content-large {
@@ -121,6 +122,7 @@ body.modal-open {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
z-index: 10;
}
.close:hover {

View File

@@ -328,11 +328,11 @@
display: block;
}
.tree-node.has-children > .tree-node-content .tree-expand-icon {
.tree-node.has-children>.tree-node-content .tree-expand-icon {
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;
pointer-events: none;
}
@@ -470,11 +470,11 @@
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);
}
.inline-toggle-container .toggle-switch input:checked + .toggle-slider:before {
.inline-toggle-container .toggle-switch input:checked+.toggle-slider:before {
transform: translateX(18px);
}

View File

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

View File

@@ -242,6 +242,20 @@
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 {
display: flex;

View File

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

View File

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

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