Compare commits

...

177 Commits

Author SHA1 Message Date
Will Miao
59b1abb719 Update version to 0.8.4 and add release notes for node layout improvements and bug fixes 2025-04-07 14:49:34 +08:00
Will Miao
3e2cfb552b Refactor image saving logic for batch processing and unique filename generation. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/79 2025-04-07 14:37:39 +08:00
Will Miao
779be1b8d0 Refactor loras_widget styles for improved layout consistency 2025-04-07 13:42:31 +08:00
Will Miao
faf74de238 Enhance model move functionality with detailed error handling and user feedback 2025-04-07 11:14:56 +08:00
Will Miao
50a51c2e79 Refactor Lora widget and dynamic module loading
- Updated lora_loader.js to dynamically import the appropriate loras widget based on ComfyUI version, enhancing compatibility and maintainability.
- Enhanced loras_widget.js with improved height management and styling for better user experience.
- Introduced utility functions in utils.js for version checking and dynamic imports, streamlining widget loading processes.
- Improved overall structure and readability of the code, ensuring better performance and easier future updates.
2025-04-07 09:02:36 +08:00
Will Miao
d31e641496 Add dynamic tags widget selection based on ComfyUI version
- Introduced a mechanism to dynamically import either the legacy or modern tags widget based on the ComfyUI frontend version.
- Updated the `addTagsWidget` function in both `tags_widget.js` and `legacy_tags_widget.js` to enhance tag rendering and widget height management.
- Improved styling and layout for tags, ensuring better alignment and responsiveness.
- Added a new serialization method to handle potential issues with ComfyUI's serialization process.
- Enhanced the overall user experience by providing a more modern and flexible tags widget implementation.
2025-04-07 08:42:20 +08:00
Will Miao
f2d36f5be9 Refactor DownloadManager and LoraFileHandler for improved file monitoring
- Simplified the path handling in DownloadManager by directly adding normalized paths to the ignore list.
- Updated LoraFileHandler to utilize a set for ignore paths, enhancing performance and clarity.
- Implemented debouncing for modified file events to prevent duplicate processing and improve efficiency.
- Enhanced the handling of file creation, modification, and deletion events for .safetensors files, ensuring accurate processing and logging.
- Adjusted cache operations to streamline the addition and removal of files based on real paths.
2025-04-06 22:27:55 +08:00
Will Miao
0b55f61fac Refactor LoraFileHandler to use real file paths for monitoring
- Updated the file monitoring logic to store and verify real file paths instead of mapped paths, ensuring accurate existence checks.
- Enhanced logging for error handling and processing actions, including detailed error messages with exception info.
- Adjusted cache operations to reflect the use of normalized paths for consistency in add/remove actions.
- Improved handling of ignore paths by removing successfully processed files from the ignore list.
2025-04-05 12:10:46 +08:00
pixelpaws
4156dcbafd Merge pull request #83 from willmiao/dev
Dev
2025-04-05 05:28:22 +08:00
Will Miao
36e6ac2362 Add CheckpointMetadata class for enhanced model metadata management
- Introduced a new CheckpointMetadata dataclass to encapsulate metadata for checkpoint models.
- Included fields for file details, model specifications, and additional attributes such as resolution and architecture.
- Implemented a __post_init__ method to initialize tags as an empty list if not provided, ensuring consistent data handling.
2025-04-05 05:16:52 +08:00
Will Miao
9613199152 Enhance SaveImage functionality with custom prompt support
- Added a new optional parameter `custom_prompt` to the SaveImage class methods to allow users to override the default prompt.
- Updated the `format_metadata` method to utilize the custom prompt if provided.
- Modified the `save_images` and `process_image` methods to accept and pass the custom prompt through the workflow processing.
2025-04-04 07:47:46 +08:00
pixelpaws
14328d7496 Merge pull request #77 from willmiao/dev
Add reconnect functionality for deleted LoRAs in recipe modal
2025-04-03 16:56:04 +08:00
Will Miao
6af12d1acc Add reconnect functionality for deleted LoRAs in recipe modal
- Introduced a new API endpoint to reconnect deleted LoRAs to local files.
- Updated RecipeModal to include UI elements for reconnecting LoRAs, including input fields and buttons.
- Enhanced CSS styles for deleted badges and reconnect containers to improve user experience.
- Implemented event handling for reconnect actions, including input validation and API calls.
- Updated recipe data handling to reflect changes after reconnecting LoRAs.
2025-04-03 16:55:19 +08:00
pixelpaws
9b44e49879 Merge pull request #75 from willmiao/dev
Enhance file monitoring for LoRA files
2025-04-03 11:10:29 +08:00
Will Miao
afee18f146 Enhance file monitoring for LoRA files
- Added a method to map symbolic links back to actual paths in the Config class.
- Improved file creation handling in LoraFileHandler to check for file size and existence before processing.
- Introduced handling for file modification events to update the ignore list and schedule updates.
- Increased debounce delay in _process_changes to allow for file downloads to complete.
- Enhanced action processing to prioritize 'add' actions and verify file existence before adding to cache.
2025-04-03 11:09:30 +08:00
Will Miao
f007369a66 Bump version to v0.8.3 2025-04-02 20:18:51 +08:00
pixelpaws
9a9c166dbe Merge pull request #74 from willmiao/dev
Dev
2025-04-02 20:15:11 +08:00
Will Miao
2f90e32dbf Delete unused files 2025-04-02 20:11:41 +08:00
Will Miao
26355ccb79 chore: remove .vscode from git 2025-04-02 20:09:58 +08:00
Will Miao
27ea3c0c8e chore: add .vscode to gitignore 2025-04-02 20:09:08 +08:00
Will Miao
5aa35b211a Update README and update_logs 2025-04-02 20:03:18 +08:00
Will Miao
92450385d2 Update README 2025-04-02 20:00:04 +08:00
Will Miao
8d15e23f3c Add markdown support for changelog in modal
- Introduced a simple markdown parser to convert markdown syntax in changelog items to HTML.
- Updated modal CSS to style markdown elements, enhancing the presentation of changelog items.
- Improved user experience by allowing formatted text in changelog, including bold, italic, code, and links.
2025-04-02 19:36:52 +08:00
Will Miao
73686d4146 Enhance modal and settings functionality with default LoRA root selection
- Updated modal styles for improved layout and added select control for default LoRA root.
- Modified DownloadManager, ImportManager, MoveManager, and SettingsManager to retrieve and set the default LoRA root from storage.
- Introduced asynchronous loading of LoRA roots in SettingsManager to dynamically populate the select options.
- Improved user experience by allowing users to set a default LoRA root for downloads, imports, and moves.
2025-04-02 17:37:16 +08:00
Will Miao
0499ca1300 Update process_node function to ignore type checking
- Added a type: ignore comment to the process_node function to suppress type checking errors.
- Removed the README.md file as it is no longer needed.
2025-04-02 17:02:11 +08:00
Will Miao
234c942f34 Refactor transform functions and update node mappers
- Moved and redefined transform functions for KSampler, EmptyLatentImage, CLIPTextEncode, and FluxGuidance to improve organization and maintainability.
- Updated NODE_MAPPERS to include new input tracking for clip_skip in KSampler and added new transform functions for LatentUpscale and CLIPSetLastLayer.
- Enhanced the transform_sampler_custom_advanced function to handle clip_skip extraction from model inputs.
2025-04-02 17:01:10 +08:00
Will Miao
aec218ba00 Enhance SaveImage class with filename formatting and multiple image support
- Updated the INPUT_TYPES to accept multiple images and modified the corresponding processing methods.
- Introduced a new format_filename method to handle dynamic filename generation using metadata patterns.
- Replaced save_workflow_json with embed_workflow for better clarity in saving workflow metadata.
- Improved directory handling and filename generation logic to ensure proper file saving.
2025-04-02 15:08:36 +08:00
Will Miao
b508f51fcf checkpoint 2025-04-02 14:13:53 +08:00
Will Miao
435628ea59 Refactor WorkflowParser by removing unused methods 2025-04-02 14:13:24 +08:00
Will Miao
4933dbfb87 Refactor ExifUtils by removing unused methods and imports
- Removed the extract_user_comment and update_user_comment methods to streamline the ExifUtils class.
- Cleaned up unnecessary imports and reduced code complexity, focusing on essential functionality for image metadata extraction.
2025-04-02 11:14:05 +08:00
Will Miao
5a93c40b79 Refactor logging levels and improve mapper registration
- Changed warning logs to debug logs in CivitaiClient and RecipeScanner for better log granularity.
- Updated the mapper registration function name for clarity and adjusted related logging messages.
- Enhanced extension loading process to automatically register mappers from NODE_MAPPERS_EXT, improving modularity and maintainability.
2025-04-02 10:29:31 +08:00
Will Miao
a8ec5af037 checkpoint 2025-04-02 06:05:24 +08:00
Will Miao
27db60ce68 checkpoint 2025-04-01 19:17:43 +08:00
Will Miao
195866b00d Implement KJNodes extension with new mappers and transform functions
- Added KJNodes mappers for JoinStrings, StringConstantMultiline, and EmptyLatentImagePresets.
- Introduced transform functions to handle string joining, string constants, and dimension extraction with optional inversion.
- Registered new mappers and logged successful registration for better traceability.
2025-04-01 16:22:57 +08:00
Will Miao
60575b6546 checkpoint 2025-04-01 08:38:49 +08:00
pixelpaws
350b81d678 Merge pull request #64 from richardhristov/main
Remember sort by name/date in LoRAs page
2025-03-31 20:16:29 +08:00
Will Miao
cc95314dae Bump version to v0.8.2 2025-03-30 20:53:22 +08:00
Will Miao
3f97087abb Update unauthorized access error message 2025-03-30 20:15:50 +08:00
Will Miao
f04af2de21 Add Civitai model retrieval and missing LoRAs download functionality
- Introduced new API endpoints for fetching Civitai model details by model version ID or hash.
- Enhanced the download manager to support downloading LoRAs using model version ID or hash, improving flexibility.
- Updated RecipeModal to handle missing LoRAs, allowing users to download them directly from the recipe interface.
- Added tooltip and click functionality for missing LoRAs status, enhancing user experience.
- Improved error handling for missing LoRAs download process, providing clearer feedback to users.
2025-03-30 19:45:03 +08:00
Richard Hristov
e7871bf843 Remember sort by name/date in LoRAs page 2025-03-29 17:11:53 +02:00
Will Miao
8e3308039a Refactor Lora handling in RecipeRoutes and enhance RecipeManager
- Updated Lora filtering logic in RecipeRoutes to skip deleted LoRAs without exclusion checks, improving performance and clarity.
- Enhanced condition for fetching cached LoRAs to ensure valid data is processed.
- Added toggleApiKeyVisibility function to RecipeManager, improving API key management in the UI.
2025-03-29 19:11:13 +08:00
Will Miao
b65350b7cb Add update functionality for recipe metadata in RecipeRoutes and RecipeModal
- Introduced a new API endpoint to update recipe metadata, allowing users to modify recipe titles and tags.
- Enhanced RecipeModal to support inline editing of recipe titles and tags, improving user interaction.
- Updated RecipeCard to reflect changes in recipe metadata, ensuring consistency across the application.
- Improved error handling for metadata updates to provide clearer feedback to users.
2025-03-29 18:46:19 +08:00
Will Miao
069ebce895 Add recipe syntax endpoint and update RecipeCard and RecipeModal for syntax fetching
- Introduced a new API endpoint to retrieve recipe syntax for LoRAs, allowing for better integration with the frontend.
- Updated RecipeCard to fetch recipe syntax from the backend instead of generating it locally.
- Modified RecipeModal to store the recipe ID and fetch syntax when the copy button is clicked, improving user experience.
- Enhanced error handling for fetching recipe syntax to provide clearer feedback to users.
2025-03-29 15:38:49 +08:00
Will Miao
63aa4e188e Add rename functionality for LoRA files and enhance UI for editing file names
- Introduced a new API endpoint to rename LoRA files, including validation and error handling for file paths and names.
- Updated the RecipeScanner to reflect changes in LoRA filenames across recipe files and cache.
- Enhanced the LoraModal UI to allow inline editing of file names with improved user interaction and validation.
- Added CSS styles for the editing interface to improve visual feedback during file name editing.
2025-03-29 09:25:41 +08:00
Will Miao
c31c9c16cf Enhance LoraScanner and file_utils for improved metadata handling
- Updated LoraScanner to first attempt to create metadata from .civitai.info files, improving metadata extraction from existing files.
- Added error handling for reading .civitai.info files and fallback to generating metadata using get_file_info if necessary.
- Refactored file_utils to expose find_preview_file function and added logic to utilize SHA256 from existing .json files to avoid recalculation.
- Improved overall robustness of metadata loading and preview file retrieval processes.
2025-03-28 16:27:59 +08:00
Will Miao
5a8a402fdc Enhance LoraRoutes and templates for improved cache initialization handling
- Updated LoraRoutes to better check cache initialization status and handle loading states.
- Added logging for successful cache loading and error handling for cache retrieval failures.
- Enhanced base.html and loras.html templates to display a loading spinner and initialization notice during cache setup.
- Improved user experience by ensuring the loading notice is displayed appropriately based on initialization state.
2025-03-28 15:04:35 +08:00
Will Miao
85c3e33343 Update version to 0.8.1 and add release notes for new features and improvements
- Bump version from 0.8.0 to 0.8.1 in pyproject.toml.
- Document new features in README.md, including base model correction, LoRA loader flexibility, expanded recipe support, enhanced showcase images, and various UI improvements and bug fixes.
2025-03-28 04:15:54 +08:00
Will Miao
1420ab31a2 Enhance CivitaiClient error handling for unauthorized access
- Updated handling of 401 unauthorized responses to differentiate between API key issues and early access restrictions.
- Improved logging for unauthorized access attempts.
- Refactored condition to check for early access restrictions based on response headers.
- Adjusted logic in DownloadManager to check for early access using a more concise method.
2025-03-28 04:11:08 +08:00
Will Miao
fd1435537f Add ImageSaverMetadataParser for ComfyUI Image Saver plugin metadata handling
- Introduced ImageSaverMetadataParser class to parse metadata from the Image Saver plugin format.
- Implemented methods to extract prompts, negative prompts, and LoRA information, including weights and hashes.
- Enhanced error handling and logging for metadata parsing failures.
- Updated RecipeParserFactory to include ImageSaverMetadataParser for relevant user comments.
2025-03-28 03:27:35 +08:00
Will Miao
4e0473ce11 Fix redownloading loras issue 2025-03-28 02:53:30 +08:00
Will Miao
450592b0d4 Implement Civitai data population methods for LoRA and checkpoint entries
- Added `populate_lora_from_civitai` and `populate_checkpoint_from_civitai` methods to enhance the extraction of model information from Civitai API responses.
- These methods populate LoRA and checkpoint entries with relevant data such as model name, version, thumbnail URL, base model, download URL, and file details.
- Improved error handling and logging for scenarios where models are not found or data retrieval fails.
- Refactored existing code to utilize the new methods, streamlining the process of fetching and updating LoRA and checkpoint metadata.
2025-03-28 02:16:53 +08:00
Will Miao
7cae0ee169 Enhance LoraModal to include image metadata panel
- Added a new image metadata panel to display generation parameters and prompts for images and videos.
- Implemented styles for the metadata panel in lora-modal.css, ensuring it is responsive and visually integrated.
- Introduced functionality to copy prompts to the clipboard and handle metadata interactions within the modal.
- Updated media rendering logic in LoraModal.js to incorporate metadata display and improve user experience.
2025-03-27 20:09:48 +08:00
Will Miao
ecd0e05f79 Add MetaFormatParser for Lora_N Model hash format metadata handling
- Introduced MetaFormatParser class to parse metadata from images with Lora_N Model hash format.
- Implemented methods to validate metadata structure, extract prompts, negative prompts, and LoRA information.
- Enhanced error handling and logging for metadata parsing failures.
- Updated RecipeParserFactory to include MetaFormatParser for relevant user comments.
2025-03-27 17:28:11 +08:00
Will Miao
6e3b4178ac Enhance LoraStacker to return active LoRAs in stack_loras method
- Updated RETURN_TYPES and RETURN_NAMES to include active LoRAs.
- Introduced active_loras list to track active LoRAs and their strengths.
- Formatted active_loras for return as a string in the format <lora:lora_name:strength>.
2025-03-27 16:10:50 +08:00
Will Miao
ba18cbabfd Add ComfyMetadataParser for Civitai ComfyUI metadata handling
- Introduced ComfyMetadataParser class to parse metadata from Civitai ComfyUI JSON format.
- Implemented methods to validate metadata structure, extract LoRA and checkpoint information, and retrieve additional model details from Civitai.
- Enhanced error handling and logging for metadata parsing failures.
- Updated RecipeParserFactory to prioritize ComfyMetadataParser for valid JSON inputs.
2025-03-27 15:43:58 +08:00
Will Miao
dec757c23b Refactor image metadata handling in RecipeRoutes and ExifUtils
- Replaced the download function for images from Twitter to Civitai in recipe_routes.py.
- Updated metadata extraction from user comments to a more comprehensive image metadata extraction method in ExifUtils.
- Enhanced the appending of recipe metadata to utilize the new metadata extraction method.
- Added a new utility function to download images from Civitai.
2025-03-27 14:56:37 +08:00
Will Miao
0459710c9b Made CLIP input optional in LoRA Loader, enabling compatibility with Hunyuan workflows 2025-03-26 21:50:26 +08:00
Will Miao
83582ef8a3 Refactor RecipeScanner to remove custom async timeout and streamline cache initialization
- Removed the custom async_timeout function and replaced it with direct usage of the initialization lock.
- Simplified the cache initialization process by eliminating the dependency on the lora scanner.
- Enhanced error handling during cache initialization to ensure a fallback to an empty cache on failure.
2025-03-26 18:56:18 +08:00
Will Miao
0dc396e148 Enhance RecipeModal to support video previews
- Updated RecipeModal.js to dynamically handle video and image previews based on the file type.
- Modified recipe-modal.css to ensure proper styling for both images and videos.
- Adjusted recipe_modal.html to accommodate the new media handling structure.
2025-03-26 16:39:53 +08:00
pixelpaws
86958e1420 Merge pull request #51 from AlUlkesh/main
Python < 3.11 backward compatibility for timeout.
2025-03-26 10:47:23 +08:00
Will Miao
c5b8e629fb Enhance save functionality in LoraModal for base model editing
- Added a check to prevent saving if the base model value has not changed.
- Stored the original value during editing to compare with the new selection.
- Updated the saveBaseModel function to accept the original value for comparison.
2025-03-26 07:05:32 +08:00
Will Miao
b0a495b4f6 Add base model editing functionality to LoraModal
- Introduced new styles for base model display and editing in lora-modal.css.
- Enhanced LoraModal.js to support editing of the base model with a dropdown selector.
- Implemented save functionality for the updated base model, including UI interactions for editing and saving changes.
2025-03-26 06:49:33 +08:00
Will Miao
7d2809467b Update tutorial video link 2025-03-25 14:10:13 +08:00
Will Miao
af90eeaf37 Bump version to 0.8.0 2025-03-25 14:00:00 +08:00
AlUlkesh
509e513f3a Python < 3.11 backward compatibility for timeout. 2025-03-24 14:16:46 +01:00
pixelpaws
80671e474c Update README.md 2025-03-24 08:39:51 +08:00
pixelpaws
a166d859e7 Update README.md 2025-03-24 04:49:28 +08:00
Will Miao
6af1e0aeb7 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-03-24 04:00:02 +08:00
Will Miao
370ffb5d7c Update discord invite 2025-03-24 03:59:44 +08:00
pixelpaws
0ba288d09e Update README.md 2025-03-24 03:49:17 +08:00
Will Miao
008d86983b Update workflow 2025-03-24 03:46:12 +08:00
Will Miao
205bdfce5c Update README.md with new features and enhancements for v0.8.0, including LoRA recipes, improved UI/UX, and workflow integration. Remove outdated screenshot and update Discord link in modals.html. 2025-03-23 16:53:46 +08:00
Will Miao
27248b197d Update cache management in ApiRoutes to remove hash index by file path
- Added functionality to update the hash index by removing entries associated with the specified file path during cache management.
- Ensured that the cache is properly resorted after the removal of raw data items.
2025-03-23 16:50:56 +08:00
Will Miao
e216b4c455 Refactor early access checks in recipe parsers
- Updated the early access condition checks in RecipeFormatParser, StandardMetadataParser, and A1111MetadataParser to use `get` method for improved readability and safety.
- Ensured consistent handling of early access status across different parser classes.
2025-03-23 15:29:47 +08:00
Will Miao
c402f53258 Implement early access handling and UI enhancements for LoRA downloads
- Added error handling for early access restrictions in the API routes, returning appropriate status codes and messages.
- Enhanced the Civitai client to log unauthorized access attempts and provide user-friendly error messages.
- Updated the download manager to check for early access requirements and log warnings accordingly.
- Introduced UI elements to indicate early access status for LoRAs, including badges and warning messages in the import manager.
- Improved toast notifications to inform users about early access download failures and provide relevant information.
2025-03-23 14:45:11 +08:00
Will Miao
93329abe8b Refactor LoraFileHandler to use provided event loop and improve logging
- Updated LoraFileHandler to utilize the passed event loop for time retrieval instead of the current thread's event loop.
- Changed error logging for extension loading in mappers from error to warning level for better clarity.
2025-03-23 09:22:57 +08:00
Will Miao
f69b3d96b6 Update dependencies in pyproject.toml and requirements.txt
- Added new dependencies: piexif, Pillow, and requests to enhance image processing and HTTP request capabilities.
- Ensured consistency between pyproject.toml and requirements.txt by including the same set of dependencies.
2025-03-23 08:48:13 +08:00
Will Miao
8690a8f11a Enhance LoraStackerMapper and WorkflowParser functionality
- Updated LoraStackerMapper to handle multiple formats for lora_stack input, improving flexibility in processing existing stacks.
- Introduced caching for processed node results in WorkflowParser to optimize performance and prevent redundant processing.
- Added a new method to collect loras from model inputs, enhancing the ability to extract relevant data from the workflow.
- Improved handling of processed nodes to avoid cycles and ensure accurate results during workflow parsing.
2025-03-23 07:41:55 +08:00
Will Miao
6aa2342be1 Enhance node processing and error handling in workflow mappers
- Improved reference handling in NodeMapper to support integer node IDs and added error logging for reference processing failures.
- Updated LoraLoaderMapper and LoraStackerMapper to handle lora_stack as a dictionary, ensuring compatibility with new data formats.
- Refactored trace_model_path utility to perform a depth-first search for LoRA nodes, improving the accuracy of model path tracing.
- Cleaned up unused code in parser.py related to LoRA processing, streamlining the workflow parsing logic.
2025-03-23 07:20:50 +08:00
Will Miao
042153329b Update dependencies 2025-03-23 05:42:00 +08:00
Will Miao
2b67091986 Enhance workflow parsing and node mapper registration
- Introduced a new WorkflowParser class to streamline workflow parsing and manage node mappers.
- Added functionality to load external mappers dynamically from a specified directory.
- Refactored LoraLoaderMapper and LoraStackerMapper to handle new data formats for loras and trigger words.
- Updated recipe routes to utilize the new WorkflowParser for parsing workflows.
- Made adjustments to the flux_prompt.json to reflect changes in active states and class types.
2025-03-23 05:21:43 +08:00
Will Miao
3da35cf0db Remove deprecated workflow parameters and associated files
- Deleted the `__init__.py`, `cli.py`, `extension_manager.py`, `integration_example.py`, `README.md`, `simple_test.py`, `test_parser.py`, `verify_workflow.py`, and `workflow_parser.py` files as they are no longer needed.
- Updated `.gitignore` to exclude new output files and test scripts.
- Cleaned up the node processors directory by removing all processor implementations and their registration logic.
2025-03-22 20:43:17 +08:00
Will Miao
e566484a17 Add Civitai URL retrieval functionality and UI integration
- Introduced a new API route to fetch the Civitai URL for a specified LoRA file.
- Implemented error handling for missing LoRA names and absence of Civitai data.
- Added a "View on Civitai" option in the UI, allowing users to access the Civitai URL directly from the LoRA widget.
- Enhanced user feedback for successful and failed URL retrieval attempts.
2025-03-22 17:35:30 +08:00
Will Miao
e7dffbbb1e Refactor LoRA handling in LoraLoader, LoraStacker, and TriggerWordToggle
- Introduced logging to track unexpected formats in LoRA and trigger word data.
- Refactored LoRA processing to support both old and new kwargs formats in LoraLoader and LoraStacker.
- Enhanced trigger word processing to handle different data formats in TriggerWordToggle.
- Improved code readability and maintainability by extracting common logic into helper methods.
2025-03-22 15:56:37 +08:00
Will Miao
a31712ad1f Wrap status badge in a container div for improved layout in ImportManager component 2025-03-22 10:24:01 +08:00
Will Miao
2958f81adc Revert "Refactor path mapping logic in Config class"
This reverts commit fce58f3206.
2025-03-22 10:18:26 +08:00
Will Miao
95380fbbfb Add base model mapping for SD 1.5 2025-03-22 09:49:35 +08:00
Will Miao
4cc6996406 Refactor theme toggle styles for improved positioning
- Updated CSS for the theme toggle component to ensure relative positioning for the container.
- Centered light and dark icons within the theme toggle using absolute positioning and transform properties.
- Added transition effects for opacity to enhance visual feedback during theme changes.
2025-03-22 09:49:15 +08:00
Will Miao
372d74ec71 Enhance settings management and localStorage integration
- Added functionality to load settings from localStorage in the SettingsManager, ensuring user preferences are retained across sessions.
- Updated the state management to initialize settings from localStorage, improving user experience.
- Refactored the UpdateService to streamline update notification preferences.
- Improved migration logic in storageHelpers to prevent duplicate migrations and ensure data integrity.
- Removed unnecessary console logs for cleaner output in various modules.
2025-03-22 08:46:36 +08:00
Will Miao
19ef73a07f Refactor storage handling across application
- Introduced a new storageHelpers module to centralize localStorage interactions, improving code maintainability and readability.
- Updated various components and managers to utilize the new storageHelpers functions for setting, getting, and removing items from localStorage.
- Added migration logic to handle localStorage items during application initialization, ensuring compatibility with the new storage structure.
- Enhanced logging during application initialization for better debugging.
2025-03-22 05:32:18 +08:00
Will Miao
bb3d73b87c Fix support modal width 2025-03-22 04:36:34 +08:00
Will Miao
30e9e7168f Update logging level for parsed workflow and add refresh button to recipe controls
- Changed logging from info to debug for parsed workflow in RecipeRoutes to reduce log verbosity.
- Added a refresh button in the recipe controls section of the HTML template to allow users to reload the recipe list easily.
2025-03-21 21:38:02 +08:00
Will Miao
fce58f3206 Refactor path mapping logic in Config class
- Updated add_path_mapping method to return a boolean indicating success or failure of mapping addition.
- Enhanced link scanning to only continue if a mapping was successfully added.
- Filtered paths to exclude those already mapped, improving efficiency in path handling.
- Added logging for existing mappings to provide better insights during execution.
2025-03-21 21:26:00 +08:00
Will Miao
b3e5ac395f Enhance recipe modal styles and tooltip functionality
- Updated CSS for recipe modal to improve layout and responsiveness, including adjustments to header and badge styles.
- Added tooltip positioning logic to ensure correct display of local-badge tooltips on hover.
- Refactored HTML structure for local status badges to enhance stability and positioning.
- Removed unnecessary console logs from recipe fetching process in JavaScript for cleaner output.
2025-03-21 20:19:58 +08:00
Will Miao
3ebe9d159a Refactor LoraRoutes to return empty recipes when no data is available
- Removed the logic for fetching and formatting recipes from the cache.
- Updated the response to return an empty list for recipes when no data is present, simplifying the flow.
- Adjusted comments for clarity regarding the new behavior.
2025-03-21 20:00:15 +08:00
pixelpaws
ff95274757 Merge pull request #45 from willmiao/dev
Dev
2025-03-21 17:31:42 +08:00
Will Miao
8e653e2173 Refactor recipe saving process to utilize workflow JSON and enhance Lora handling
- Updated the recipe saving logic to accept a workflow JSON input instead of individual fields like name, tags, and metadata.
- Implemented parsing of the workflow to extract generation parameters and Lora stack, improving the recipe creation process.
- Enhanced error handling for missing workflow data and invalid Lora formats.
- Removed deprecated code related to individual field handling, streamlining the recipe saving functionality.
- Updated the front-end widget to send the workflow JSON directly, simplifying the data preparation process.
2025-03-21 17:28:20 +08:00
Will Miao
4bff17aa1a Update prompt configuration and enhance Lora management functionality
- Expanded the prompt.json file with new configurations for KSampler, CheckpointLoaderSimple, and various CLIPTextEncode nodes.
- Introduced additional Lora management features, including a new Lora Stacker and improved trigger word handling.
- Enhanced the loras_widget.js to log the generated prompt when saving recipes directly, aiding in debugging and user feedback.
- Improved overall structure and organization of the prompt configurations for better maintainability.
2025-03-21 16:35:52 +08:00
Will Miao
d4f300645d Enhance ExifUtils to extract prompts from node references in workflows
- Updated the logic in ExifUtils to first identify KSampler nodes and store references to positive and negative prompt nodes.
- Added a helper function to follow these references and extract text content from CLIP Text Encode nodes.
- Implemented a fallback mechanism to extract prompts using traditional methods if references are not available.
- Improved code readability with additional comments and structured handling of node data.
2025-03-21 11:32:51 +08:00
Will Miao
4ee32f02c5 Add functionality to save recipes from the LoRAs widget
- Introduced a new API endpoint to save recipes directly from the LoRAs widget.
- Implemented logic to handle recipe data, including image processing and metadata extraction.
- Enhanced error handling for missing fields and image retrieval.
- Updated the ExifUtils to extract generation parameters from images for recipe creation.
- Added a direct save option in the widget, improving user experience.
2025-03-21 11:11:09 +08:00
Will Miao
2cf4440a1e Add Android icons and site.webmanifest for PWA support
- Added new Android icon images (192x192 and 512x512) for better app integration.
- Created site.webmanifest file to define app metadata and icon usage for Progressive Web App (PWA) functionality.
2025-03-21 05:37:58 +08:00
Will Miao
644ee31654 Remove site.webmanifest and refactor state initialization in RecipeManager and HeaderManager
- Deleted the site.webmanifest file as it is no longer needed.
- Simplified state management by removing initPageState from RecipeManager and integrating it into HeaderManager.
- Cleaned up console logging in loraApi.js to reduce unnecessary output.
- Minor formatting adjustments in FilterManager to enhance code readability.
2025-03-21 05:22:20 +08:00
Will Miao
34078d8a60 Refactor recipe card styles and update HTML structure
- Migrated CSS styles from recipe-card.css to card.css for better organization.
- Updated recipe card class names in HTML to align with new styling conventions.
- Enhanced card layout with additional flex properties for improved responsiveness.
- Adjusted infinite scroll debounce timing for better performance.
2025-03-20 21:42:17 +08:00
Will Miao
5cfae7198d Refactor recipe metadata handling and update CSS styles
- Moved the recipe metadata appending logic to occur after the JSON creation for better workflow.
- Adjusted the user comment formatting in ExifUtils to include a newline for improved readability.
- Reduced the maximum height of the recipe modal bottom section for better layout consistency.
2025-03-20 19:53:05 +08:00
Will Miao
6a10cda61f Add recipe metadata handling in image processing
- Implemented functionality to append recipe metadata to images during the recipe creation process.
- Removed redundant metadata handling from the temporary image processing step, streamlining the image handling logic.
- Enhanced the overall image processing workflow for better integration of recipe data.
2025-03-20 18:55:00 +08:00
Will Miao
c149e73ef7 Add recipe tags functionality to RecipeModal
- Implemented display of recipe tags in a compact format within the RecipeModal.
- Added tooltip for additional tags with hover functionality.
- Updated CSS styles for recipe tags and tooltips to enhance visual presentation.
- Adjusted layout and padding in related components for improved aesthetics.
2025-03-20 17:57:35 +08:00
Will Miao
b11757c913 Fix infinite scroll 2025-03-20 17:31:56 +08:00
Will Miao
607ab35cce Refactor search functionality in Lora and Recipe scanners to utilize fuzzy matching
- Introduced a new fuzzy_match utility function for improved search accuracy across Lora and Recipe scanners.
- Updated search logic in LoraScanner and RecipeScanner to leverage fuzzy matching for titles, tags, and filenames, enhancing user experience.
- Removed deprecated search methods to streamline the codebase and improve maintainability.
- Adjusted API routes to ensure compatibility with the new search options, including recursive search handling.
2025-03-20 16:55:51 +08:00
Will Miao
19ff2ebfe1 Refactor RecipeManager and ImportManager for improved functionality
- Removed deprecated global functions from RecipeManager to streamline the API and enhance clarity.
- Updated the import handling in ImportManager to directly call loadRecipes on the RecipeManager, ensuring better integration.
- Adjusted the recipes.html template to utilize the ImportManager for showing the import modal, improving code consistency.
2025-03-20 15:57:00 +08:00
Will Miao
4a47dc2073 Add new API routes for base models and update existing routes
- Introduced a new endpoint for retrieving base models used in loras, enhancing the API functionality.
- Updated the existing top-tags route to reflect the new URL structure under '/api/loras'.
- Modified the FilterManager to accommodate the new base models API, ensuring proper data fetching and display on the loras page.
- Improved error handling and logging for base model retrieval, enhancing overall robustness of the application.
2025-03-20 15:19:05 +08:00
Will Miao
addf92d966 Refactor API routes and enhance recipe and filter management
- Removed the handle_get_recipes method from ApiRoutes to streamline the API structure.
- Updated RecipeRoutes to include logging for recipe retrieval requests and improved filter management.
- Consolidated filter management logic in FilterManager to support both recipes and loras, enhancing code reusability.
- Deleted obsolete LoraSearchManager and RecipeSearchManager classes to simplify the search functionality.
- Improved infinite scroll implementation for both recipes and loras, ensuring consistent loading behavior across pages.
2025-03-20 14:54:13 +08:00
Will Miao
c987338c84 Add Checkpoints feature with routes, template, and JavaScript integration
- Introduced CheckpointsRoutes for managing checkpoints-related endpoints and handling the checkpoints page.
- Added checkpoints.html template for rendering the checkpoints interface with a work-in-progress message.
- Implemented checkpoints.js to manage the initialization of the Checkpoints page and its components.
- Updated LoraManager to include checkpoints routes in the application setup, enhancing overall functionality.
2025-03-20 10:50:46 +08:00
Will Miao
a88b0239eb Refactor panel position management and enhance recipe card handling
- Removed redundant updatePanelPositions calls from various components and centralized the logic in the uiHelpers.js for better maintainability.
- Introduced appendRecipeCards function in RecipeManager to streamline the addition of recipe cards from search results.
- Cleaned up unused code related to search input handling and recipe loading, improving overall code clarity and performance.
- Updated HeaderManager and SearchManager to utilize the new updatePanelPositions function, ensuring consistent panel positioning across the application.
2025-03-20 09:54:13 +08:00
Will Miao
caf5b1528c Enhance recipe search functionality with improved state management and search options
- Introduced new search options for recipes, allowing users to filter by title, tags, LoRA filename, and LoRA model name.
- Updated the RecipeRoutes and RecipeScanner to accommodate the new search options, enhancing the filtering capabilities.
- Refactored RecipeManager and RecipeSearchManager to utilize the hierarchical state structure for managing search parameters and pagination state.
- Improved the user interface by dynamically displaying relevant search options based on the current page context.
2025-03-20 08:27:38 +08:00
Will Miao
90f74018ae Refactor state management to support hierarchical structure and page-specific states
- Introduced a new hierarchical state structure to manage global and page-specific states, enhancing organization and maintainability.
- Updated various managers and components to utilize the new state structure, ensuring consistent access to page-specific data.
- Removed the initSettings function and replaced it with initPageState for better initialization of page-specific states.
- Adjusted imports across multiple files to accommodate the new state management approach, improving code clarity.
2025-03-19 21:12:04 +08:00
Will Miao
d7a253cba3 Update LoraModal to enhance preset value configuration and file path retrieval
- Adjusted preset value min, max, and step properties for improved functionality based on selected options.
- Refactored file path retrieval to ensure consistency by targeting the specific modal context, enhancing code clarity and maintainability.
2025-03-19 20:53:15 +08:00
Will Miao
8a28846bac Merge branch 'main' into dev 2025-03-19 17:34:29 +08:00
Will Miao
04545c5706 Implement lazy loading and infinite scroll features in core application
- Added lazy loading for images and initialized infinite scroll in the AppCore class to enhance performance across various pages.
- Updated LoraPageManager and RecipeManager to utilize the new initializePageFeatures method for common UI features.
- Enhanced infinite scroll functionality to dynamically load more content based on the page type, improving user experience.
- Refactored recipes.html to trigger the import modal through the ModalManager for better modal handling.
2025-03-19 17:04:58 +08:00
Will Miao
32fa81cf93 Refactor ModalManager to conditionally register modals based on their existence
- Updated ModalManager to check for the presence of modals before registration, improving performance and preventing errors on pages without certain modals.
- Added support for closing modals when clicking outside of them, enhancing user experience.
- Ensured consistent handling of modal display properties across various modal types.
2025-03-19 16:36:07 +08:00
Will Miao
7924e4000c Refactor LoraModal and RecipeSearchManager for improved functionality and performance
- Updated LoraModal to enhance lazy loading and scroll behavior, utilizing MutationObserver for dynamic content changes and adding a new helper function for the back-to-top button.
- Modified RecipeSearchManager to ensure proper recipe loading through the window.recipeManager object, improving reliability in recipe reloading.
- Added additional components to loras.html for better modularity and organization of the modal structure.
2025-03-19 16:15:18 +08:00
Will Miao
f9c54690b0 Refactor logging and improve image optimization in RecipeRoutes and ExifUtils
- Removed print statements for initialization and setup in RecipeRoutes to reduce console clutter and improve logging practices.
- Updated image optimization parameters in RecipeRoutes to enhance image quality by increasing the target width.
- Modified user comment handling in ExifUtils to ensure proper formatting when appending recipe metadata, improving metadata consistency.
2025-03-19 14:50:36 +08:00
Will Miao
c3aaef3916 Enhance image handling and EXIF metadata processing in RecipeRoutes and ExifUtils
- Implemented image optimization in RecipeRoutes, resizing and converting uploaded images to WebP format while preserving metadata.
- Updated ExifUtils to support EXIF data handling for WebP images, ensuring compatibility with various image formats.
- Added a new method for optimizing images, allowing for better performance and quality in image uploads.
2025-03-19 14:17:37 +08:00
Will Miao
03dfe13769 Remove supportModal.html and refactor error-message styles across multiple CSS files for consistency
- Deleted supportModal.html as it is no longer needed.
- Removed duplicate error-message styles from download-modal.css, import-modal.css, and lora-modal.css.
- Consolidated error-message styles into shared.css to ensure consistent styling across components.
2025-03-19 10:10:27 +08:00
Will Miao
f38b51b85a Enhance RecipeScanner and CSS components for improved functionality and styling
- Added localPath retrieval for LoRA entries in RecipeScanner to enhance metadata handling.
- Included shared.css in the main stylesheet for better component styling consistency.
- Removed unused local-badge and local-path styles from download-modal.css and recipe-modal.css to streamline the CSS and improve maintainability.
2025-03-19 08:21:51 +08:00
Will Miao
0017a6cce5 Update A1111MetadataParser to correctly extract model ID, name, and version from Civitai info
- Changed the extraction of model ID to use 'id' instead of 'modelVersionId'.
- Updated the retrieval of model name and version to align with the new Civitai response structure, ensuring accurate metadata parsing for LoRA entries.
- Improved error handling and logging for better traceability during metadata fetching.
2025-03-19 05:49:53 +08:00
Will Miao
541ad624c5 Implement input-with-button layout in import modal for improved user experience
- Added a new input-with-button component to the import modal, allowing users to input an image URL and fetch the image with a button click.
- Removed the previous button placement to streamline the UI and enhance usability.
- Updated CSS styles for the new component to ensure proper layout and responsiveness.
2025-03-19 05:24:28 +08:00
Will Miao
7c56825f9b Enhance import functionality for recipes with image upload and URL support
- Added support for importing recipes via image upload or URL input in the ImportManager.
- Implemented toggle functionality to switch between upload and URL modes, updating the UI accordingly.
- Enhanced error handling for missing fields and invalid URLs during the import process.
- Updated the RecipeRoutes to analyze images from both uploaded files and URLs, returning appropriate metadata.
- Improved the import modal UI to accommodate new input methods and provide clearer user feedback.
2025-03-19 05:13:44 +08:00
Will Miao
8a871ae643 Refactor EXIF data extraction and enhance recipe metadata parsing
- Updated ExifUtils to handle both JPEG/TIFF and non-JPEG/TIFF images for extracting UserComment from EXIF data, improving compatibility with various image formats.
- Introduced A1111MetadataParser to support parsing of images with A1111 metadata format, extracting prompts, negative prompts, and LoRA information.
- Enhanced error handling and logging for metadata parsing processes, ensuring better traceability and debugging capabilities.
2025-03-18 20:36:58 +08:00
Will Miao
e2191ab4b4 Refactor recipe metadata processing in RecipeRoutes
- Introduced a new RecipeParserFactory to streamline the parsing of recipe metadata from user comments, supporting multiple formats.
- Removed legacy metadata extraction logic from RecipeRoutes, delegating responsibilities to the new parser classes.
- Enhanced error handling for cases where no valid parser is found, ensuring graceful responses.
- Updated the RecipeScanner to improve the handling of LoRA metadata and reduce logging verbosity for better performance.
2025-03-18 18:54:22 +08:00
Will Miao
4264dd19a8 Enhance recipe metadata handling in RecipeRoutes and ExifUtils
- Added functionality to extract and process existing recipe metadata from images, including LoRA details and Civitai information.
- Updated ExifUtils to manage recipe metadata more effectively, including appending and removing metadata from user comments.
- Improved the ImportManager to utilize recipe metadata for setting default recipe names and tags when importing shared recipes.
2025-03-18 16:49:04 +08:00
Will Miao
78f8d4ecc7 Add sharing functionality for recipes
- Introduced new endpoints for sharing recipes and downloading shared images in RecipeRoutes.
- Implemented logic to process recipe images and append metadata to EXIF data.
- Updated RecipeCard component to handle sharing via API calls, providing user feedback during the process.
- Enhanced error handling for missing recipe IDs and failed API responses.
2025-03-18 14:52:21 +08:00
Will Miao
e2cc3145de Update refs 2025-03-18 14:21:22 +08:00
Will Miao
710857dd41 checkpoint 2025-03-17 19:58:17 +08:00
Will Miao
1bfe12a288 Add filter button functionality and clean up recipe template scripts
- Implemented click handler for the filter button in FilterManager to toggle the filter panel.
- Removed redundant recipe filter manager initialization from recipes.html for cleaner code.
- Updated header.html to remove inline JavaScript for filter button, enhancing maintainability.
2025-03-17 17:41:41 +08:00
Will Miao
14a88e2cfa update 2025-03-17 16:55:19 +08:00
Will Miao
0580130d47 Fix lora page header 2025-03-17 15:53:53 +08:00
Will Miao
a4ee82b51f checkpoint 2025-03-17 15:10:11 +08:00
Will Miao
1034282161 Enhance LoRA and Recipe templates by adding request context to template rendering. Update JavaScript to initialize search managers with context-specific options and improve header navigation with dynamic search placeholders. Refactor header component for better context awareness in search functionality. 2025-03-17 10:11:50 +08:00
Will Miao
b0a8b0cc6f Implement share functionality in RecipeCard component to enable image downloads. Adjust recipe indicator position in CSS for improved layout. 2025-03-17 06:10:43 +08:00
Will Miao
3f38764a0e Add filter-related endpoints to RecipeRoutes for top tags and base models. Enhance get_paginated_data method in RecipeScanner to support filtering by base model and tags. Implement logic to retrieve and count occurrences of top tags and base models from cached recipes. 2025-03-16 21:21:00 +08:00
Will Miao
3338c17e8f Refactor recipe processing in RecipeRoutes to enhance LoRA handling. Introduce base model counting logic to determine the most common base model from LoRAs, and streamline the collection of LoRA metadata. Remove outdated metadata update method from RecipeScanner to improve code clarity and maintainability. 2025-03-16 18:56:27 +08:00
Will Miao
22085e5174 Add delete confirmation modal for recipes with updated styling and functionality. Implement modal content generation, event handling for delete and cancel actions, and integrate with modal manager for improved user experience. Enhance CSS for delete preview image display. 2025-03-16 18:17:19 +08:00
Will Miao
d7c643ee9b Enhance LoRA management by introducing deletion status and UI updates. Implement warning indicators for deleted LoRAs in the import modal, update cache handling for added and removed recipes, and improve styling for deleted items. Adjust logic to exclude deleted LoRAs from download prompts and ensure proper display of their status in the UI. 2025-03-16 17:59:55 +08:00
Will Miao
406284a045 checkpoint 2025-03-16 16:56:33 +08:00
Will Miao
50babfd471 Update modal CSS to allow scrolling by changing overflow property from hidden to auto. Adjust max-height to account for header height while maintaining reduced top margin. 2025-03-15 20:41:10 +08:00
Will Miao
edd36427ac Refactor recipe management to enhance initialization and metadata handling. Improve error logging during cache pre-warming, streamline recipe data structure, and ensure proper handling of generation parameters. Update UI components for missing LoRAs with improved summary and toggle functionality. Add new methods for adding recipes to cache and loading recipe data from JSON files. 2025-03-15 20:08:26 +08:00
Will Miao
9f2289329c Implement enhanced loading progress display in DownloadManager and ImportManager. Introduce detailed progress updates and UI elements for current item and overall progress during downloads. Update LoadingManager to support dynamic progress visualization. 2025-03-15 16:25:56 +08:00
Will Miao
9a1fe19cc8 Enhance DownloadManager and LoraFileHandler to support dynamic ignore path management with expiration times. Added handling for alternative path formats and improved logging for added and removed paths. 2025-03-15 14:58:40 +08:00
Will Miao
09f5e2961e Bump version to 0.7.39 2025-03-15 10:58:55 +08:00
Will Miao
756ad399bf Enhance LoraManagerLoader to include formatted loaded_loras in return values, improving data output for loaded LoRAs. 2025-03-15 10:45:32 +08:00
Will Miao
02adced7b8 Fix path formatting in LoraStacker to ensure compatibility across different operating systems by replacing '/' with os.sep. 2025-03-15 10:45:16 +08:00
Will Miao
9059795816 Enhance DownloadManager to update hash index with new LoRA entries, improving file tracking during downloads. 2025-03-15 10:16:52 +08:00
Will Miao
6920944724 Refactor API and DownloadManager to utilize version-level properties for model file existence and size, improving data handling and UI responsiveness. 2025-03-15 09:56:41 +08:00
Will Miao
c76b287aed Normalize SHA256 hash handling by converting to lowercase in LoraScanner and LoraMetadata classes for consistency. 2025-03-15 09:56:28 +08:00
Will Miao
5c62ec1177 checkpoint 2025-03-15 09:53:50 +08:00
Will Miao
09b2fdfc59 Refactor API and DownloadManager to utilize version-level properties for model file existence and size, improving data handling and UI responsiveness. 2025-03-15 09:45:07 +08:00
Will Miao
e498c9ce29 Normalize SHA256 hash handling by converting to lowercase in LoraScanner and LoraMetadata classes for consistency. 2025-03-15 07:25:00 +08:00
Will Miao
9bb4d7078e checkpoint 2025-03-15 05:29:25 +08:00
Will Miao
5e4d2c7760 checkpoint 2025-03-14 21:10:24 +08:00
Will Miao
426e84cfa3 checkpoint 2025-03-14 16:37:52 +08:00
Will Miao
b77df8f89f Merge branch 'main' into dev 2025-03-14 11:45:18 +08:00
Will Miao
f7c946778d Bump version to 0.7.38. fix: correct LoRA naming issue when fetching data from Civitai 2025-03-14 11:23:07 +08:00
Will Miao
81599b8f43 Fix: correct LoRA naming issue when fetching data from Civitai 2025-03-14 11:22:21 +08:00
Will Miao
9c0dcb2853 checkpoint 2025-03-14 11:04:58 +08:00
Will Miao
d3e4534673 Refactor model name editing functionality in LoraModal; update styles for improved user interaction and accessibility 2025-03-13 22:11:51 +08:00
Will Miao
dd81c86540 Enhance folder tag functionality and layout; update styles for action buttons and toggle behavior 2025-03-13 21:23:24 +08:00
Will Miao
3620376c3c Add search and filter functionality to header; adjust styles for responsiveness 2025-03-13 21:02:54 +08:00
Will Miao
444e8004c7 update 2025-03-13 20:55:35 +08:00
Will Miao
0b0caa1142 Fix layout 2025-03-13 20:37:23 +08:00
Will Miao
e7233c147d checkpoint 2025-03-13 15:04:18 +08:00
Will Miao
004c203ef2 Merge branch 'main' into dev 2025-03-13 11:45:43 +08:00
Will Miao
db04c349a7 Bump version to 0.7.37-bugfix for release preparation 2025-03-13 11:11:51 +08:00
Will Miao
e57a72d12b Fixed an issue caused by inconsistent base model name for Illustrious. It fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/37 2025-03-13 11:00:55 +08:00
Will Miao
c88388da67 Refactor toggle switch styles for update preferences in the modal 2025-03-13 10:00:32 +08:00
Will Miao
d69406c4cb checkpoint 2025-03-09 15:42:00 +08:00
Will Miao
250e8445bb checkpoint 2025-03-09 12:29:24 +08:00
Will Miao
e6aafe8773 Add recipes checkpoint 2025-03-08 23:10:24 +08:00
124 changed files with 18647 additions and 2300 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,5 @@
__pycache__/
settings.json
settings.json
output/*
py/run_test.py
.vscode/

105
README.md
View File

@@ -1,63 +1,58 @@
# ComfyUI LoRA Manager
A web-based management interface designed to help you organize and manage your local LoRA models in ComfyUI. Access the interface at: `http://localhost:8188/loras`
> **Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!**
![Interface Preview](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/Screenshot%202025-01-27%20172349.png)
[![Discord](https://img.shields.io/discord/1346296675538571315?color=7289DA&label=Discord&logo=discord&logoColor=white)](https://discord.gg/vcqNrWVFvM)
[![Release](https://img.shields.io/github/v/release/willmiao/ComfyUI-Lora-Manager?include_prereleases&color=blue&logo=github)](https://github.com/willmiao/ComfyUI-Lora-Manager/releases)
[![Release Date](https://img.shields.io/github/release-date/willmiao/ComfyUI-Lora-Manager?color=green&logo=github)](https://github.com/willmiao/ComfyUI-Lora-Manager/releases)
A comprehensive toolset that streamlines organizing, downloading, and applying LoRA models in ComfyUI. With powerful features like recipe management and one-click workflow integration, working with LoRAs becomes faster, smoother, and significantly easier. Access the interface at: `http://localhost:8188/loras`
![Interface Preview](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/screenshot.png)
## 📺 Tutorial: One-Click LoRA Integration
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
[![One-Click LoRA Integration Tutorial](https://img.youtube.com/vi/qS95OjX3e70/0.jpg)](https://youtu.be/qS95OjX3e70)
[![LoRA Manager v0.8.0 - New Recipe Feature & Bulk Operations](https://img.youtube.com/vi/noN7f_ER7yo/0.jpg)](https://youtu.be/noN7f_ER7yo)
---
## Release Notes
### v0.7.37
* Added NSFW content control settings (blur mature content and SFW-only filter)
* Implemented intelligent blur effects for previews and showcase media
* Added manual content rating option through context menu
* Enhanced user experience with configurable content visibility
* Fixed various bugs and improved stability
### v0.8.4
* **Node Layout Improvements** - Fixed layout issues with LoRA Loader and Trigger Words Toggle nodes in newer ComfyUI frontend versions
* **Recipe LoRA Reconnection** - Added ability to reconnect deleted LoRAs in recipes by clicking the "deleted" badge in recipe details
* **Bug Fixes & Stability** - Resolved various issues for improved reliability
### v0.7.36
* Enhanced LoRA details view with model descriptions and tags display
* Added tag filtering system for improved model discovery
* Implemented editable trigger words functionality
* Improved TriggerWord Toggle node with new group mode option for granular control
* Added new Lora Stacker node with cross-compatibility support (works with efficiency nodes, ComfyRoll, easy-use, etc.)
* Fixed several bugs
### v0.8.3
* **Enhanced Workflow Parser** - Rebuilt workflow analysis engine with improved support for ComfyUI core nodes and easier extensibility
* **Improved Recipe System** - Refined the experimental Save Recipe functionality with better workflow integration
* **New Save Image Node** - Added experimental node with metadata support for perfect CivitAI compatibility
* Supports dynamic filename prefixes with variables [1](https://github.com/nkchocoai/ComfyUI-SaveImageWithMetaData?tab=readme-ov-file#filename_prefix)
* **Default LoRA Root Setting** - Added configuration option for setting your preferred LoRA directory
### v0.7.35-beta
* Added base model filtering
* Implemented bulk operations (copy syntax, move multiple LoRAs)
* Added ability to edit LoRA model names in details view
* Added update checker with notification system
* Added support modal for user feedback and community links
### v0.8.2
* **Faster Initialization for Forge Users** - Improved first-run efficiency by utilizing existing `.json` and `.civitai.info` files from Forges CivitAI helper extension, making migration smoother.
* **LoRA Filename Editing** - Added support for renaming LoRA files directly within LoRA Manager.
* **Recipe Editing** - Users can now edit recipe names and tags.
* **Retain Deleted LoRAs in Recipes** - Deleted LoRAs will remain listed in recipes, allowing future functionality to reconnect them once re-obtained.
* **Download Missing LoRAs from Recipes** - Easily fetch missing LoRAs associated with a recipe.
### v0.7.33
* Enhanced LoRA Loader node with visual strength adjustment widgets
* Added toggle switches for LoRA enable/disable
* Implemented image tooltips for LoRA preview
* Added TriggerWord Toggle node with visual word selection
* Fixed various bugs and improved stability
### v0.8.1
* **Base Model Correction** - Added support for modifying base model associations to fix incorrect metadata for non-CivitAI LoRAs
* **LoRA Loader Flexibility** - Made CLIP input optional for model-only workflows like Hunyuan video generation
* **Expanded Recipe Support** - Added compatibility with 3 additional recipe metadata formats
* **Enhanced Showcase Images** - Generation parameters now displayed alongside LoRA preview images
* **UI Improvements & Bug Fixes** - Various interface refinements and stability enhancements
### v0.7.3
* Added "Lora Loader (LoraManager)" custom node for workflows
* Implemented one-click LoRA integration
* Added direct copying of LoRA syntax from manager interface
* Added automatic preset strength value application
* Added automatic trigger word loading
### v0.7.0
* Added direct CivitAI integration for downloading LoRAs
* Implemented version selection for model downloads
* Added target folder selection for downloads
* Added context menu with quick actions
* Added force refresh for CivitAI data
* Implemented LoRA movement between folders
* Added personal usage tips and notes for LoRAs
* Improved performance for details window
### v0.8.0
* **Introduced LoRA Recipes** - Create, import, save, and share your favorite LoRA combinations
* **Recipe Management System** - Easily browse, search, and organize your LoRA recipes
* **Workflow Integration** - Save recipes directly from your workflow with generation parameters preserved
* **Simplified Workflow Application** - Quickly apply saved recipes to new projects
* **Enhanced UI & UX** - Improved interface design and user experience
* **Bug Fixes & Stability** - Resolved various issues and enhanced overall performance
[View Update History](./update_logs.md)
@@ -91,6 +86,12 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
- Trigger words at a glance
- One-click workflow integration with preset values
- 🧩 **LoRA Recipes**
- Save and share favorite LoRA combinations
- Preserve generation parameters for future reference
- Quick application to workflows
- Import/export functionality for community sharing
- 💻 **User Friendly**
- One-click access from ComfyUI menu
- Context menu for quick actions
@@ -136,6 +137,15 @@ pip install requirements.txt
---
## Credits
This project has been inspired by and benefited from other excellent ComfyUI extensions:
- [ComfyUI-SaveImageWithMetaData](https://github.com/Comfy-Community/ComfyUI-SaveImageWithMetaData) - For the image metadata functionality
- [rgthree-comfy](https://github.com/rgthree/rgthree-comfy) - For the lora loader functionality
---
## Contributing
If you have suggestions, bug reports, or improvements, feel free to open an issue or contribute directly to the codebase. Pull requests are always welcome!
@@ -154,12 +164,3 @@ Join our Discord community for support, discussions, and updates:
[Discord Server](https://discord.gg/vcqNrWVFvM)
---
## 🗺️ Roadmap
- ✅ One-click integration of LoRAs into ComfyUI workflows with preset strength values
- 🤝 Improved usage tips retrieval from CivitAI model pages
- 🔌 Integration with Power LoRA Loader and other management tools
- 🛡️ Configurable NSFW level settings for content filtering
---

View File

@@ -2,15 +2,17 @@ from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraManagerLoader
from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.lora_stacker import LoraStacker
from .py.nodes.save_image import SaveImage
NODE_CLASS_MAPPINGS = {
LoraManagerLoader.NAME: LoraManagerLoader,
TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker
LoraStacker.NAME: LoraStacker,
SaveImage.NAME: SaveImage
}
WEB_DIRECTORY = "./web/comfyui"
# Register routes on import
LoraManager.add_routes()
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']
__all__ = ['NODE_CLASS_MAPPINGS', 'WEB_DIRECTORY']

View File

@@ -17,6 +17,7 @@ class Config:
# 静态路由映射字典, target to route mapping
self._route_mappings = {}
self.loras_roots = self._init_lora_paths()
self.temp_directory = folder_paths.get_temp_directory()
# 在初始化时扫描符号链接
self._scan_symbolic_links()
@@ -84,12 +85,23 @@ class Config:
mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path
return path
def map_link_to_path(self, link_path: str) -> str:
"""将符号链接路径映射回实际路径"""
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
# 检查路径是否包含在任何映射的目标路径中
for target_path, link_path in self._path_mappings.items():
if normalized_link.startswith(target_path):
# 如果路径以目标路径开头,则替换为实际路径
mapped_path = normalized_link.replace(target_path, link_path, 1)
return mapped_path
return link_path
def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings"""
paths = list(set(path.replace(os.sep, "/")
paths = sorted(set(path.replace(os.sep, "/")
for path in folder_paths.get_folder_paths("loras")
if os.path.exists(path)))
if os.path.exists(path)), key=lambda p: p.lower())
print("Found LoRA roots:", "\n - " + "\n - ".join(paths))
if not paths:

View File

@@ -4,9 +4,13 @@ from server import PromptServer # type: ignore
from .config import config
from .routes.lora_routes import LoraRoutes
from .routes.api_routes import ApiRoutes
from .routes.recipe_routes import RecipeRoutes
from .routes.checkpoints_routes import CheckpointsRoutes
from .services.lora_scanner import LoraScanner
from .services.recipe_scanner import RecipeScanner
from .services.file_monitor import LoraFileMonitor
from .services.lora_cache import LoraCache
from .services.recipe_cache import RecipeCache
import logging
logger = logging.getLogger(__name__)
@@ -56,36 +60,42 @@ class LoraManager:
# Setup feature routes
routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
# Setup file monitoring
monitor = LoraFileMonitor(routes.scanner, config.loras_roots)
monitor.start()
routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
ApiRoutes.setup_routes(app, monitor)
RecipeRoutes.setup_routes(app)
# Store monitor in app for cleanup
app['lora_monitor'] = monitor
# Schedule cache initialization using the application's startup handler
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner))
app.on_startup.append(lambda app: cls._schedule_cache_init(routes.scanner, routes.recipe_scanner))
# Add cleanup
app.on_shutdown.append(cls._cleanup)
app.on_shutdown.append(ApiRoutes.cleanup)
@classmethod
async def _schedule_cache_init(cls, scanner: LoraScanner):
async def _schedule_cache_init(cls, scanner: LoraScanner, recipe_scanner: RecipeScanner):
"""Schedule cache initialization in the running event loop"""
try:
# 创建低优先级的初始化任务
asyncio.create_task(cls._initialize_cache(scanner), name='lora_cache_init')
lora_task = asyncio.create_task(cls._initialize_lora_cache(scanner), name='lora_cache_init')
# Schedule recipe cache initialization with a delay to let lora scanner initialize first
recipe_task = asyncio.create_task(cls._initialize_recipe_cache(recipe_scanner, delay=2), name='recipe_cache_init')
except Exception as e:
print(f"LoRA Manager: Error scheduling cache initialization: {e}")
logger.error(f"LoRA Manager: Error scheduling cache initialization: {e}")
@classmethod
async def _initialize_cache(cls, scanner: LoraScanner):
"""Initialize cache in background"""
async def _initialize_lora_cache(cls, scanner: LoraScanner):
"""Initialize lora cache in background"""
try:
# 设置初始缓存占位
scanner._cache = LoraCache(
@@ -98,10 +108,29 @@ class LoraManager:
# 分阶段加载缓存
await scanner.get_cached_data(force_refresh=True)
except Exception as e:
print(f"LoRA Manager: Error initializing cache: {e}")
logger.error(f"LoRA Manager: Error initializing lora cache: {e}")
@classmethod
async def _initialize_recipe_cache(cls, scanner: RecipeScanner, delay: float = 2.0):
"""Initialize recipe cache in background with a delay"""
try:
# Wait for the specified delay to let lora scanner initialize first
await asyncio.sleep(delay)
# Set initial empty cache
scanner._cache = RecipeCache(
raw_data=[],
sorted_by_name=[],
sorted_by_date=[]
)
# Force refresh to load the actual data
await scanner.get_cached_data(force_refresh=True)
except Exception as e:
logger.error(f"LoRA Manager: Error initializing recipe cache: {e}")
@classmethod
async def _cleanup(cls, app):
"""Cleanup resources"""
if 'lora_monitor' in app:
app['lora_monitor'].stop()
app['lora_monitor'].stop()

View File

@@ -1,3 +1,4 @@
import logging
from nodes import LoraLoader
from comfy.comfy_types import IO # type: ignore
from ..services.lora_scanner import LoraScanner
@@ -6,6 +7,8 @@ import asyncio
import os
from .utils import FlexibleOptionalInputType, any_type
logger = logging.getLogger(__name__)
class LoraManagerLoader:
NAME = "Lora Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@@ -15,7 +18,7 @@ class LoraManagerLoader:
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
# "clip": ("CLIP",),
"text": (IO.STRING, {
"multiline": True,
"dynamicPrompts": True,
@@ -26,8 +29,8 @@ class LoraManagerLoader:
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING)
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words")
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras"
async def get_lora_info(self, lora_name):
@@ -55,11 +58,29 @@ class LoraManagerLoader:
basename = os.path.basename(lora_path)
return os.path.splitext(basename)[0]
def load_loras(self, model, clip, text, **kwargs):
def _get_loras_list(self, kwargs):
"""Helper to extract loras list from either old or new kwargs format"""
if 'loras' not in kwargs:
return []
loras_data = kwargs['loras']
# Handle new format: {'loras': {'__value__': [...]}}
if isinstance(loras_data, dict) and '__value__' in loras_data:
return loras_data['__value__']
# Handle old format: {'loras': [...]}
elif isinstance(loras_data, list):
return loras_data
# Unexpected format
else:
logger.warning(f"Unexpected loras format: {type(loras_data)}")
return []
def load_loras(self, model, text, **kwargs):
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
loaded_loras = []
all_trigger_words = []
clip = kwargs.get('clip', None)
lora_stack = kwargs.get('lora_stack', None)
# First process lora_stack if available
if lora_stack:
@@ -74,26 +95,30 @@ class LoraManagerLoader:
all_trigger_words.extend(trigger_words)
loaded_loras.append(f"{lora_name}: {model_strength}")
# Then process loras from kwargs
if 'loras' in kwargs:
for lora in kwargs['loras']:
if not lora.get('active', False):
continue
lora_name = lora['name']
strength = float(lora['strength'])
# Then process loras from kwargs with support for both old and new formats
loras_list = self._get_loras_list(kwargs)
for lora in loras_list:
if not lora.get('active', False):
continue
# Get lora path and trigger words
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
# Apply the LoRA using the resolved path
model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength)
loaded_loras.append(f"{lora_name}: {strength}")
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
lora_name = lora['name']
strength = float(lora['strength'])
# Get lora path and trigger words
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
# Apply the LoRA using the resolved path
model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength)
loaded_loras.append(f"{lora_name}: {strength}")
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format loaded_loras as <lora:lora_name:strength> separated by spaces
formatted_loras = " ".join([f"<lora:{name.split(':')[0].strip()}:{str(strength).strip()}>"
for name, strength in [item.split(':') for item in loaded_loras]])
return (model, clip, trigger_words_text)
return (model, clip, trigger_words_text, formatted_loras)

View File

@@ -4,6 +4,9 @@ from ..config import config
import asyncio
import os
from .utils import FlexibleOptionalInputType, any_type
import logging
logger = logging.getLogger(__name__)
class LoraStacker:
NAME = "Lora Stacker (LoraManager)"
@@ -23,8 +26,8 @@ class LoraStacker:
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("LORA_STACK", IO.STRING)
RETURN_NAMES = ("LORA_STACK", "trigger_words")
RETURN_TYPES = ("LORA_STACK", IO.STRING, IO.STRING)
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
FUNCTION = "stack_loras"
async def get_lora_info(self, lora_name):
@@ -52,9 +55,27 @@ class LoraStacker:
basename = os.path.basename(lora_path)
return os.path.splitext(basename)[0]
def _get_loras_list(self, kwargs):
"""Helper to extract loras list from either old or new kwargs format"""
if 'loras' not in kwargs:
return []
loras_data = kwargs['loras']
# Handle new format: {'loras': {'__value__': [...]}}
if isinstance(loras_data, dict) and '__value__' in loras_data:
return loras_data['__value__']
# Handle old format: {'loras': [...]}
elif isinstance(loras_data, list):
return loras_data
# Unexpected format
else:
logger.warning(f"Unexpected loras format: {type(loras_data)}")
return []
def stack_loras(self, text, **kwargs):
"""Stacks multiple LoRAs based on the kwargs input without loading them."""
stack = []
active_loras = []
all_trigger_words = []
# Process existing lora_stack if available
@@ -67,25 +88,31 @@ class LoraStacker:
_, trigger_words = asyncio.run(self.get_lora_info(lora_name))
all_trigger_words.extend(trigger_words)
if 'loras' in kwargs:
for lora in kwargs['loras']:
if not lora.get('active', False):
continue
lora_name = lora['name']
model_strength = float(lora['strength'])
clip_strength = model_strength # Using same strength for both as in the original loader
# Process loras from kwargs with support for both old and new formats
loras_list = self._get_loras_list(kwargs)
for lora in loras_list:
if not lora.get('active', False):
continue
# Get lora path and trigger words
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
# Add to stack without loading
stack.append((lora_path, model_strength, clip_strength))
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
lora_name = lora['name']
model_strength = float(lora['strength'])
clip_strength = model_strength # Using same strength for both as in the original loader
# Get lora path and trigger words
lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name))
# Add to stack without loading
# replace '/' with os.sep to avoid different OS path format
stack.append((lora_path.replace('/', os.sep), model_strength, clip_strength))
active_loras.append((lora_name, model_strength))
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# Format active_loras as <lora:lora_name:strength> separated by spaces
active_loras_text = " ".join([f"<lora:{name}:{str(strength).strip()}>"
for name, strength in active_loras])
return (stack, trigger_words_text)
return (stack, trigger_words_text, active_loras_text)

375
py/nodes/save_image.py Normal file
View File

@@ -0,0 +1,375 @@
import json
import os
import asyncio
import re
import numpy as np
import folder_paths # type: ignore
from ..services.lora_scanner import LoraScanner
from ..workflow.parser import WorkflowParser
from PIL import Image, PngImagePlugin
import piexif
from io import BytesIO
class SaveImage:
NAME = "Save Image (LoraManager)"
CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Save images with embedded generation metadata in compatible format"
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
self.compress_level = 4
self.counter = 0
# Add pattern format regex for filename substitution
pattern_format = re.compile(r"(%[^%]+%)")
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
"file_format": (["png", "jpeg", "webp"],),
},
"optional": {
"custom_prompt": ("STRING", {"default": "", "forceInput": True}),
"lossless_webp": ("BOOLEAN", {"default": True}),
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
"embed_workflow": ("BOOLEAN", {"default": False}),
"add_counter_to_filename": ("BOOLEAN", {"default": True}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "process_image"
OUTPUT_NODE = True
async def get_lora_hash(self, lora_name):
"""Get the lora hash from cache"""
scanner = await LoraScanner.get_instance()
cache = await scanner.get_cached_data()
for item in cache.raw_data:
if item.get('file_name') == lora_name:
return item.get('sha256')
return None
async def format_metadata(self, parsed_workflow, custom_prompt=None):
"""Format metadata in the requested format similar to userComment example"""
if not parsed_workflow:
return ""
# Extract the prompt and negative prompt
prompt = parsed_workflow.get('prompt', '')
negative_prompt = parsed_workflow.get('negative_prompt', '')
# Override prompt with custom_prompt if provided
if custom_prompt:
prompt = custom_prompt
# Extract loras from the prompt if present
loras_text = parsed_workflow.get('loras', '')
lora_hashes = {}
# If loras are found, add them on a new line after the prompt
if loras_text:
prompt_with_loras = f"{prompt}\n{loras_text}"
# Extract lora names from the format <lora:name:strength>
lora_matches = re.findall(r'<lora:([^:]+):([^>]+)>', loras_text)
# Get hash for each lora
for lora_name, strength in lora_matches:
hash_value = await self.get_lora_hash(lora_name)
if hash_value:
lora_hashes[lora_name] = hash_value
else:
prompt_with_loras = prompt
# Format the first part (prompt and loras)
metadata_parts = [prompt_with_loras]
# Add negative prompt
if negative_prompt:
metadata_parts.append(f"Negative prompt: {negative_prompt}")
# Format the second part (generation parameters)
params = []
# Add standard parameters in the correct order
if 'steps' in parsed_workflow:
params.append(f"Steps: {parsed_workflow.get('steps')}")
if 'sampler' in parsed_workflow:
sampler = parsed_workflow.get('sampler')
# Convert ComfyUI sampler names to user-friendly names
sampler_mapping = {
'euler': 'Euler',
'euler_ancestral': 'Euler a',
'dpm_2': 'DPM2',
'dpm_2_ancestral': 'DPM2 a',
'heun': 'Heun',
'dpm_fast': 'DPM fast',
'dpm_adaptive': 'DPM adaptive',
'lms': 'LMS',
'dpmpp_2s_ancestral': 'DPM++ 2S a',
'dpmpp_sde': 'DPM++ SDE',
'dpmpp_sde_gpu': 'DPM++ SDE',
'dpmpp_2m': 'DPM++ 2M',
'dpmpp_2m_sde': 'DPM++ 2M SDE',
'dpmpp_2m_sde_gpu': 'DPM++ 2M SDE',
'ddim': 'DDIM'
}
sampler_name = sampler_mapping.get(sampler, sampler)
params.append(f"Sampler: {sampler_name}")
if 'scheduler' in parsed_workflow:
scheduler = parsed_workflow.get('scheduler')
scheduler_mapping = {
'normal': 'Simple',
'karras': 'Karras',
'exponential': 'Exponential',
'sgm_uniform': 'SGM Uniform',
'sgm_quadratic': 'SGM Quadratic'
}
scheduler_name = scheduler_mapping.get(scheduler, scheduler)
params.append(f"Schedule type: {scheduler_name}")
# CFG scale (cfg in parsed_workflow)
if 'cfg_scale' in parsed_workflow:
params.append(f"CFG scale: {parsed_workflow.get('cfg_scale')}")
elif 'cfg' in parsed_workflow:
params.append(f"CFG scale: {parsed_workflow.get('cfg')}")
# Seed
if 'seed' in parsed_workflow:
params.append(f"Seed: {parsed_workflow.get('seed')}")
# Size
if 'size' in parsed_workflow:
params.append(f"Size: {parsed_workflow.get('size')}")
# Model info
if 'checkpoint' in parsed_workflow:
# Extract basename without path
checkpoint = os.path.basename(parsed_workflow.get('checkpoint', ''))
# Remove extension if present
checkpoint = os.path.splitext(checkpoint)[0]
params.append(f"Model: {checkpoint}")
# Add LoRA hashes if available
if lora_hashes:
lora_hash_parts = []
for lora_name, hash_value in lora_hashes.items():
lora_hash_parts.append(f"{lora_name}: {hash_value}")
if lora_hash_parts:
params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"")
# Combine all parameters with commas
metadata_parts.append(", ".join(params))
# Join all parts with a new line
return "\n".join(metadata_parts)
# credit to nkchocoai
# Add format_filename method to handle pattern substitution
def format_filename(self, filename, parsed_workflow):
"""Format filename with metadata values"""
if not parsed_workflow:
return filename
result = re.findall(self.pattern_format, filename)
for segment in result:
parts = segment.replace("%", "").split(":")
key = parts[0]
if key == "seed" and 'seed' in parsed_workflow:
filename = filename.replace(segment, str(parsed_workflow.get('seed', '')))
elif key == "width" and 'size' in parsed_workflow:
size = parsed_workflow.get('size', 'x')
w = size.split('x')[0] if isinstance(size, str) else size[0]
filename = filename.replace(segment, str(w))
elif key == "height" and 'size' in parsed_workflow:
size = parsed_workflow.get('size', 'x')
h = size.split('x')[1] if isinstance(size, str) else size[1]
filename = filename.replace(segment, str(h))
elif key == "pprompt" and 'prompt' in parsed_workflow:
prompt = parsed_workflow.get('prompt', '').replace("\n", " ")
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip())
elif key == "nprompt" and 'negative_prompt' in parsed_workflow:
prompt = parsed_workflow.get('negative_prompt', '').replace("\n", " ")
if len(parts) >= 2:
length = int(parts[1])
prompt = prompt[:length]
filename = filename.replace(segment, prompt.strip())
elif key == "model" and 'checkpoint' in parsed_workflow:
model = parsed_workflow.get('checkpoint', '')
model = os.path.splitext(os.path.basename(model))[0]
if len(parts) >= 2:
length = int(parts[1])
model = model[:length]
filename = filename.replace(segment, model)
elif key == "date":
from datetime import datetime
now = datetime.now()
date_table = {
"yyyy": str(now.year),
"MM": str(now.month).zfill(2),
"dd": str(now.day).zfill(2),
"hh": str(now.hour).zfill(2),
"mm": str(now.minute).zfill(2),
"ss": str(now.second).zfill(2),
}
if len(parts) >= 2:
date_format = parts[1]
for k, v in date_table.items():
date_format = date_format.replace(k, v)
filename = filename.replace(segment, date_format)
else:
date_format = "yyyyMMddhhmmss"
for k, v in date_table.items():
date_format = date_format.replace(k, v)
filename = filename.replace(segment, date_format)
return filename
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None,
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
custom_prompt=None):
"""Save images with metadata"""
results = []
# Parse the workflow using the WorkflowParser
parser = WorkflowParser()
if prompt:
parsed_workflow = parser.parse_workflow(prompt)
else:
parsed_workflow = {}
# Get or create metadata asynchronously
metadata = asyncio.run(self.format_metadata(parsed_workflow, custom_prompt))
# Process filename_prefix with pattern substitution
filename_prefix = self.format_filename(filename_prefix, parsed_workflow)
# Get initial save path info once for the batch
full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
)
# Create directory if it doesn't exist
if not os.path.exists(full_output_folder):
os.makedirs(full_output_folder, exist_ok=True)
# Process each image with incrementing counter
for i, image in enumerate(images):
# Convert the tensor image to numpy array
img = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
# Generate filename with counter if needed
base_filename = filename
if add_counter_to_filename:
# Use counter + i to ensure unique filenames for all images in batch
current_counter = counter + i
base_filename += f"_{current_counter:05}"
# Set file extension and prepare saving parameters
if file_format == "png":
file = base_filename + ".png"
file_extension = ".png"
save_kwargs = {"optimize": True, "compress_level": self.compress_level}
pnginfo = PngImagePlugin.PngInfo()
elif file_format == "jpeg":
file = base_filename + ".jpg"
file_extension = ".jpg"
save_kwargs = {"quality": quality, "optimize": True}
elif file_format == "webp":
file = base_filename + ".webp"
file_extension = ".webp"
save_kwargs = {"quality": quality, "lossless": lossless_webp}
# Full save path
file_path = os.path.join(full_output_folder, file)
# Save the image with metadata
try:
if file_format == "png":
if metadata:
pnginfo.add_text("parameters", metadata)
if embed_workflow and extra_pnginfo is not None:
workflow_json = json.dumps(extra_pnginfo["workflow"])
pnginfo.add_text("workflow", workflow_json)
save_kwargs["pnginfo"] = pnginfo
img.save(file_path, format="PNG", **save_kwargs)
elif file_format == "jpeg":
# For JPEG, use piexif
if metadata:
try:
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception as e:
print(f"Error adding EXIF data: {e}")
img.save(file_path, format="JPEG", **save_kwargs)
elif file_format == "webp":
# For WebP, also use piexif for metadata
if metadata:
try:
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
save_kwargs["exif"] = exif_bytes
except Exception as e:
print(f"Error adding EXIF data: {e}")
img.save(file_path, format="WEBP", **save_kwargs)
results.append({
"filename": file,
"subfolder": subfolder,
"type": self.type
})
except Exception as e:
print(f"Error saving image: {e}")
return results
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
custom_prompt=""):
"""Process and save image with metadata"""
# Make sure the output directory exists
os.makedirs(self.output_dir, exist_ok=True)
# Ensure images is always a list of images
if len(images.shape) == 3: # Single image (height, width, channels)
images = [images]
else: # Multiple images (batch, height, width, channels)
images = [img for img in images]
# Save all images
results = self.save_images(
images,
filename_prefix,
file_format,
prompt,
extra_pnginfo,
lossless_webp,
quality,
embed_workflow,
add_counter_to_filename,
custom_prompt if custom_prompt.strip() else None
)
return (images,)

View File

@@ -2,6 +2,10 @@ import json
import re
from server import PromptServer # type: ignore
from .utils import FlexibleOptionalInputType, any_type
import logging
logger = logging.getLogger(__name__)
class TriggerWordToggle:
NAME = "TriggerWord Toggle (LoraManager)"
@@ -24,8 +28,24 @@ class TriggerWordToggle:
RETURN_NAMES = ("filtered_trigger_words",)
FUNCTION = "process_trigger_words"
def _get_toggle_data(self, kwargs, key='toggle_trigger_words'):
"""Helper to extract data from either old or new kwargs format"""
if key not in kwargs:
return None
data = kwargs[key]
# Handle new format: {'key': {'__value__': ...}}
if isinstance(data, dict) and '__value__' in data:
return data['__value__']
# Handle old format: {'key': ...}
else:
return data
def process_trigger_words(self, id, group_mode, **kwargs):
trigger_words = kwargs.get("trigger_words", "")
# Handle both old and new formats for trigger_words
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
# Send trigger words to frontend
PromptServer.instance.send_sync("trigger_word_update", {
"id": id,
@@ -34,11 +54,10 @@ class TriggerWordToggle:
filtered_triggers = trigger_words
if 'toggle_trigger_words' in kwargs:
# Get toggle data with support for both formats
trigger_data = self._get_toggle_data(kwargs, 'toggle_trigger_words')
if trigger_data:
try:
# Get trigger word toggle data
trigger_data = kwargs['toggle_trigger_words']
# Convert to list if it's a JSON string
if isinstance(trigger_data, str):
trigger_data = json.loads(trigger_data)
@@ -72,6 +91,6 @@ class TriggerWordToggle:
filtered_triggers = ""
except Exception as e:
print(f"Error processing trigger words: {e}")
logger.error(f"Error processing trigger words: {e}")
return (filtered_triggers,)

View File

@@ -4,6 +4,8 @@ import logging
from aiohttp import web
from typing import Dict, List
from ..utils.model_utils import determine_base_model
from ..services.file_monitor import LoraFileMonitor
from ..services.download_manager import DownloadManager
from ..services.civitai_client import CivitaiClient
@@ -14,6 +16,7 @@ from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings
import asyncio
from .update_routes import UpdateRoutes
from ..services.recipe_scanner import RecipeScanner
logger = logging.getLogger(__name__)
@@ -37,7 +40,10 @@ class ApiRoutes:
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
app.router.add_get('/api/lora-roots', routes.get_lora_roots)
app.router.add_get('/api/folders', routes.get_folders)
app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions)
app.router.add_get('/api/civitai/model/{modelVersionId}', routes.get_civitai_model)
app.router.add_get('/api/civitai/model/{hash}', routes.get_civitai_model)
app.router.add_post('/api/download-lora', routes.download_lora)
app.router.add_post('/api/settings', routes.update_settings)
app.router.add_post('/api/move_model', routes.move_model)
@@ -45,7 +51,10 @@ class ApiRoutes:
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
app.router.add_get('/api/top-tags', routes.get_top_tags) # Add new route for top tags
app.router.add_get('/api/loras/top-tags', routes.get_top_tags) # Add new route for top tags
app.router.add_get('/api/loras/base-models', routes.get_base_models) # Add new route for base models
app.router.add_get('/api/lora-civitai-url', routes.get_lora_civitai_url) # Add new route for Civitai URL
app.router.add_post('/api/rename_lora', routes.rename_lora) # Add new route for renaming LoRA files
# Add update check routes
UpdateRoutes.setup_routes(app)
@@ -126,7 +135,6 @@ class ApiRoutes:
folder = request.query.get('folder')
search = request.query.get('search', '').lower()
fuzzy = request.query.get('fuzzy', 'false').lower() == 'true'
recursive = request.query.get('recursive', 'false').lower() == 'true'
# Parse base models filter parameter
base_models = request.query.get('base_models', '').split(',')
@@ -136,6 +144,7 @@ class ApiRoutes:
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
search_modelname = request.query.get('search_modelname', 'true').lower() == 'true'
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
recursive = request.query.get('recursive', 'false').lower() == 'true'
# Validate parameters
if page < 1 or page_size < 1 or page_size > 100:
@@ -160,13 +169,13 @@ class ApiRoutes:
folder=folder,
search=search,
fuzzy=fuzzy,
recursive=recursive,
base_models=base_models, # Pass base models filter
tags=tags, # Add tags parameter
search_options={
'filename': search_filename,
'modelname': search_modelname,
'tags': search_tags
'tags': search_tags,
'recursive': recursive
}
)
@@ -263,6 +272,9 @@ class ApiRoutes:
cache = await self.scanner.get_cached_data()
cache.raw_data = [item for item in cache.raw_data if item['file_path'] != main_path]
await cache.resort()
# update hash index
self.scanner._hash_index.remove_by_path(main_path)
# Delete optional files
for pattern in patterns[1:]:
@@ -351,8 +363,8 @@ class ApiRoutes:
# Update model name if available
if 'model' in civitai_metadata:
local_metadata['model_name'] = civitai_metadata['model'].get('name',
local_metadata.get('model_name'))
if civitai_metadata.get('model', {}).get('name'):
local_metadata['model_name'] = civitai_metadata['model']['name']
# Fetch additional model metadata (description and tags) if we have model ID
model_id = civitai_metadata['modelId']
@@ -363,7 +375,7 @@ class ApiRoutes:
local_metadata['tags'] = model_metadata.get('tags', [])
# Update base model
local_metadata['base_model'] = civitai_metadata.get('baseModel')
local_metadata['base_model'] = determine_base_model(civitai_metadata.get('baseModel'))
# Update preview if needed
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
@@ -516,6 +528,13 @@ class ApiRoutes:
return web.json_response({
'roots': config.loras_roots
})
async def get_folders(self, request: web.Request) -> web.Response:
"""Get all folders in the cache"""
cache = await self.scanner.get_cached_data()
return web.json_response({
'folders': cache.folders
})
async def get_civitai_versions(self, request: web.Request) -> web.Response:
"""Get available versions for a Civitai model with local availability info"""
@@ -527,17 +546,45 @@ class ApiRoutes:
# Check local availability for each version
for version in versions:
for file in version.get('files', []):
sha256 = file.get('hashes', {}).get('SHA256')
# Find the model file (type="Model") in the files list
model_file = next((file for file in version.get('files', [])
if file.get('type') == 'Model'), None)
if model_file:
sha256 = model_file.get('hashes', {}).get('SHA256')
if sha256:
file['existsLocally'] = self.scanner.has_lora_hash(sha256)
if file['existsLocally']:
file['localPath'] = self.scanner.get_lora_path_by_hash(sha256)
# Set existsLocally and localPath at the version level
version['existsLocally'] = self.scanner.has_lora_hash(sha256)
if version['existsLocally']:
version['localPath'] = self.scanner.get_lora_path_by_hash(sha256)
# Also set the model file size at the version level for easier access
version['modelSizeKB'] = model_file.get('sizeKB')
else:
# No model file found in this version
version['existsLocally'] = False
return web.json_response(versions)
except Exception as e:
logger.error(f"Error fetching model versions: {e}")
return web.Response(status=500, text=str(e))
async def get_civitai_model(self, request: web.Request) -> web.Response:
"""Get CivitAI model details by model version ID or hash"""
try:
model_version_id = request.match_info['modelVersionId']
if not model_version_id:
hash = request.match_info['hash']
model = await self.civitai_client.get_model_by_hash(hash)
return web.json_response(model)
# Get model details from Civitai API
model = await self.civitai_client.get_model_version_info(model_version_id)
return web.json_response(model)
except Exception as e:
logger.error(f"Error fetching model details: {e}")
return web.Response(status=500, text=str(e))
async def download_lora(self, request: web.Request) -> web.Response:
async with self._download_lock:
@@ -551,20 +598,54 @@ class ApiRoutes:
'progress': progress
})
# Check which identifier is provided
download_url = data.get('download_url')
model_hash = data.get('model_hash')
model_version_id = data.get('model_version_id')
# Validate that at least one identifier is provided
if not any([download_url, model_hash, model_version_id]):
return web.Response(
status=400,
text="Missing required parameter: Please provide either 'download_url', 'hash', or 'modelVersionId'"
)
result = await self.download_manager.download_from_civitai(
download_url=data.get('download_url'),
download_url=download_url,
model_hash=model_hash,
model_version_id=model_version_id,
save_dir=data.get('lora_root'),
relative_path=data.get('relative_path'),
progress_callback=progress_callback # Add progress callback
progress_callback=progress_callback
)
if not result.get('success', False):
return web.Response(status=500, text=result.get('error', 'Unknown error'))
error_message = result.get('error', 'Unknown error')
# Return 401 for early access errors
if 'early access' in error_message.lower():
logger.warning(f"Early access download failed: {error_message}")
return web.Response(
status=401, # Use 401 status code to match Civitai's response
text=f"Early Access Restriction: {error_message}"
)
return web.Response(status=500, text=error_message)
return web.json_response(result)
except Exception as e:
logger.error(f"Error downloading LoRA: {e}")
return web.Response(status=500, text=str(e))
error_message = str(e)
# Check if this might be an early access error
if '401' in error_message:
logger.warning(f"Early access error (401): {error_message}")
return web.Response(
status=401,
text="Early Access Restriction: This LoRA requires purchase. Please buy early access on Civitai.com."
)
logger.error(f"Error downloading LoRA: {error_message}")
return web.Response(status=500, text=error_message)
async def update_settings(self, request: web.Request) -> web.Response:
"""Update application settings"""
@@ -586,12 +667,28 @@ class ApiRoutes:
"""Handle model move request"""
try:
data = await request.json()
file_path = data.get('file_path')
target_path = data.get('target_path')
file_path = data.get('file_path') # full path of the model file, e.g. /path/to/model.safetensors
target_path = data.get('target_path') # folder path to move the model to, e.g. /path/to/target_folder
if not file_path or not target_path:
return web.Response(text='File path and target path are required', status=400)
# Check if source and destination are the same
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}")
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
# Check if target file already exists
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
if os.path.exists(target_file_path):
return web.json_response({
'success': False,
'error': f"Target file already exists: {target_file_path}"
}, status=409) # 409 Conflict
# Call scanner to handle the move operation
success = await self.scanner.move_model(file_path, target_path)
@@ -694,37 +791,101 @@ class ApiRoutes:
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
return web.Response(text=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:
# Get lora file name from query parameters
lora_name = request.query.get('name')
if not lora_name:
return web.Response(text='Lora file name is required', status=400)
# Get cache data
cache = await self.scanner.get_cached_data()
# Search for the lora in cache data
for lora in cache.raw_data:
file_name = lora['file_name']
if file_name == lora_name:
civitai_data = lora.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 web.json_response({
'success': True,
'civitai_url': civitai_url,
'model_id': model_id,
'version_id': version_id
})
break
# If no Civitai data found
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.Response(text=str(e), status=500)
async def move_models_bulk(self, request: web.Request) -> web.Response:
"""Handle bulk model move request"""
try:
data = await request.json()
file_paths = data.get('file_paths', [])
target_path = data.get('target_path')
file_paths = data.get('file_paths', []) # list of full paths of the model files, e.g. ["/path/to/model1.safetensors", "/path/to/model2.safetensors"]
target_path = data.get('target_path') # folder path to move the models to, e.g. "/path/to/target_folder"
if not file_paths or not target_path:
return web.Response(text='File paths and target path are required', status=400)
results = []
for file_path in file_paths:
# Check if source and destination are the same
source_dir = os.path.dirname(file_path)
if os.path.normpath(source_dir) == os.path.normpath(target_path):
results.append({
"path": file_path,
"success": True,
"message": "Source and target directories are the same"
})
continue
# Check if target file already exists
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
if os.path.exists(target_file_path):
results.append({
"path": file_path,
"success": False,
"message": f"Target file already exists: {target_file_path}"
})
continue
# Try to move the model
success = await self.scanner.move_model(file_path, target_path)
results.append({"path": file_path, "success": success})
results.append({
"path": file_path,
"success": success,
"message": "Success" if success else "Failed to move model"
})
# Count successes
# Count successes and failures
success_count = sum(1 for r in results if r["success"])
failure_count = len(results) - success_count
if success_count == len(file_paths):
return web.json_response({
'success': True,
'message': f'Successfully moved {success_count} models'
})
elif success_count > 0:
return web.json_response({
'success': True,
'message': f'Moved {success_count} of {len(file_paths)} models',
'results': results
})
else:
return web.Response(text='Failed to move any models', status=500)
return web.json_response({
'success': True,
'message': f'Moved {success_count} of {len(file_paths)} models',
'results': results,
'success_count': success_count,
'failure_count': failure_count
})
except Exception as e:
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
@@ -820,3 +981,170 @@ class ApiRoutes:
'success': False,
'error': 'Internal server error'
}, status=500)
async def get_base_models(self, request: web.Request) -> web.Response:
"""Get base models used in loras"""
try:
# Parse query parameters
limit = int(request.query.get('limit', '20'))
# Validate limit
if limit < 1 or limit > 100:
limit = 20 # Default to a reasonable limit
# Get base models
base_models = await self.scanner.get_base_models(limit)
return web.json_response({
'success': True,
'base_models': base_models
})
except Exception as e:
logger.error(f"Error retrieving base models: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
def get_multipart_ext(self, filename):
parts = filename.split(".")
if len(parts) > 2: # 如果包含多级扩展名
return "." + ".".join(parts[-2:]) # 取最后两部分,如 ".metadata.json"
return os.path.splitext(filename)[1] # 否则取普通扩展名,如 ".safetensors"
async def rename_lora(self, request: web.Request) -> web.Response:
"""Handle renaming a LoRA file and its associated files"""
try:
data = await request.json()
file_path = data.get('file_path')
new_file_name = data.get('new_file_name')
if not file_path or not new_file_name:
return web.json_response({
'success': False,
'error': 'File path and new file name are required'
}, status=400)
# Validate the new file name (no path separators or invalid characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
if any(char in new_file_name for char in invalid_chars):
return web.json_response({
'success': False,
'error': 'Invalid characters in file name'
}, status=400)
# Get the directory and current file name
target_dir = os.path.dirname(file_path)
old_file_name = os.path.splitext(os.path.basename(file_path))[0]
# Check if the target file already exists
new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/')
if os.path.exists(new_file_path):
return web.json_response({
'success': False,
'error': 'A file with this name already exists'
}, status=400)
# Define the patterns for associated files
patterns = [
f"{old_file_name}.safetensors", # Required
f"{old_file_name}.metadata.json",
f"{old_file_name}.preview.png",
f"{old_file_name}.preview.jpg",
f"{old_file_name}.preview.jpeg",
f"{old_file_name}.preview.webp",
f"{old_file_name}.preview.mp4",
f"{old_file_name}.png",
f"{old_file_name}.jpg",
f"{old_file_name}.jpeg",
f"{old_file_name}.webp",
f"{old_file_name}.mp4"
]
# Find all matching files
existing_files = []
for pattern in patterns:
path = os.path.join(target_dir, pattern)
if os.path.exists(path):
existing_files.append((path, pattern))
# Get the hash from the main file to update hash index
hash_value = None
metadata = None
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
hash_value = metadata.get('sha256')
except Exception as e:
logger.error(f"Error loading metadata for rename: {e}")
# Rename all files
renamed_files = []
new_metadata_path = None
# Notify file monitor to ignore these events
main_file_path = os.path.join(target_dir, f"{old_file_name}.safetensors")
if os.path.exists(main_file_path) and self.download_manager.file_monitor:
# Add old and new paths to ignore list
file_size = os.path.getsize(main_file_path)
self.download_manager.file_monitor.handler.add_ignore_path(main_file_path, file_size)
self.download_manager.file_monitor.handler.add_ignore_path(new_file_path, file_size)
for old_path, pattern in existing_files:
# Get the file extension like .safetensors or .metadata.json
ext = self.get_multipart_ext(pattern)
# Create the new path
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
# Rename the file
os.rename(old_path, new_path)
renamed_files.append(new_path)
# Keep track of metadata path for later update
if ext == '.metadata.json':
new_metadata_path = new_path
# Update the metadata file with new file name and paths
if new_metadata_path and metadata:
# Update file_name, file_path and preview_url in metadata
metadata['file_name'] = new_file_name
metadata['file_path'] = new_file_path
# Update preview_url if it exists
if 'preview_url' in metadata and metadata['preview_url']:
old_preview = metadata['preview_url']
ext = self.get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
metadata['preview_url'] = new_preview
# Save updated metadata
with open(new_metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
# Update the scanner cache
if metadata:
await self.scanner.update_single_lora_cache(file_path, new_file_path, metadata)
# Update recipe files and cache if hash is available
if hash_value:
recipe_scanner = RecipeScanner(self.scanner)
recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name)
logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed LoRA")
return web.json_response({
'success': True,
'new_file_path': new_file_path,
'renamed_files': renamed_files,
'reload_required': False
})
except Exception as e:
logger.error(f"Error renaming LoRA: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -0,0 +1,44 @@
import os
from aiohttp import web
import jinja2
import logging
from ..config import config
from ..services.settings_manager import settings
logger = logging.getLogger(__name__)
logging.getLogger('asyncio').setLevel(logging.CRITICAL)
class CheckpointsRoutes:
"""Route handlers for Checkpoints management endpoints"""
def __init__(self):
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),
autoescape=True
)
async def handle_checkpoints_page(self, request: web.Request) -> web.Response:
"""Handle GET /checkpoints request"""
try:
template = self.template_env.get_template('checkpoints.html')
rendered = template.render(
is_initializing=False,
settings=settings,
request=request
)
return web.Response(
text=rendered,
content_type='text/html'
)
except Exception as e:
logger.error(f"Error handling checkpoints request: {e}", exc_info=True)
return web.Response(
text="Error loading checkpoints page",
status=500
)
def setup_routes(self, app: web.Application):
"""Register routes with the application"""
app.router.add_get('/checkpoints', self.handle_checkpoints_page)

View File

@@ -4,6 +4,7 @@ import jinja2
from typing import Dict, List
import logging
from ..services.lora_scanner import LoraScanner
from ..services.recipe_scanner import RecipeScanner
from ..config import config
from ..services.settings_manager import settings # Add this import
@@ -15,6 +16,7 @@ class LoraRoutes:
def __init__(self):
self.scanner = LoraScanner()
self.recipe_scanner = RecipeScanner(self.scanner)
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),
autoescape=True
@@ -56,11 +58,13 @@ class LoraRoutes:
async def handle_loras_page(self, request: web.Request) -> web.Response:
"""Handle GET /loras request"""
try:
# 不等待缓存数据,直接检查缓存状态
# 检查缓存初始化状态,增强判断条件
is_initializing = (
self.scanner._cache is None and
self.scanner._cache is None or
(self.scanner._initialization_task is not None and
not self.scanner._initialization_task.done())
not self.scanner._initialization_task.done()) or
(self.scanner._cache is not None and len(self.scanner._cache.raw_data) == 0 and
self.scanner._initialization_task is not None)
)
if is_initializing:
@@ -69,17 +73,34 @@ class LoraRoutes:
rendered = template.render(
folders=[], # 空文件夹列表
is_initializing=True, # 新增标志
settings=settings # Pass settings to template
settings=settings, # Pass settings to template
request=request # Pass the request object to the template
)
logger.info("Loras page is initializing, returning loading page")
else:
# 正常流程
cache = await self.scanner.get_cached_data()
template = self.template_env.get_template('loras.html')
rendered = template.render(
folders=cache.folders,
is_initializing=False,
settings=settings # Pass settings to template
)
# 正常流程 - 但不要等待缓存刷新
try:
cache = await self.scanner.get_cached_data(force_refresh=False)
template = self.template_env.get_template('loras.html')
rendered = template.render(
folders=cache.folders,
is_initializing=False,
settings=settings, # Pass settings to template
request=request # Pass the request object to the template
)
logger.debug(f"Loras page loaded successfully with {len(cache.raw_data)} items")
except Exception as cache_error:
logger.error(f"Error loading cache data: {cache_error}")
# 如果获取缓存失败,也显示初始化页面
template = self.template_env.get_template('loras.html')
rendered = template.render(
folders=[],
is_initializing=True,
settings=settings,
request=request
)
logger.info("Cache error, returning initialization page")
return web.Response(
text=rendered,
@@ -93,6 +114,65 @@ class LoraRoutes:
status=500
)
async def handle_recipes_page(self, request: web.Request) -> web.Response:
"""Handle GET /loras/recipes request"""
try:
# Check cache initialization status
is_initializing = (
self.recipe_scanner._cache is None and
(self.recipe_scanner._initialization_task is not None and
not self.recipe_scanner._initialization_task.done())
)
if is_initializing:
# If initializing, return a loading page
template = self.template_env.get_template('recipes.html')
rendered = template.render(
is_initializing=True,
settings=settings,
request=request # Pass the request object to the template
)
else:
# return empty recipes
recipes_data = []
template = self.template_env.get_template('recipes.html')
rendered = template.render(
recipes=recipes_data,
is_initializing=False,
settings=settings,
request=request # Pass the request object to the template
)
return web.Response(
text=rendered,
content_type='text/html'
)
except Exception as e:
logger.error(f"Error handling recipes request: {e}", exc_info=True)
return web.Response(
text="Error loading recipes page",
status=500
)
def _format_recipe_file_url(self, file_path: str) -> str:
"""Format file path for recipe image as a URL - same as in recipe_routes"""
try:
# Return the file URL directly for the first lora root's preview
recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, '/')
if file_path.replace(os.sep, '/').startswith(recipes_dir):
relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, '/')
return f"/loras_static/root1/preview/{relative_path}"
# If not in recipes dir, try to create a valid URL from the file path
file_name = os.path.basename(file_path)
return f"/loras_static/root1/preview/recipes/{file_name}"
except Exception as e:
logger.error(f"Error formatting recipe file URL: {e}", exc_info=True)
return '/loras_static/images/no-preview.png' # Return default image on error
def setup_routes(self, app: web.Application):
"""Register routes with the application"""
app.router.add_get('/loras', self.handle_loras_page)
app.router.add_get('/loras/recipes', self.handle_recipes_page)

1136
py/routes/recipe_routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -24,11 +24,9 @@ class UpdateRoutes:
try:
# Read local version from pyproject.toml
local_version = UpdateRoutes._get_local_version()
logger.info(f"Local version: {local_version}")
# Fetch remote version from GitHub
remote_version, changelog = await UpdateRoutes._get_remote_version()
logger.info(f"Remote version: {remote_version}")
# Compare versions
update_available = UpdateRoutes._compare_versions(
@@ -36,8 +34,6 @@ class UpdateRoutes:
remote_version.replace('v', '')
)
logger.info(f"Update available: {update_available}")
return web.json_response({
'success': True,
'current_version': local_version,

View File

@@ -76,6 +76,18 @@ class CivitaiClient:
headers = self._get_request_headers()
async with session.get(url, headers=headers, allow_redirects=True) as response:
if response.status != 200:
# Handle 401 unauthorized responses
if response.status == 401:
logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
return False, "Invalid or missing CivitAI API key, or early access restriction."
# Handle other client errors that might be permission-related
if response.status == 403:
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
return False, "Access forbidden: You don't have permission to download this file."
# Generic error response for other status codes
return False, f"Download failed with status {response.status}"
# Get filename from content-disposition header
@@ -214,4 +226,27 @@ class CivitaiClient:
"""Close the session if it exists"""
if self._session is not None:
await self._session.close()
self._session = None
self._session = None
async def _get_hash_from_civitai(self, model_version_id: str) -> Optional[str]:
"""Get hash from Civitai API"""
try:
if not self._session:
return None
version_info = await self._session.get(f"{self.base_url}/model-versions/{model_version_id}")
if not version_info or not version_info.json().get('files'):
return None
# Get hash from the first file
for file_info in version_info.json().get('files', []):
if file_info.get('hashes', {}).get('SHA256'):
# Convert hash to lowercase to standardize
hash_value = file_info['hashes']['SHA256'].lower()
return hash_value
return None
except Exception as e:
logger.error(f"Error getting hash from Civitai: {e}")
return None

View File

@@ -13,8 +13,9 @@ class DownloadManager:
self.civitai_client = CivitaiClient()
self.file_monitor = file_monitor
async def download_from_civitai(self, download_url: str, save_dir: str, relative_path: str = '',
progress_callback=None) -> Dict:
async def download_from_civitai(self, download_url: str = None, model_hash: str = None,
model_version_id: str = None, save_dir: str = None,
relative_path: str = '', progress_callback=None) -> Dict:
try:
# Update save directory with relative path if provided
if relative_path:
@@ -22,12 +23,43 @@ class DownloadManager:
# Create directory if it doesn't exist
os.makedirs(save_dir, exist_ok=True)
# Get version info
version_id = download_url.split('/')[-1]
version_info = await self.civitai_client.get_model_version_info(version_id)
# Get version info based on the provided identifier
version_info = None
if download_url:
# Extract version ID from download URL
version_id = download_url.split('/')[-1]
version_info = await self.civitai_client.get_model_version_info(version_id)
elif model_version_id:
# Use model version ID directly
version_info = await self.civitai_client.get_model_version_info(model_version_id)
elif model_hash:
# Get model by hash
version_info = await self.civitai_client.get_model_by_hash(model_hash)
if not version_info:
return {'success': False, 'error': 'Failed to fetch model metadata'}
# Check if this is an early access LoRA
if version_info.get('earlyAccessEndsAt'):
early_access_date = version_info.get('earlyAccessEndsAt', '')
# Convert to a readable date if possible
try:
from datetime import datetime
date_obj = datetime.fromisoformat(early_access_date.replace('Z', '+00:00'))
formatted_date = date_obj.strftime('%Y-%m-%d')
early_access_msg = f"This LoRA requires early access payment (until {formatted_date}). "
except:
early_access_msg = "This LoRA requires early access payment. "
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
logger.warning(f"Early access LoRA detected: {version_info.get('name', 'Unknown')}")
# We'll still try to download, but log a warning and prepare for potential failure
if progress_callback:
await progress_callback(1) # Show minimal progress to indicate we're trying
# Report initial progress
if progress_callback:
await progress_callback(0)
@@ -42,10 +74,10 @@ class DownloadManager:
save_path = os.path.join(save_dir, file_name)
file_size = file_info.get('sizeKB', 0) * 1024
# 4. 通知文件监控系统
# 4. 通知文件监控系统 - 使用规范化路径和文件大小
self.file_monitor.handler.add_ignore_path(
save_path.replace(os.sep, '/'),
file_size
save_path.replace(os.sep, '/'),
file_size
)
# 5. 准备元数据
@@ -63,7 +95,7 @@ class DownloadManager:
# 6. 开始下载流程
result = await self._execute_download(
download_url=download_url,
download_url=file_info.get('downloadUrl', ''),
save_dir=save_dir,
metadata=metadata,
version_info=version_info,
@@ -75,6 +107,10 @@ class DownloadManager:
except Exception as e:
logger.error(f"Error in download_from_civitai: {e}", exc_info=True)
# Check if this might be an early access error
error_str = str(e).lower()
if "403" in error_str or "401" in error_str or "unauthorized" in error_str or "early access" in error_str:
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
return {'success': False, 'error': str(e)}
async def _execute_download(self, download_url: str, save_dir: str,
@@ -135,6 +171,12 @@ class DownloadManager:
all_folders = set(cache.folders)
all_folders.add(relative_path)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# Update the hash index with the new LoRA entry
self.file_monitor.scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
# Update the hash index with the new LoRA entry
self.file_monitor.scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
# Report 100% completion
if progress_callback:

View File

@@ -2,9 +2,10 @@ from operator import itemgetter
import os
import logging
import asyncio
import time
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileDeletedEvent
from typing import List
from watchdog.events import FileSystemEventHandler
from typing import List, Dict, Set
from threading import Lock
from .lora_scanner import LoraScanner
from ..config import config
@@ -23,6 +24,14 @@ class LoraFileHandler(FileSystemEventHandler):
self._ignore_paths = set() # Add ignore paths set
self._min_ignore_timeout = 5 # minimum timeout in seconds
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
# Track modified files with timestamps for debouncing
self.modified_files: Dict[str, float] = {}
self.debounce_timer = None
self.debounce_delay = 3.0 # seconds to wait after last modification
# Track files that are already scheduled for processing
self.scheduled_files: Set[str] = set()
def _should_ignore(self, path: str) -> bool:
"""Check if path should be ignored"""
@@ -37,28 +46,142 @@ class LoraFileHandler(FileSystemEventHandler):
# Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
timeout = 5
asyncio.get_event_loop().call_later(
self.loop.call_later(
timeout,
self._ignore_paths.discard,
real_path.replace(os.sep, '/')
)
def on_created(self, event):
if event.is_directory or not event.src_path.endswith('.safetensors'):
if event.is_directory:
return
if self._should_ignore(event.src_path):
# Handle safetensors files directly
if event.src_path.endswith('.safetensors'):
if self._should_ignore(event.src_path):
return
# We'll process this file directly and ignore subsequent modifications
# to prevent duplicate processing
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
if normalized_path not in self.scheduled_files:
logger.info(f"LoRA file created: {event.src_path}")
self.scheduled_files.add(normalized_path)
self._schedule_update('add', event.src_path)
# Ignore modifications for a short period after creation
# This helps avoid duplicate processing
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
normalized_path
)
# For browser downloads, we'll catch them when they're renamed to .safetensors
def on_modified(self, event):
if event.is_directory:
return
logger.info(f"LoRA file created: {event.src_path}")
self._schedule_update('add', event.src_path)
# Only process safetensors files
if event.src_path.endswith('.safetensors'):
if self._should_ignore(event.src_path):
return
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
# Skip if this file is already scheduled for processing
if normalized_path in self.scheduled_files:
return
# Update the timestamp for this file
self.modified_files[normalized_path] = time.time()
# Cancel any existing timer
if self.debounce_timer:
self.debounce_timer.cancel()
# Set a new timer to process modified files after debounce period
self.debounce_timer = self.loop.call_later(
self.debounce_delay,
self.loop.call_soon_threadsafe,
self._process_modified_files
)
def _process_modified_files(self):
"""Process files that have been modified after debounce period"""
current_time = time.time()
files_to_process = []
# Find files that haven't been modified for debounce_delay seconds
for file_path, last_modified in list(self.modified_files.items()):
if current_time - last_modified >= self.debounce_delay:
# Only process if not already scheduled
if file_path not in self.scheduled_files:
files_to_process.append(file_path)
self.scheduled_files.add(file_path)
# Auto-remove from scheduled list after reasonable time
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
file_path
)
del self.modified_files[file_path]
# Process stable files
for file_path in files_to_process:
logger.info(f"Processing modified LoRA file: {file_path}")
self._schedule_update('add', file_path)
def on_deleted(self, event):
if event.is_directory or not event.src_path.endswith('.safetensors'):
return
if self._should_ignore(event.src_path):
return
# Remove from scheduled files if present
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
self.scheduled_files.discard(normalized_path)
logger.info(f"LoRA file deleted: {event.src_path}")
self._schedule_update('remove', event.src_path)
def on_moved(self, event):
"""Handle file move/rename events"""
# If destination is a safetensors file, treat it as a new file
if event.dest_path.endswith('.safetensors'):
if self._should_ignore(event.dest_path):
return
normalized_path = os.path.realpath(event.dest_path).replace(os.sep, '/')
# Only process if not already scheduled
if normalized_path not in self.scheduled_files:
logger.info(f"LoRA file renamed/moved to: {event.dest_path}")
self.scheduled_files.add(normalized_path)
self._schedule_update('add', event.dest_path)
# Auto-remove from scheduled list after reasonable time
self.loop.call_later(
self.debounce_delay * 2,
self.scheduled_files.discard,
normalized_path
)
# If source was a safetensors file, treat it as deleted
if event.src_path.endswith('.safetensors'):
if self._should_ignore(event.src_path):
return
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
self.scheduled_files.discard(normalized_path)
logger.info(f"LoRA file moved/renamed from: {event.src_path}")
self._schedule_update('remove', event.src_path)
def _schedule_update(self, action: str, file_path: str): #file_path is a real path
"""Schedule a cache update"""
with self.lock:
@@ -95,6 +218,12 @@ class LoraFileHandler(FileSystemEventHandler):
for action, file_path in changes:
try:
if action == 'add':
# Check if file already exists in cache
existing = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
if existing:
logger.info(f"File {file_path} already in cache, skipping")
continue
# Scan new file
lora_data = await self.scanner.scan_single_lora(file_path)
if lora_data:

View File

@@ -15,11 +15,13 @@ class LoraHashIndex:
"""Add or update a hash -> path mapping"""
if not sha256 or not file_path:
return
self._hash_to_path[sha256] = file_path
# Always store lowercase hashes for consistency
self._hash_to_path[sha256.lower()] = file_path
def remove_entry(self, sha256: str) -> None:
"""Remove a hash entry"""
self._hash_to_path.pop(sha256, None)
if sha256:
self._hash_to_path.pop(sha256.lower(), None)
def remove_by_path(self, file_path: str) -> None:
"""Remove entry by file path"""
@@ -30,7 +32,9 @@ class LoraHashIndex:
def get_path(self, sha256: str) -> Optional[str]:
"""Get file path for a given hash"""
return self._hash_to_path.get(sha256)
if not sha256:
return None
return self._hash_to_path.get(sha256.lower())
def get_hash(self, file_path: str) -> Optional[str]:
"""Get hash for a given file path"""
@@ -41,7 +45,9 @@ class LoraHashIndex:
def has_hash(self, sha256: str) -> bool:
"""Check if hash exists in index"""
return sha256 in self._hash_to_path
if not sha256:
return False
return sha256.lower() in self._hash_to_path
def clear(self) -> None:
"""Clear all entries"""

View File

@@ -3,16 +3,19 @@ import os
import logging
import asyncio
import shutil
import time
from typing import List, Dict, Optional
from dataclasses import dataclass
from operator import itemgetter
from ..utils.models import LoraMetadata
from ..config import config
from ..utils.file_utils import load_metadata, get_file_info
from ..utils.file_utils import load_metadata, get_file_info, normalize_path, find_preview_file, save_metadata
from ..utils.lora_metadata import extract_lora_metadata
from .lora_cache import LoraCache
from difflib import SequenceMatcher
from .lora_hash_index import LoraHashIndex
from .settings_manager import settings
from ..utils.constants import NSFW_LEVELS
from ..utils.utils import fuzzy_match
import sys
logger = logging.getLogger(__name__)
@@ -90,6 +93,7 @@ class LoraScanner:
async def _initialize_cache(self) -> None:
"""Initialize or refresh the cache"""
try:
start_time = time.time()
# Clear existing hash index
self._hash_index.clear()
@@ -102,7 +106,7 @@ class LoraScanner:
# Build hash index and tags count
for lora_data in raw_data:
if 'sha256' in lora_data and 'file_path' in lora_data:
self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path'])
self._hash_index.add_entry(lora_data['sha256'].lower(), lora_data['file_path'])
# Count tags
if 'tags' in lora_data and lora_data['tags']:
@@ -121,7 +125,7 @@ class LoraScanner:
await self._cache.resort()
self._initialization_task = None
logger.info("LoRA Manager: Cache initialization completed")
logger.info(f"LoRA Manager: Cache initialization completed in {time.time() - start_time:.2f} seconds, found {len(raw_data)} loras")
except Exception as e:
logger.error(f"LoRA Manager: Error initializing cache: {e}")
self._cache = LoraCache(
@@ -131,45 +135,9 @@ class LoraScanner:
folders=[]
)
def fuzzy_match(self, text: str, pattern: str, threshold: float = 0.7) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
"""
if not pattern or not text:
return False
# Convert both to lowercase for case-insensitive matching
text = text.lower()
pattern = pattern.lower()
# Split pattern into words
search_words = pattern.split()
# Check each word
for word in search_words:
# First check if word is a substring (faster)
if word in text:
continue
# If not found as substring, try fuzzy matching
# Check if any part of the text matches this word
found_match = False
for text_part in text.split():
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
break
if not found_match:
return False
# All words found either as substrings or fuzzy matches
return True
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
folder: str = None, search: str = None, fuzzy: bool = False,
recursive: bool = False, base_models: list = None, tags: list = None,
base_models: list = None, tags: list = None,
search_options: dict = None) -> Dict:
"""Get paginated and filtered lora data
@@ -180,10 +148,9 @@ class LoraScanner:
folder: Filter by folder path
search: Search term
fuzzy: Use fuzzy matching for search
recursive: Include subfolders when folder filter is applied
base_models: List of base models to filter by
tags: List of tags to filter by
search_options: Dictionary with search options (filename, modelname, tags)
search_options: Dictionary with search options (filename, modelname, tags, recursive)
"""
cache = await self.get_cached_data()
@@ -192,7 +159,8 @@ class LoraScanner:
search_options = {
'filename': True,
'modelname': True,
'tags': False
'tags': False,
'recursive': False
}
# Get the base data set
@@ -207,7 +175,7 @@ class LoraScanner:
# Apply folder filtering
if folder is not None:
if recursive:
if search_options.get('recursive', False):
# Recursive mode: match all paths starting with this folder
filtered_data = [
item for item in filtered_data
@@ -236,16 +204,47 @@ class LoraScanner:
# Apply search filtering
if search:
if fuzzy:
filtered_data = [
item for item in filtered_data
if self._fuzzy_search_match(item, search, search_options)
]
else:
filtered_data = [
item for item in filtered_data
if self._exact_search_match(item, search, search_options)
]
search_results = []
for item in filtered_data:
# Check filename if enabled
if search_options.get('filename', True):
if fuzzy:
if fuzzy_match(item.get('file_name', ''), search):
search_results.append(item)
continue
else:
if search.lower() in item.get('file_name', '').lower():
search_results.append(item)
continue
# Check model name if enabled
if search_options.get('modelname', True):
if fuzzy:
if fuzzy_match(item.get('model_name', ''), search):
search_results.append(item)
continue
else:
if search.lower() in item.get('model_name', '').lower():
search_results.append(item)
continue
# Check tags if enabled
if search_options.get('tags', False) and item.get('tags'):
found_tag = False
for tag in item['tags']:
if fuzzy:
if fuzzy_match(tag, search):
found_tag = True
break
else:
if search.lower() in tag.lower():
found_tag = True
break
if found_tag:
search_results.append(item)
continue
filtered_data = search_results
# Calculate pagination
total_items = len(filtered_data)
@@ -262,44 +261,6 @@ class LoraScanner:
return result
def _fuzzy_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
"""Check if an item matches the search term using fuzzy matching with search options"""
# Check filename if enabled
if search_options.get('filename', True) and self.fuzzy_match(item.get('file_name', ''), search):
return True
# Check model name if enabled
if search_options.get('modelname', True) and self.fuzzy_match(item.get('model_name', ''), search):
return True
# Check tags if enabled
if search_options.get('tags', False) and item.get('tags'):
for tag in item['tags']:
if self.fuzzy_match(tag, search):
return True
return False
def _exact_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
"""Check if an item matches the search term using exact matching with search options"""
search = search.lower()
# Check filename if enabled
if search_options.get('filename', True) and search in item.get('file_name', '').lower():
return True
# Check model name if enabled
if search_options.get('modelname', True) and search in item.get('model_name', '').lower():
return True
# Check tags if enabled
if search_options.get('tags', False) and item.get('tags'):
for tag in item['tags']:
if search in tag.lower():
return True
return False
def invalidate_cache(self):
"""Invalidate the current cache"""
self._cache = None
@@ -372,8 +333,30 @@ class LoraScanner:
metadata = await load_metadata(file_path)
if metadata is None:
# Create new metadata if none exists
metadata = await get_file_info(file_path)
# Try to find and use .civitai.info file first
civitai_info_path = f"{os.path.splitext(file_path)[0]}.civitai.info"
if os.path.exists(civitai_info_path):
try:
with open(civitai_info_path, 'r', encoding='utf-8') as f:
version_info = json.load(f)
file_info = next((f for f in version_info.get('files', []) if f.get('primary')), None)
if file_info:
# Create a minimal file_info with the required fields
file_name = os.path.splitext(os.path.basename(file_path))[0]
file_info['name'] = file_name
# Use from_civitai_info to create metadata
metadata = LoraMetadata.from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
await save_metadata(file_path, metadata)
logger.debug(f"Created metadata from .civitai.info for {file_path}")
except Exception as e:
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
# If still no metadata, create new metadata using get_file_info
if metadata is None:
metadata = await get_file_info(file_path)
# Convert to dict and add folder info
lora_data = metadata.to_dict()
@@ -384,7 +367,7 @@ class LoraScanner:
lora_data['folder'] = folder.replace(os.path.sep, '/')
return lora_data
async def _fetch_missing_metadata(self, file_path: str, lora_data: Dict) -> None:
"""Fetch missing description and tags from Civitai if needed
@@ -604,7 +587,7 @@ class LoraScanner:
# Update hash index with new path
if 'sha256' in metadata:
self._hash_index.add_entry(metadata['sha256'], new_path)
self._hash_index.add_entry(metadata['sha256'].lower(), new_path)
# Update folders list
all_folders = set(item['folder'] for item in cache.raw_data)
@@ -649,15 +632,35 @@ class LoraScanner:
# Add new methods for hash index functionality
def has_lora_hash(self, sha256: str) -> bool:
"""Check if a LoRA with given hash exists"""
return self._hash_index.has_hash(sha256)
return self._hash_index.has_hash(sha256.lower())
def get_lora_path_by_hash(self, sha256: str) -> Optional[str]:
"""Get file path for a LoRA by its hash"""
return self._hash_index.get_path(sha256)
return self._hash_index.get_path(sha256.lower())
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
"""Get hash for a LoRA by its file path"""
return self._hash_index.get_hash(file_path)
return self._hash_index.get_hash(file_path)
def get_preview_url_by_hash(self, sha256: str) -> Optional[str]:
"""Get preview static URL for a LoRA by its hash"""
# Get the file path first
file_path = self._hash_index.get_path(sha256.lower())
if not file_path:
return None
# Determine the preview file path (typically same name with different extension)
base_name = os.path.splitext(file_path)[0]
preview_extensions = ['.preview.png', '.preview.jpeg', '.preview.jpg', '.preview.mp4',
'.png', '.jpeg', '.jpg', '.mp4']
for ext in preview_extensions:
preview_path = f"{base_name}{ext}"
if os.path.exists(preview_path):
# Convert to static URL using config
return config.get_preview_static_url(preview_path)
return None
# Add new method to get top tags
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
@@ -681,4 +684,81 @@ class LoraScanner:
# Return limited number
return sorted_tags[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get base models used in loras sorted by frequency
Args:
limit: Maximum number of base models to return
Returns:
List of dictionaries with base model name and count, sorted by count
"""
# Make sure cache is initialized
cache = await self.get_cached_data()
# Count base model occurrences
base_model_counts = {}
for lora in cache.raw_data:
if 'base_model' in lora and lora['base_model']:
base_model = lora['base_model']
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
# Sort base models by count
sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()]
sorted_models.sort(key=lambda x: x['count'], reverse=True)
# Return limited number
return sorted_models[:limit]
async def diagnose_hash_index(self):
"""Diagnostic method to verify hash index functionality"""
print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr)
# First check if the hash index has any entries
if hasattr(self, '_hash_index'):
index_entries = len(self._hash_index._hash_to_path)
print(f"Hash index has {index_entries} entries", file=sys.stderr)
# Print a few example entries if available
if index_entries > 0:
print("\nSample hash index entries:", file=sys.stderr)
count = 0
for hash_val, path in self._hash_index._hash_to_path.items():
if count < 5: # Just show the first 5
print(f"Hash: {hash_val[:8]}... -> Path: {path}", file=sys.stderr)
count += 1
else:
break
else:
print("Hash index not initialized", file=sys.stderr)
# Try looking up by a known hash for testing
if not hasattr(self, '_hash_index') or not self._hash_index._hash_to_path:
print("No hash entries to test lookup with", file=sys.stderr)
return
test_hash = next(iter(self._hash_index._hash_to_path.keys()))
test_path = self._hash_index.get_path(test_hash)
print(f"\nTest lookup by hash: {test_hash[:8]}... -> {test_path}", file=sys.stderr)
# Also test reverse lookup
test_hash_result = self._hash_index.get_hash(test_path)
print(f"Test reverse lookup: {test_path} -> {test_hash_result[:8]}...\n\n", file=sys.stderr)
async def get_lora_info_by_name(self, name):
"""Get LoRA information by name"""
try:
# Get cached data
cache = await self.get_cached_data()
# Find the LoRA by name
for lora in cache.raw_data:
if lora.get("file_name") == name:
return lora
return None
except Exception as e:
logger.error(f"Error getting LoRA info by name: {e}", exc_info=True)
return None

View File

@@ -0,0 +1,85 @@
import asyncio
from typing import List, Dict
from dataclasses import dataclass
from operator import itemgetter
@dataclass
class RecipeCache:
"""Cache structure for Recipe data"""
raw_data: List[Dict]
sorted_by_name: List[Dict]
sorted_by_date: List[Dict]
def __post_init__(self):
self._lock = asyncio.Lock()
async def resort(self, name_only: bool = False):
"""Resort all cached data views"""
async with self._lock:
self.sorted_by_name = sorted(
self.raw_data,
key=lambda x: x.get('title', '').lower() # Case-insensitive sort
)
if not name_only:
self.sorted_by_date = sorted(
self.raw_data,
key=itemgetter('created_date', 'file_path'),
reverse=True
)
async def update_recipe_metadata(self, recipe_id: str, metadata: Dict) -> bool:
"""Update metadata for a specific recipe in all cached data
Args:
recipe_id: The ID of the recipe to update
metadata: The new metadata
Returns:
bool: True if the update was successful, False if the recipe wasn't found
"""
# Update in raw_data
for item in self.raw_data:
if item.get('id') == recipe_id:
item.update(metadata)
break
else:
return False # Recipe not found
# Resort to reflect changes
await self.resort()
return True
async def add_recipe(self, recipe_data: Dict) -> None:
"""Add a new recipe to the cache
Args:
recipe_data: The recipe data to add
"""
async with self._lock:
self.raw_data.append(recipe_data)
await self.resort()
async def remove_recipe(self, recipe_id: str) -> bool:
"""Remove a recipe from the cache by ID
Args:
recipe_id: The ID of the recipe to remove
Returns:
bool: True if the recipe was found and removed, False otherwise
"""
# Find the recipe in raw_data
recipe_index = next((i for i, recipe in enumerate(self.raw_data)
if recipe.get('id') == recipe_id), None)
if recipe_index is None:
return False
# Remove from raw_data
self.raw_data.pop(recipe_index)
# Resort to update sorted lists
await self.resort()
return True

View File

@@ -0,0 +1,564 @@
import os
import logging
import asyncio
import json
from typing import List, Dict, Optional, Any, Tuple
from ..config import config
from .recipe_cache import RecipeCache
from .lora_scanner import LoraScanner
from .civitai_client import CivitaiClient
from ..utils.utils import fuzzy_match
import sys
logger = logging.getLogger(__name__)
class RecipeScanner:
"""Service for scanning and managing recipe images"""
_instance = None
_lock = asyncio.Lock()
def __new__(cls, lora_scanner: Optional[LoraScanner] = None):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._lora_scanner = lora_scanner
cls._instance._civitai_client = CivitaiClient()
return cls._instance
def __init__(self, lora_scanner: Optional[LoraScanner] = None):
# Ensure initialization only happens once
if not hasattr(self, '_initialized'):
self._cache: Optional[RecipeCache] = None
self._initialization_lock = asyncio.Lock()
self._initialization_task: Optional[asyncio.Task] = None
self._is_initializing = False
if lora_scanner:
self._lora_scanner = lora_scanner
self._initialized = True
# Initialization will be scheduled by LoraManager
@property
def recipes_dir(self) -> str:
"""Get path to recipes directory"""
if not config.loras_roots:
return ""
# config.loras_roots already sorted case-insensitively, use the first one
recipes_dir = os.path.join(config.loras_roots[0], "recipes")
os.makedirs(recipes_dir, exist_ok=True)
return recipes_dir
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
"""Get cached recipe data, refresh if needed"""
# If cache is already initialized and no refresh is needed, return it immediately
if self._cache is not None and not force_refresh:
return self._cache
# If another initialization is already in progress, wait for it to complete
if self._is_initializing and not force_refresh:
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
# Try to acquire the lock with a timeout to prevent deadlocks
try:
async with self._initialization_lock:
# Check again after acquiring the lock
if self._cache is not None and not force_refresh:
return self._cache
# Mark as initializing to prevent concurrent initializations
self._is_initializing = True
try:
# Remove dependency on lora scanner initialization
# Scan for recipe data directly
raw_data = await self.scan_all_recipes()
# Update cache
self._cache = RecipeCache(
raw_data=raw_data,
sorted_by_name=[],
sorted_by_date=[]
)
# Resort cache
await self._cache.resort()
return self._cache
except Exception as e:
logger.error(f"Recipe Manager: Error initializing cache: {e}", exc_info=True)
# Create empty cache on error
self._cache = RecipeCache(
raw_data=[],
sorted_by_name=[],
sorted_by_date=[]
)
return self._cache
finally:
# Mark initialization as complete
self._is_initializing = False
except Exception as e:
logger.error(f"Unexpected error in get_cached_data: {e}")
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
async def scan_all_recipes(self) -> List[Dict]:
"""Scan all recipe JSON files and return metadata"""
recipes = []
recipes_dir = self.recipes_dir
if not recipes_dir or not os.path.exists(recipes_dir):
logger.warning(f"Recipes directory not found: {recipes_dir}")
return recipes
# Get all recipe JSON files in the recipes directory
recipe_files = []
for root, _, files in os.walk(recipes_dir):
recipe_count = sum(1 for f in files if f.lower().endswith('.recipe.json'))
if recipe_count > 0:
for file in files:
if file.lower().endswith('.recipe.json'):
recipe_files.append(os.path.join(root, file))
# Process each recipe file
for recipe_path in recipe_files:
recipe_data = await self._load_recipe_file(recipe_path)
if recipe_data:
recipes.append(recipe_data)
return recipes
async def _load_recipe_file(self, recipe_path: str) -> Optional[Dict]:
"""Load recipe data from a JSON file"""
try:
with open(recipe_path, 'r', encoding='utf-8') as f:
recipe_data = json.load(f)
# Validate recipe data
if not recipe_data or not isinstance(recipe_data, dict):
logger.warning(f"Invalid recipe data in {recipe_path}")
return None
# Ensure required fields exist
required_fields = ['id', 'file_path', 'title']
for field in required_fields:
if field not in recipe_data:
logger.warning(f"Missing required field '{field}' in {recipe_path}")
return None
# Ensure the image file exists
image_path = recipe_data.get('file_path')
if not os.path.exists(image_path):
logger.warning(f"Recipe image not found: {image_path}")
# Try to find the image in the same directory as the recipe
recipe_dir = os.path.dirname(recipe_path)
image_filename = os.path.basename(image_path)
alternative_path = os.path.join(recipe_dir, image_filename)
if os.path.exists(alternative_path):
recipe_data['file_path'] = alternative_path
else:
logger.warning(f"Could not find alternative image path for {image_path}")
# Ensure loras array exists
if 'loras' not in recipe_data:
recipe_data['loras'] = []
# Ensure gen_params exists
if 'gen_params' not in recipe_data:
recipe_data['gen_params'] = {}
# Update lora information with local paths and availability
await self._update_lora_information(recipe_data)
return recipe_data
except Exception as e:
logger.error(f"Error loading recipe file {recipe_path}: {e}")
import traceback
traceback.print_exc(file=sys.stderr)
return None
async def _update_lora_information(self, recipe_data: Dict) -> bool:
"""Update LoRA information with hash and file_name
Returns:
bool: True if metadata was updated
"""
if not recipe_data.get('loras'):
return False
metadata_updated = False
for lora in recipe_data['loras']:
# Skip if already has complete information
if 'hash' in lora and 'file_name' in lora and lora['file_name']:
continue
# If has modelVersionId but no hash, look in lora cache first, then fetch from Civitai
if 'modelVersionId' in lora and not lora.get('hash'):
model_version_id = lora['modelVersionId']
# Try to find in lora cache first
hash_from_cache = await self._find_hash_in_lora_cache(model_version_id)
if hash_from_cache:
lora['hash'] = hash_from_cache
metadata_updated = True
else:
# If not in cache, fetch from Civitai
hash_from_civitai = await self._get_hash_from_civitai(model_version_id)
if hash_from_civitai:
lora['hash'] = hash_from_civitai
metadata_updated = True
else:
logger.debug(f"Could not get hash for modelVersionId {model_version_id}")
# If has hash but no file_name, look up in lora library
if 'hash' in lora and (not lora.get('file_name') or not lora['file_name']):
hash_value = lora['hash']
if self._lora_scanner.has_lora_hash(hash_value):
lora_path = self._lora_scanner.get_lora_path_by_hash(hash_value)
if lora_path:
file_name = os.path.splitext(os.path.basename(lora_path))[0]
lora['file_name'] = file_name
metadata_updated = True
else:
# Lora not in library
lora['file_name'] = ''
metadata_updated = True
return metadata_updated
async def _find_hash_in_lora_cache(self, model_version_id: str) -> Optional[str]:
"""Find hash in lora cache based on modelVersionId"""
try:
# Get all loras from cache
if not self._lora_scanner:
return None
cache = await self._lora_scanner.get_cached_data()
if not cache or not cache.raw_data:
return None
# Find lora with matching civitai.id
for lora in cache.raw_data:
civitai_data = lora.get('civitai', {})
if civitai_data and str(civitai_data.get('id', '')) == str(model_version_id):
return lora.get('sha256')
return None
except Exception as e:
logger.error(f"Error finding hash in lora cache: {e}")
return None
async def _get_hash_from_civitai(self, model_version_id: str) -> Optional[str]:
"""Get hash from Civitai API"""
try:
if not self._civitai_client:
return None
version_info = await self._civitai_client.get_model_version_info(model_version_id)
if not version_info or not version_info.get('files'):
logger.debug(f"No files found in version info for ID: {model_version_id}")
return None
# Get hash from the first file
for file_info in version_info.get('files', []):
if file_info.get('hashes', {}).get('SHA256'):
return file_info['hashes']['SHA256']
logger.debug(f"No SHA256 hash found in version info for ID: {model_version_id}")
return None
except Exception as e:
logger.error(f"Error getting hash from Civitai: {e}")
return None
async def _get_model_version_name(self, model_version_id: str) -> Optional[str]:
"""Get model version name from Civitai API"""
try:
if not self._civitai_client:
return None
version_info = await self._civitai_client.get_model_version_info(model_version_id)
if version_info and 'name' in version_info:
return version_info['name']
logger.debug(f"No version name found for modelVersionId {model_version_id}")
return None
except Exception as e:
logger.error(f"Error getting model version name from Civitai: {e}")
return None
async def _determine_base_model(self, loras: List[Dict]) -> Optional[str]:
"""Determine the most common base model among LoRAs"""
base_models = {}
# Count occurrences of each base model
for lora in loras:
if 'hash' in lora:
lora_path = self._lora_scanner.get_lora_path_by_hash(lora['hash'])
if lora_path:
base_model = await self._get_base_model_for_lora(lora_path)
if base_model:
base_models[base_model] = base_models.get(base_model, 0) + 1
# Return the most common base model
if base_models:
return max(base_models.items(), key=lambda x: x[1])[0]
return None
async def _get_base_model_for_lora(self, lora_path: str) -> Optional[str]:
"""Get base model for a LoRA from cache"""
try:
if not self._lora_scanner:
return None
cache = await self._lora_scanner.get_cached_data()
if not cache or not cache.raw_data:
return None
# Find matching lora in cache
for lora in cache.raw_data:
if lora.get('file_path') == lora_path:
return lora.get('base_model')
return None
except Exception as e:
logger.error(f"Error getting base model for lora: {e}")
return None
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None):
"""Get paginated and filtered recipe data
Args:
page: Current page number (1-based)
page_size: Number of items per page
sort_by: Sort method ('name' or 'date')
search: Search term
filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply
"""
cache = await self.get_cached_data()
# Get base dataset
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
# Apply search filter
if search:
# Default search options if none provided
if not search_options:
search_options = {
'title': True,
'tags': True,
'lora_name': True,
'lora_model': True
}
# Build the search predicate based on search options
def matches_search(item):
# Search in title if enabled
if search_options.get('title', True):
if fuzzy_match(str(item.get('title', '')), search):
return True
# Search in tags if enabled
if search_options.get('tags', True) and 'tags' in item:
for tag in item['tags']:
if fuzzy_match(tag, search):
return True
# Search in lora file names if enabled
if search_options.get('lora_name', True) and 'loras' in item:
for lora in item['loras']:
if fuzzy_match(str(lora.get('file_name', '')), search):
return True
# Search in lora model names if enabled
if search_options.get('lora_model', True) and 'loras' in item:
for lora in item['loras']:
if fuzzy_match(str(lora.get('modelName', '')), search):
return True
# No match found
return False
# Filter the data using the search predicate
filtered_data = [item for item in filtered_data if matches_search(item)]
# Apply additional filters
if filters:
# Filter by base model
if 'base_model' in filters and filters['base_model']:
filtered_data = [
item for item in filtered_data
if item.get('base_model', '') in filters['base_model']
]
# Filter by tags
if 'tags' in filters and filters['tags']:
filtered_data = [
item for item in filtered_data
if any(tag in item.get('tags', []) for tag in filters['tags'])
]
# Calculate pagination
total_items = len(filtered_data)
start_idx = (page - 1) * page_size
end_idx = min(start_idx + page_size, total_items)
# Get paginated items
paginated_items = filtered_data[start_idx:end_idx]
# Add inLibrary information for each lora
for item in paginated_items:
if 'loras' in item:
for lora in item['loras']:
if 'hash' in lora and lora['hash']:
lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora['hash'].lower())
lora['preview_url'] = self._lora_scanner.get_preview_url_by_hash(lora['hash'].lower())
lora['localPath'] = self._lora_scanner.get_lora_path_by_hash(lora['hash'].lower())
result = {
'items': paginated_items,
'total': total_items,
'page': page,
'page_size': page_size,
'total_pages': (total_items + page_size - 1) // page_size
}
return result
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
"""Update recipe metadata (like title and tags) in both file system and cache
Args:
recipe_id: The ID of the recipe to update
metadata: Dictionary containing metadata fields to update (title, tags, etc.)
Returns:
bool: True if successful, False otherwise
"""
import os
import json
# First, find the recipe JSON file path
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
if not os.path.exists(recipe_json_path):
return False
try:
# Load existing recipe data
with open(recipe_json_path, 'r', encoding='utf-8') as f:
recipe_data = json.load(f)
# Update fields
for key, value in metadata.items():
recipe_data[key] = value
# Save updated recipe
with open(recipe_json_path, 'w', encoding='utf-8') as f:
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
# Update the cache if it exists
if self._cache is not None:
await self._cache.update_recipe_metadata(recipe_id, metadata)
# If the recipe has an image, update its EXIF metadata
from ..utils.exif_utils import ExifUtils
image_path = recipe_data.get('file_path')
if image_path and os.path.exists(image_path):
ExifUtils.append_recipe_metadata(image_path, recipe_data)
return True
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Error updating recipe metadata: {e}", exc_info=True)
return False
async def update_lora_filename_by_hash(self, hash_value: str, new_file_name: str) -> Tuple[int, int]:
"""Update file_name in all recipes that contain a LoRA with the specified hash.
Args:
hash_value: The SHA256 hash value of the LoRA
new_file_name: The new file_name to set
Returns:
Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache)
"""
if not hash_value or not new_file_name:
return 0, 0
# Always use lowercase hash for consistency
hash_value = hash_value.lower()
# Get recipes directory
recipes_dir = self.recipes_dir
if not recipes_dir or not os.path.exists(recipes_dir):
logger.warning(f"Recipes directory not found: {recipes_dir}")
return 0, 0
# Check if cache is initialized
cache_initialized = self._cache is not None
cache_updated_count = 0
file_updated_count = 0
# Get all recipe JSON files in the recipes directory
recipe_files = []
for root, _, files in os.walk(recipes_dir):
for file in files:
if file.lower().endswith('.recipe.json'):
recipe_files.append(os.path.join(root, file))
# Process each recipe file
for recipe_path in recipe_files:
try:
# Load the recipe data
with open(recipe_path, 'r', encoding='utf-8') as f:
recipe_data = json.load(f)
# Skip if no loras or invalid structure
if not recipe_data or not isinstance(recipe_data, dict) or 'loras' not in recipe_data:
continue
# Check if any lora has matching hash
file_updated = False
for lora in recipe_data.get('loras', []):
if 'hash' in lora and lora['hash'].lower() == hash_value:
# Update file_name
old_file_name = lora.get('file_name', '')
lora['file_name'] = new_file_name
file_updated = True
logger.info(f"Updated file_name in recipe {recipe_path}: {old_file_name} -> {new_file_name}")
# If updated, save the file
if file_updated:
with open(recipe_path, 'w', encoding='utf-8') as f:
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
file_updated_count += 1
# Also update in cache if it exists
if cache_initialized:
recipe_id = recipe_data.get('id')
if recipe_id:
for cache_item in self._cache.raw_data:
if cache_item.get('id') == recipe_id:
# Replace loras array with updated version
cache_item['loras'] = recipe_data['loras']
cache_updated_count += 1
break
except Exception as e:
logger.error(f"Error updating recipe file {recipe_path}: {e}")
import traceback
traceback.print_exc(file=sys.stderr)
# Resort cache if updates were made
if cache_initialized and cache_updated_count > 0:
await self._cache.resort()
logger.info(f"Resorted recipe cache after updating {cache_updated_count} items")
return file_updated_count, cache_updated_count

315
py/utils/exif_utils.py Normal file
View File

@@ -0,0 +1,315 @@
import piexif
import json
import logging
from typing import Optional
from io import BytesIO
import os
from PIL import Image
logger = logging.getLogger(__name__)
class ExifUtils:
"""Utility functions for working with EXIF data in images"""
@staticmethod
def extract_image_metadata(image_path: str) -> Optional[str]:
"""Extract metadata from image including UserComment or parameters field
Args:
image_path (str): Path to the image file
Returns:
Optional[str]: Extracted metadata or None if not found
"""
try:
# First try to open the image
with Image.open(image_path) as img:
# Method 1: Check for parameters in image info
if hasattr(img, 'info') and 'parameters' in img.info:
return img.info['parameters']
# Method 2: Check EXIF UserComment field
if img.format not in ['JPEG', 'TIFF', 'WEBP']:
# For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL
exif = img._getexif()
if exif and piexif.ExifIFD.UserComment in exif:
user_comment = exif[piexif.ExifIFD.UserComment]
if isinstance(user_comment, bytes):
if user_comment.startswith(b'UNICODE\0'):
return user_comment[8:].decode('utf-16be')
return user_comment.decode('utf-8', errors='ignore')
return user_comment
# For JPEG/TIFF/WEBP, use piexif
try:
exif_dict = piexif.load(image_path)
if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}):
user_comment = exif_dict['Exif'][piexif.ExifIFD.UserComment]
if isinstance(user_comment, bytes):
if user_comment.startswith(b'UNICODE\0'):
user_comment = user_comment[8:].decode('utf-16be')
else:
user_comment = user_comment.decode('utf-8', errors='ignore')
return user_comment
except Exception as e:
logger.debug(f"Error loading EXIF data: {e}")
# Method 3: Check PNG metadata for workflow info (for ComfyUI images)
if img.format == 'PNG':
# Look for workflow or prompt metadata in PNG chunks
for key in img.info:
if key in ['workflow', 'prompt', 'parameters']:
return img.info[key]
return None
except Exception as e:
logger.error(f"Error extracting image metadata: {e}", exc_info=True)
return None
@staticmethod
def update_image_metadata(image_path: str, metadata: str) -> str:
"""Update metadata in image's EXIF data or parameters fields
Args:
image_path (str): Path to the image file
metadata (str): Metadata string to save
Returns:
str: Path to the updated image
"""
try:
# Load the image and check its format
with Image.open(image_path) as img:
img_format = img.format
# For PNG, try to update parameters directly
if img_format == 'PNG':
# We'll save with parameters in the PNG info
info_dict = {'parameters': metadata}
img.save(image_path, format='PNG', pnginfo=info_dict)
return image_path
# For WebP format, use PIL's exif parameter directly
elif img_format == 'WEBP':
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
# Save with the exif data
img.save(image_path, format='WEBP', exif=exif_bytes, quality=85)
return image_path
# For other formats, use standard EXIF approach
else:
try:
exif_dict = piexif.load(img.info.get('exif', b''))
except:
exif_dict = {'0th':{}, 'Exif':{}, 'GPS':{}, 'Interop':{}, '1st':{}}
# If no Exif dictionary exists, create one
if 'Exif' not in exif_dict:
exif_dict['Exif'] = {}
# Update the UserComment field - use UNICODE format
unicode_bytes = metadata.encode('utf-16be')
metadata_bytes = b'UNICODE\0' + unicode_bytes
exif_dict['Exif'][piexif.ExifIFD.UserComment] = metadata_bytes
# Convert EXIF dict back to bytes
exif_bytes = piexif.dump(exif_dict)
# Save the image with updated EXIF data
img.save(image_path, exif=exif_bytes)
return image_path
except Exception as e:
logger.error(f"Error updating metadata in {image_path}: {e}")
return image_path
@staticmethod
def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data"""
try:
# First, extract existing metadata
metadata = ExifUtils.extract_image_metadata(image_path)
# Check if there's already recipe metadata
if metadata:
# Remove any existing recipe metadata
metadata = ExifUtils.remove_recipe_metadata(metadata)
# Prepare simplified loras data
simplified_loras = []
for lora in recipe_data.get("loras", []):
simplified_lora = {
"file_name": lora.get("file_name", ""),
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
"strength": float(lora.get("strength", 1.0)),
"modelVersionId": lora.get("modelVersionId", ""),
"modelName": lora.get("modelName", ""),
"modelVersionName": lora.get("modelVersionName", ""),
}
simplified_loras.append(simplified_lora)
# Create recipe metadata JSON
recipe_metadata = {
'title': recipe_data.get('title', ''),
'base_model': recipe_data.get('base_model', ''),
'loras': simplified_loras,
'gen_params': recipe_data.get('gen_params', {}),
'tags': recipe_data.get('tags', [])
}
# Convert to JSON string
recipe_metadata_json = json.dumps(recipe_metadata)
# Create the recipe metadata marker
recipe_metadata_marker = f"Recipe metadata: {recipe_metadata_json}"
# Append to existing metadata or create new one
new_metadata = f"{metadata} \n {recipe_metadata_marker}" if metadata else recipe_metadata_marker
# Write back to the image
return ExifUtils.update_image_metadata(image_path, new_metadata)
except Exception as e:
logger.error(f"Error appending recipe metadata: {e}", exc_info=True)
return image_path
@staticmethod
def remove_recipe_metadata(user_comment):
"""Remove recipe metadata from user comment"""
if not user_comment:
return ""
# Find the recipe metadata marker
recipe_marker_index = user_comment.find("Recipe metadata: ")
if recipe_marker_index == -1:
return user_comment
# If recipe metadata is not at the start, remove the preceding ", "
if recipe_marker_index >= 2 and user_comment[recipe_marker_index-2:recipe_marker_index] == ", ":
recipe_marker_index -= 2
# Remove the recipe metadata part
# First, find where the metadata ends (next line or end of string)
next_line_index = user_comment.find("\n", recipe_marker_index)
if next_line_index == -1:
# Metadata is at the end of the string
return user_comment[:recipe_marker_index].rstrip()
else:
# Metadata is in the middle of the string
return user_comment[:recipe_marker_index] + user_comment[next_line_index:]
@staticmethod
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=True):
"""
Optimize an image by resizing and converting to WebP format
Args:
image_data: Binary image data or path to image file
target_width: Width to resize the image to (preserves aspect ratio)
format: Output format (default: webp)
quality: Output quality (0-100)
preserve_metadata: Whether to preserve EXIF metadata
Returns:
Tuple of (optimized_image_data, extension)
"""
try:
# Extract metadata if needed
metadata = None
if preserve_metadata:
if isinstance(image_data, str) and os.path.exists(image_data):
# It's a file path
metadata = ExifUtils.extract_image_metadata(image_data)
img = Image.open(image_data)
else:
# It's binary data
temp_img = BytesIO(image_data)
img = Image.open(temp_img)
# Save to a temporary file to extract metadata
import tempfile
with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(image_data)
metadata = ExifUtils.extract_image_metadata(temp_path)
os.unlink(temp_path)
else:
# Just open the image without extracting metadata
if isinstance(image_data, str) and os.path.exists(image_data):
img = Image.open(image_data)
else:
img = Image.open(BytesIO(image_data))
# Calculate new height to maintain aspect ratio
width, height = img.size
new_height = int(height * (target_width / width))
# Resize the image
resized_img = img.resize((target_width, new_height), Image.LANCZOS)
# Save to BytesIO in the specified format
output = BytesIO()
# WebP format
if format.lower() == 'webp':
resized_img.save(output, format='WEBP', quality=quality)
extension = '.webp'
# JPEG format
elif format.lower() in ('jpg', 'jpeg'):
resized_img.save(output, format='JPEG', quality=quality)
extension = '.jpg'
# PNG format
elif format.lower() == 'png':
resized_img.save(output, format='PNG', optimize=True)
extension = '.png'
else:
# Default to WebP
resized_img.save(output, format='WEBP', quality=quality)
extension = '.webp'
# Get the optimized image data
optimized_data = output.getvalue()
# If we need to preserve metadata, write it to a temporary file
if preserve_metadata and metadata:
# For WebP format, we'll directly save with metadata
if format.lower() == 'webp':
# Create a new BytesIO with metadata
output_with_metadata = BytesIO()
# Create EXIF data with user comment
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict)
# Save with metadata
resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality)
optimized_data = output_with_metadata.getvalue()
else:
# For other formats, use the temporary file approach
import tempfile
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_file:
temp_path = temp_file.name
temp_file.write(optimized_data)
# Add the metadata back
ExifUtils.update_image_metadata(temp_path, metadata)
# Read the file with metadata
with open(temp_path, 'rb') as f:
optimized_data = f.read()
# Clean up
os.unlink(temp_path)
return optimized_data, extension
except Exception as e:
logger.error(f"Error optimizing image: {e}", exc_info=True)
# Return original data if optimization fails
if isinstance(image_data, str) and os.path.exists(image_data):
with open(image_data, 'rb') as f:
return f.read(), os.path.splitext(image_data)[1]
return image_data, '.jpg'

View File

@@ -4,6 +4,8 @@ import hashlib
import json
from typing import Dict, Optional
from .model_utils import determine_base_model
from .lora_metadata import extract_lora_metadata
from .models import LoraMetadata
@@ -17,7 +19,7 @@ async def calculate_sha256(file_path: str) -> str:
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def _find_preview_file(base_name: str, dir_path: str) -> str:
def find_preview_file(base_name: str, dir_path: str) -> str:
"""Find preview file for given base name in directory"""
preview_patterns = [
f"{base_name}.preview.png",
@@ -54,16 +56,33 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
preview_url = _find_preview_file(base_name, dir_path)
preview_url = find_preview_file(base_name, dir_path)
# Check if a .json file exists with SHA256 hash to avoid recalculation
json_path = f"{os.path.splitext(file_path)[0]}.json"
sha256 = None
if os.path.exists(json_path):
try:
with open(json_path, 'r', encoding='utf-8') as f:
json_data = json.load(f)
if 'sha256' in json_data:
sha256 = json_data['sha256'].lower()
logger.debug(f"Using SHA256 from .json file for {file_path}")
except Exception as e:
logger.error(f"Error reading .json file for {file_path}: {e}")
try:
# If we didn't get SHA256 from the .json file, calculate it
if not sha256:
sha256 = await calculate_sha256(real_path)
metadata = LoraMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=os.path.getmtime(real_path),
sha256=await calculate_sha256(real_path),
sha256=sha256,
base_model="Unknown", # Will be updated later
usage_tips="",
notes="",
@@ -105,6 +124,12 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
data = json.load(f)
needs_update = False
# Check and normalize base model name
normalized_base_model = determine_base_model(data['base_model'])
if data['base_model'] != normalized_base_model:
data['base_model'] = normalized_base_model
needs_update = True
# Compare paths without extensions
stored_path_base = os.path.splitext(data['file_path'])[0]
@@ -117,7 +142,7 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
if not preview_url or not os.path.exists(preview_url):
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = normalize_path(_find_preview_file(base_name, dir_path))
new_preview_url = normalize_path(find_preview_file(base_name, dir_path))
if new_preview_url != preview_url:
data['preview_url'] = new_preview_url
needs_update = True

View File

@@ -2,13 +2,15 @@ from typing import Optional
# Base model mapping based on version string
BASE_MODEL_MAPPING = {
"sd_1.5": "SD 1.5",
"sd-v1-5": "SD 1.5",
"sd-v2-1": "SD 2.1",
"sdxl": "SDXL 1.0",
"sd-v2": "SD 2.0",
"flux1": "Flux.1 D",
"flux.1 d": "Flux.1 D",
"illustrious": "IL",
"illustrious": "Illustrious",
"il": "Illustrious",
"pony": "Pony",
"Hunyuan Video": "Hunyuan Video"
}

View File

@@ -47,7 +47,7 @@ class LoraMetadata:
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
modified=datetime.now().timestamp(),
sha256=file_info['hashes'].get('SHA256', ''),
sha256=file_info['hashes'].get('SHA256', '').lower(),
base_model=base_model,
preview_url=None, # Will be updated after preview download
preview_nsfw_level=0, # Will be updated after preview download, it is decided by the nsfw level of the preview image
@@ -75,3 +75,31 @@ class LoraMetadata:
self.modified = os.path.getmtime(file_path)
self.file_path = file_path.replace(os.sep, '/')
@dataclass
class CheckpointMetadata:
"""Represents the metadata structure for a Checkpoint model"""
file_name: str # The filename without extension
model_name: str # The checkpoint's name defined by the creator
file_path: str # Full path to the model file
size: int # File size in bytes
modified: float # Last modified timestamp
sha256: str # SHA256 hash of the file
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
preview_url: str # Preview image URL
preview_nsfw_level: int = 0 # NSFW level of the preview image
model_type: str = "checkpoint" # Model type (checkpoint, inpainting, etc.)
notes: str = "" # Additional notes
from_civitai: bool = True # Whether from Civitai
civitai: Optional[Dict] = None # Civitai API data if available
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description
# Additional checkpoint-specific fields
resolution: Optional[str] = None # Native resolution (e.g., 512x512, 1024x1024)
vae_included: bool = False # Whether VAE is included in the checkpoint
architecture: str = "" # Model architecture (if known)
def __post_init__(self):
if self.tags is None:
self.tags = []

1083
py/utils/recipe_parsers.py Normal file

File diff suppressed because it is too large Load Diff

116
py/utils/utils.py Normal file
View File

@@ -0,0 +1,116 @@
from difflib import SequenceMatcher
import requests
import tempfile
import re
from bs4 import BeautifulSoup
def download_twitter_image(url):
"""Download image from a URL containing twitter:image meta tag
Args:
url (str): The URL to download image from
Returns:
str: Path to downloaded temporary image file
"""
try:
# Download page content
response = requests.get(url)
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.text, 'html.parser')
# Find twitter:image meta tag
meta_tag = soup.find('meta', attrs={'property': 'twitter:image'})
if not meta_tag:
return None
image_url = meta_tag['content']
# Download image
image_response = requests.get(image_url)
image_response.raise_for_status()
# Save to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
temp_file.write(image_response.content)
return temp_file.name
except Exception as e:
print(f"Error downloading twitter image: {e}")
return None
def download_civitai_image(url):
"""Download image from a URL containing avatar image with specific class and style attributes
Args:
url (str): The URL to download image from
Returns:
str: Path to downloaded temporary image file
"""
try:
# Download page content
response = requests.get(url)
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.text, 'html.parser')
# Find image with specific class and style attributes
image = soup.select_one('img.EdgeImage_image__iH4_q.max-h-full.w-auto.max-w-full')
if not image or 'src' not in image.attrs:
return None
image_url = image['src']
# Download image
image_response = requests.get(image_url)
image_response.raise_for_status()
# Save to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
temp_file.write(image_response.content)
return temp_file.name
except Exception as e:
print(f"Error downloading civitai avatar: {e}")
return None
def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool:
"""
Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold.
"""
if not pattern or not text:
return False
# Convert both to lowercase for case-insensitive matching
text = text.lower()
pattern = pattern.lower()
# Split pattern into words
search_words = pattern.split()
# Check each word
for word in search_words:
# First check if word is a substring (faster)
if word in text:
continue
# If not found as substring, try fuzzy matching
# Check if any part of the text matches this word
found_match = False
for text_part in text.split():
ratio = SequenceMatcher(None, text_part, word).ratio()
if ratio >= threshold:
found_match = True
break
if not found_match:
return False
# All words found either as substrings or fuzzy matches
return True

3
py/workflow/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
ComfyUI workflow parsing module to extract generation parameters
"""

58
py/workflow/cli.py Normal file
View File

@@ -0,0 +1,58 @@
"""
Command-line interface for the ComfyUI workflow parser
"""
import argparse
import json
import os
import logging
import sys
from .parser import parse_workflow
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
def main():
"""Entry point for the CLI"""
parser = argparse.ArgumentParser(description='Parse ComfyUI workflow files')
parser.add_argument('input', help='Input workflow JSON file path')
parser.add_argument('-o', '--output', help='Output JSON file path')
parser.add_argument('-p', '--pretty', action='store_true', help='Pretty print JSON output')
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
args = parser.parse_args()
# Set logging level
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
# Validate input file
if not os.path.isfile(args.input):
logger.error(f"Input file not found: {args.input}")
sys.exit(1)
# Parse workflow
try:
result = parse_workflow(args.input, args.output)
# Print result to console if output file not specified
if not args.output:
if args.pretty:
print(json.dumps(result, indent=4))
else:
print(json.dumps(result))
else:
logger.info(f"Output saved to: {args.output}")
except Exception as e:
logger.error(f"Error parsing workflow: {e}")
if args.debug:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,3 @@
"""
Extension directory for custom node mappers
"""

View File

@@ -0,0 +1,285 @@
"""
ComfyUI Core nodes mappers extension for workflow parsing
"""
import logging
from typing import Dict, Any, List
logger = logging.getLogger(__name__)
# =============================================================================
# Transform Functions
# =============================================================================
def transform_random_noise(inputs: Dict) -> Dict:
"""Transform function for RandomNoise node"""
return {"seed": str(inputs.get("noise_seed", ""))}
def transform_ksampler_select(inputs: Dict) -> Dict:
"""Transform function for KSamplerSelect node"""
return {"sampler": inputs.get("sampler_name", "")}
def transform_basic_scheduler(inputs: Dict) -> Dict:
"""Transform function for BasicScheduler node"""
result = {
"scheduler": inputs.get("scheduler", ""),
"denoise": str(inputs.get("denoise", "1.0"))
}
# Get steps from inputs or steps input
if "steps" in inputs:
if isinstance(inputs["steps"], str):
result["steps"] = inputs["steps"]
elif isinstance(inputs["steps"], dict) and "value" in inputs["steps"]:
result["steps"] = str(inputs["steps"]["value"])
else:
result["steps"] = str(inputs["steps"])
return result
def transform_basic_guider(inputs: Dict) -> Dict:
"""Transform function for BasicGuider node"""
result = {}
# Process conditioning
if "conditioning" in inputs:
if isinstance(inputs["conditioning"], str):
result["prompt"] = inputs["conditioning"]
elif isinstance(inputs["conditioning"], dict):
result["conditioning"] = inputs["conditioning"]
# Get model information if needed
if "model" in inputs and isinstance(inputs["model"], dict):
result["model"] = inputs["model"]
return result
def transform_model_sampling_flux(inputs: Dict) -> Dict:
"""Transform function for ModelSamplingFlux - mostly a pass-through node"""
# This node is primarily used for routing, so we mostly pass through values
return inputs["model"]
def transform_sampler_custom_advanced(inputs: Dict) -> Dict:
"""Transform function for SamplerCustomAdvanced node"""
result = {}
# Extract seed from noise
if "noise" in inputs and isinstance(inputs["noise"], dict):
result["seed"] = str(inputs["noise"].get("seed", ""))
# Extract sampler info
if "sampler" in inputs and isinstance(inputs["sampler"], dict):
sampler = inputs["sampler"].get("sampler", "")
if sampler:
result["sampler"] = sampler
# Extract scheduler, steps, denoise from sigmas
if "sigmas" in inputs and isinstance(inputs["sigmas"], dict):
sigmas = inputs["sigmas"]
result["scheduler"] = sigmas.get("scheduler", "")
result["steps"] = str(sigmas.get("steps", ""))
result["denoise"] = str(sigmas.get("denoise", "1.0"))
# Extract prompt and guidance from guider
if "guider" in inputs and isinstance(inputs["guider"], dict):
guider = inputs["guider"]
# Get prompt from conditioning
if "conditioning" in guider and isinstance(guider["conditioning"], str):
result["prompt"] = guider["conditioning"]
elif "conditioning" in guider and isinstance(guider["conditioning"], dict):
result["guidance"] = guider["conditioning"].get("guidance", "")
result["prompt"] = guider["conditioning"].get("prompt", "")
if "model" in guider and isinstance(guider["model"], dict):
result["checkpoint"] = guider["model"].get("checkpoint", "")
result["loras"] = guider["model"].get("loras", "")
result["clip_skip"] = str(int(guider["model"].get("clip_skip", "-1")) * -1)
# Extract dimensions from latent_image
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
latent = inputs["latent_image"]
width = latent.get("width", 0)
height = latent.get("height", 0)
if width and height:
result["width"] = width
result["height"] = height
result["size"] = f"{width}x{height}"
return result
def transform_ksampler(inputs: Dict) -> Dict:
"""Transform function for KSampler nodes"""
result = {
"seed": str(inputs.get("seed", "")),
"steps": str(inputs.get("steps", "")),
"cfg": str(inputs.get("cfg", "")),
"sampler": inputs.get("sampler_name", ""),
"scheduler": inputs.get("scheduler", ""),
}
# Process positive prompt
if "positive" in inputs:
result["prompt"] = inputs["positive"]
# Process negative prompt
if "negative" in inputs:
result["negative_prompt"] = inputs["negative"]
# Get dimensions from latent image
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
width = inputs["latent_image"].get("width", 0)
height = inputs["latent_image"].get("height", 0)
if width and height:
result["size"] = f"{width}x{height}"
# Add clip_skip if present
if "clip_skip" in inputs:
result["clip_skip"] = str(inputs.get("clip_skip", ""))
# Add guidance if present
if "guidance" in inputs:
result["guidance"] = str(inputs.get("guidance", ""))
# Add model if present
if "model" in inputs:
result["checkpoint"] = inputs.get("model", {}).get("checkpoint", "")
result["loras"] = inputs.get("model", {}).get("loras", "")
result["clip_skip"] = str(inputs.get("model", {}).get("clip_skip", -1) * -1)
return result
def transform_empty_latent(inputs: Dict) -> Dict:
"""Transform function for EmptyLatentImage nodes"""
width = inputs.get("width", 0)
height = inputs.get("height", 0)
return {"width": width, "height": height, "size": f"{width}x{height}"}
def transform_clip_text(inputs: Dict) -> Any:
"""Transform function for CLIPTextEncode nodes"""
return inputs.get("text", "")
def transform_flux_guidance(inputs: Dict) -> Dict:
"""Transform function for FluxGuidance nodes"""
result = {}
if "guidance" in inputs:
result["guidance"] = inputs["guidance"]
if "conditioning" in inputs:
conditioning = inputs["conditioning"]
if isinstance(conditioning, str):
result["prompt"] = conditioning
else:
result["prompt"] = "Unknown prompt"
return result
def transform_unet_loader(inputs: Dict) -> Dict:
"""Transform function for UNETLoader node"""
unet_name = inputs.get("unet_name", "")
return {"checkpoint": unet_name} if unet_name else {}
def transform_checkpoint_loader(inputs: Dict) -> Dict:
"""Transform function for CheckpointLoaderSimple node"""
ckpt_name = inputs.get("ckpt_name", "")
return {"checkpoint": ckpt_name} if ckpt_name else {}
def transform_latent_upscale_by(inputs: Dict) -> Dict:
"""Transform function for LatentUpscaleBy node"""
result = {}
width = inputs["samples"].get("width", 0) * inputs["scale_by"]
height = inputs["samples"].get("height", 0) * inputs["scale_by"]
result["width"] = width
result["height"] = height
result["size"] = f"{width}x{height}"
return result
def transform_clip_set_last_layer(inputs: Dict) -> Dict:
"""Transform function for CLIPSetLastLayer node"""
result = {}
if "stop_at_clip_layer" in inputs:
result["clip_skip"] = inputs["stop_at_clip_layer"]
return result
# =============================================================================
# Node Mapper Definitions
# =============================================================================
# Define the mappers for ComfyUI core nodes not in main mapper
NODE_MAPPERS_EXT = {
# KSamplers
"SamplerCustomAdvanced": {
"inputs_to_track": ["noise", "guider", "sampler", "sigmas", "latent_image"],
"transform_func": transform_sampler_custom_advanced
},
"KSampler": {
"inputs_to_track": [
"seed", "steps", "cfg", "sampler_name", "scheduler",
"denoise", "positive", "negative", "latent_image",
"model", "clip_skip"
],
"transform_func": transform_ksampler
},
# ComfyUI core nodes
"EmptyLatentImage": {
"inputs_to_track": ["width", "height", "batch_size"],
"transform_func": transform_empty_latent
},
"EmptySD3LatentImage": {
"inputs_to_track": ["width", "height", "batch_size"],
"transform_func": transform_empty_latent
},
"CLIPTextEncode": {
"inputs_to_track": ["text", "clip"],
"transform_func": transform_clip_text
},
"FluxGuidance": {
"inputs_to_track": ["guidance", "conditioning"],
"transform_func": transform_flux_guidance
},
"RandomNoise": {
"inputs_to_track": ["noise_seed"],
"transform_func": transform_random_noise
},
"KSamplerSelect": {
"inputs_to_track": ["sampler_name"],
"transform_func": transform_ksampler_select
},
"BasicScheduler": {
"inputs_to_track": ["scheduler", "steps", "denoise", "model"],
"transform_func": transform_basic_scheduler
},
"BasicGuider": {
"inputs_to_track": ["model", "conditioning"],
"transform_func": transform_basic_guider
},
"ModelSamplingFlux": {
"inputs_to_track": ["max_shift", "base_shift", "width", "height", "model"],
"transform_func": transform_model_sampling_flux
},
"UNETLoader": {
"inputs_to_track": ["unet_name"],
"transform_func": transform_unet_loader
},
"CheckpointLoaderSimple": {
"inputs_to_track": ["ckpt_name"],
"transform_func": transform_checkpoint_loader
},
"LatentUpscale": {
"inputs_to_track": ["width", "height"],
"transform_func": transform_empty_latent
},
"LatentUpscaleBy": {
"inputs_to_track": ["samples", "scale_by"],
"transform_func": transform_latent_upscale_by
},
"CLIPSetLastLayer": {
"inputs_to_track": ["clip", "stop_at_clip_layer"],
"transform_func": transform_clip_set_last_layer
}
}

View File

@@ -0,0 +1,74 @@
"""
KJNodes mappers extension for ComfyUI workflow parsing
"""
import logging
import re
from typing import Dict, Any
logger = logging.getLogger(__name__)
# =============================================================================
# Transform Functions
# =============================================================================
def transform_join_strings(inputs: Dict) -> str:
"""Transform function for JoinStrings nodes"""
string1 = inputs.get("string1", "")
string2 = inputs.get("string2", "")
delimiter = inputs.get("delimiter", "")
return f"{string1}{delimiter}{string2}"
def transform_string_constant(inputs: Dict) -> str:
"""Transform function for StringConstant nodes"""
return inputs.get("string", "")
def transform_empty_latent_presets(inputs: Dict) -> Dict:
"""Transform function for EmptyLatentImagePresets nodes"""
dimensions = inputs.get("dimensions", "")
invert = inputs.get("invert", False)
# Extract width and height from dimensions string
# Expected format: "width x height (ratio)" or similar
width = 0
height = 0
if dimensions:
# Try to extract dimensions using regex
match = re.search(r'(\d+)\s*x\s*(\d+)', dimensions)
if match:
width = int(match.group(1))
height = int(match.group(2))
# If invert is True, swap width and height
if invert and width and height:
width, height = height, width
return {"width": width, "height": height, "size": f"{width}x{height}"}
def transform_int_constant(inputs: Dict) -> int:
"""Transform function for INTConstant nodes"""
return inputs.get("value", 0)
# =============================================================================
# Node Mapper Definitions
# =============================================================================
# Define the mappers for KJNodes
NODE_MAPPERS_EXT = {
"JoinStrings": {
"inputs_to_track": ["string1", "string2", "delimiter"],
"transform_func": transform_join_strings
},
"StringConstantMultiline": {
"inputs_to_track": ["string"],
"transform_func": transform_string_constant
},
"EmptyLatentImagePresets": {
"inputs_to_track": ["dimensions", "invert", "batch_size"],
"transform_func": transform_empty_latent_presets
},
"INTConstant": {
"inputs_to_track": ["value"],
"transform_func": transform_int_constant
}
}

37
py/workflow/main.py Normal file
View File

@@ -0,0 +1,37 @@
"""
Main entry point for the workflow parser module
"""
import os
import sys
import logging
from typing import Dict, Optional, Union
# Add the parent directory to sys.path to enable imports
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..'))
sys.path.insert(0, os.path.dirname(SCRIPT_DIR))
from .parser import parse_workflow
logger = logging.getLogger(__name__)
def parse_comfyui_workflow(
workflow_path: str,
output_path: Optional[str] = None
) -> Dict:
"""
Parse a ComfyUI workflow file and extract generation parameters
Args:
workflow_path: Path to the workflow JSON file
output_path: Optional path to save the output JSON
Returns:
Dictionary containing extracted parameters
"""
return parse_workflow(workflow_path, output_path)
if __name__ == "__main__":
# If run directly, use the CLI
from .cli import main
main()

282
py/workflow/mappers.py Normal file
View File

@@ -0,0 +1,282 @@
"""
Node mappers for ComfyUI workflow parsing
"""
import logging
import os
import importlib.util
import inspect
from typing import Dict, List, Any, Optional, Union, Type, Callable, Tuple
logger = logging.getLogger(__name__)
# Global mapper registry
_MAPPER_REGISTRY: Dict[str, Dict] = {}
# =============================================================================
# Mapper Definition Functions
# =============================================================================
def create_mapper(
node_type: str,
inputs_to_track: List[str],
transform_func: Callable[[Dict], Any] = None
) -> Dict:
"""Create a mapper definition for a node type"""
mapper = {
"node_type": node_type,
"inputs_to_track": inputs_to_track,
"transform": transform_func or (lambda inputs: inputs)
}
return mapper
def register_mapper(mapper: Dict) -> None:
"""Register a node mapper in the global registry"""
_MAPPER_REGISTRY[mapper["node_type"]] = mapper
logger.debug(f"Registered mapper for node type: {mapper['node_type']}")
def get_mapper(node_type: str) -> Optional[Dict]:
"""Get a mapper for the specified node type"""
return _MAPPER_REGISTRY.get(node_type)
def get_all_mappers() -> Dict[str, Dict]:
"""Get all registered mappers"""
return _MAPPER_REGISTRY.copy()
# =============================================================================
# Node Processing Function
# =============================================================================
def process_node(node_id: str, node_data: Dict, workflow: Dict, parser: 'WorkflowParser') -> Any: # type: ignore
"""Process a node using its mapper and extract relevant information"""
node_type = node_data.get("class_type")
mapper = get_mapper(node_type)
if not mapper:
logger.warning(f"No mapper found for node type: {node_type}")
return None
result = {}
# Extract inputs based on the mapper's tracked inputs
for input_name in mapper["inputs_to_track"]:
if input_name in node_data.get("inputs", {}):
input_value = node_data["inputs"][input_name]
# Check if input is a reference to another node's output
if isinstance(input_value, list) and len(input_value) == 2:
try:
# Format is [node_id, output_slot]
ref_node_id, output_slot = input_value
# Convert node_id to string if it's an integer
if isinstance(ref_node_id, int):
ref_node_id = str(ref_node_id)
# Recursively process the referenced node
ref_value = parser.process_node(ref_node_id, workflow)
if ref_value is not None:
result[input_name] = ref_value
else:
# If we couldn't get a value from the reference, store the raw value
result[input_name] = input_value
except Exception as e:
logger.error(f"Error processing reference in node {node_id}, input {input_name}: {e}")
result[input_name] = input_value
else:
# Direct value
result[input_name] = input_value
# Apply the transform function
try:
return mapper["transform"](result)
except Exception as e:
logger.error(f"Error in transform function for node {node_id} of type {node_type}: {e}")
return result
# =============================================================================
# Transform Functions
# =============================================================================
def transform_lora_loader(inputs: Dict) -> Dict:
"""Transform function for LoraLoader nodes"""
loras_data = inputs.get("loras", [])
lora_stack = inputs.get("lora_stack", {}).get("lora_stack", [])
lora_texts = []
# Process loras array
if isinstance(loras_data, dict) and "__value__" in loras_data:
loras_list = loras_data["__value__"]
elif isinstance(loras_data, list):
loras_list = loras_data
else:
loras_list = []
# Process each active lora entry
for lora in loras_list:
if isinstance(lora, dict) and lora.get("active", False):
lora_name = lora.get("name", "")
strength = lora.get("strength", 1.0)
lora_texts.append(f"<lora:{lora_name}:{strength}>")
# Process lora_stack if valid
if lora_stack and isinstance(lora_stack, list):
if not (len(lora_stack) == 2 and isinstance(lora_stack[0], (str, int)) and isinstance(lora_stack[1], int)):
for stack_entry in lora_stack:
lora_name = stack_entry[0]
strength = stack_entry[1]
lora_texts.append(f"<lora:{lora_name}:{strength}>")
result = {
"checkpoint": inputs.get("model", {}).get("checkpoint", ""),
"loras": " ".join(lora_texts)
}
if "clip" in inputs and isinstance(inputs["clip"], dict):
result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
return result
def transform_lora_stacker(inputs: Dict) -> Dict:
"""Transform function for LoraStacker nodes"""
loras_data = inputs.get("loras", [])
result_stack = []
# Handle existing stack entries
existing_stack = []
lora_stack_input = inputs.get("lora_stack", [])
if isinstance(lora_stack_input, dict) and "lora_stack" in lora_stack_input:
existing_stack = lora_stack_input["lora_stack"]
elif isinstance(lora_stack_input, list):
if not (len(lora_stack_input) == 2 and isinstance(lora_stack_input[0], (str, int)) and
isinstance(lora_stack_input[1], int)):
existing_stack = lora_stack_input
# Add existing entries
if existing_stack:
result_stack.extend(existing_stack)
# Process new loras
if isinstance(loras_data, dict) and "__value__" in loras_data:
loras_list = loras_data["__value__"]
elif isinstance(loras_data, list):
loras_list = loras_data
else:
loras_list = []
for lora in loras_list:
if isinstance(lora, dict) and lora.get("active", False):
lora_name = lora.get("name", "")
strength = float(lora.get("strength", 1.0))
result_stack.append((lora_name, strength))
return {"lora_stack": result_stack}
def transform_trigger_word_toggle(inputs: Dict) -> str:
"""Transform function for TriggerWordToggle nodes"""
toggle_data = inputs.get("toggle_trigger_words", [])
if isinstance(toggle_data, dict) and "__value__" in toggle_data:
toggle_words = toggle_data["__value__"]
elif isinstance(toggle_data, list):
toggle_words = toggle_data
else:
toggle_words = []
# Filter active trigger words
active_words = []
for item in toggle_words:
if isinstance(item, dict) and item.get("active", False):
word = item.get("text", "")
if word and not word.startswith("__dummy"):
active_words.append(word)
return ", ".join(active_words)
# =============================================================================
# Node Mapper Definitions
# =============================================================================
# Central definition of all supported node types and their configurations
NODE_MAPPERS = {
# LoraManager nodes
"Lora Loader (LoraManager)": {
"inputs_to_track": ["model", "clip", "loras", "lora_stack"],
"transform_func": transform_lora_loader
},
"Lora Stacker (LoraManager)": {
"inputs_to_track": ["loras", "lora_stack"],
"transform_func": transform_lora_stacker
},
"TriggerWord Toggle (LoraManager)": {
"inputs_to_track": ["toggle_trigger_words"],
"transform_func": transform_trigger_word_toggle
}
}
def register_all_mappers() -> None:
"""Register all mappers from the NODE_MAPPERS dictionary"""
for node_type, config in NODE_MAPPERS.items():
mapper = create_mapper(
node_type=node_type,
inputs_to_track=config["inputs_to_track"],
transform_func=config["transform_func"]
)
register_mapper(mapper)
logger.info(f"Registered {len(NODE_MAPPERS)} node mappers")
# =============================================================================
# Extension Loading
# =============================================================================
def load_extensions(ext_dir: str = None) -> None:
"""
Load mapper extensions from the specified directory
Extension files should define a NODE_MAPPERS_EXT dictionary containing mapper configurations.
These will be added to the global NODE_MAPPERS dictionary and registered automatically.
"""
# Use default path if none provided
if ext_dir is None:
# Get the directory of this file
current_dir = os.path.dirname(os.path.abspath(__file__))
ext_dir = os.path.join(current_dir, 'ext')
# Ensure the extension directory exists
if not os.path.exists(ext_dir):
os.makedirs(ext_dir, exist_ok=True)
logger.info(f"Created extension directory: {ext_dir}")
return
# Load each Python file in the extension directory
for filename in os.listdir(ext_dir):
if filename.endswith('.py') and not filename.startswith('_'):
module_path = os.path.join(ext_dir, filename)
module_name = f"workflow.ext.{filename[:-3]}" # Remove .py
try:
# Load the module
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec and spec.loader:
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
# Check if the module defines NODE_MAPPERS_EXT
if hasattr(module, 'NODE_MAPPERS_EXT'):
# Add the extension mappers to the global NODE_MAPPERS dictionary
NODE_MAPPERS.update(module.NODE_MAPPERS_EXT)
logger.info(f"Added {len(module.NODE_MAPPERS_EXT)} mappers from extension: {filename}")
else:
logger.warning(f"Extension {filename} does not define NODE_MAPPERS_EXT dictionary")
except Exception as e:
logger.warning(f"Error loading extension {filename}: {e}")
# Re-register all mappers after loading extensions
register_all_mappers()
# Initialize the registry with default mappers
# register_default_mappers()

181
py/workflow/parser.py Normal file
View File

@@ -0,0 +1,181 @@
"""
Main workflow parser implementation for ComfyUI
"""
import json
import logging
from typing import Dict, List, Any, Optional, Union, Set
from .mappers import get_mapper, get_all_mappers, load_extensions, process_node
from .utils import (
load_workflow, save_output, find_node_by_type,
trace_model_path
)
logger = logging.getLogger(__name__)
class WorkflowParser:
"""Parser for ComfyUI workflows"""
def __init__(self):
"""Initialize the parser with mappers"""
self.processed_nodes: Set[str] = set() # Track processed nodes to avoid cycles
self.node_results_cache: Dict[str, Any] = {} # Cache for processed node results
# Load extensions
load_extensions()
def process_node(self, node_id: str, workflow: Dict) -> Any:
"""Process a single node and extract relevant information"""
# Return cached result if available
if node_id in self.node_results_cache:
return self.node_results_cache[node_id]
# Check if we're in a cycle
if node_id in self.processed_nodes:
return None
# Mark this node as being processed (to detect cycles)
self.processed_nodes.add(node_id)
if node_id not in workflow:
self.processed_nodes.remove(node_id)
return None
node_data = workflow[node_id]
node_type = node_data.get("class_type")
result = None
if get_mapper(node_type):
try:
result = process_node(node_id, node_data, workflow, self)
# Cache the result
self.node_results_cache[node_id] = result
except Exception as e:
logger.error(f"Error processing node {node_id} of type {node_type}: {e}", exc_info=True)
# Return a partial result or None depending on how we want to handle errors
result = {}
# Remove node from processed set to allow it to be processed again in a different context
self.processed_nodes.remove(node_id)
return result
def find_primary_sampler_node(self, workflow: Dict) -> Optional[str]:
"""
Find the primary sampler node in the workflow.
Priority:
1. First try to find a SamplerCustomAdvanced node
2. If not found, look for KSampler nodes with denoise=1.0
3. If still not found, use the first KSampler node
Args:
workflow: The workflow data as a dictionary
Returns:
The node ID of the primary sampler node, or None if not found
"""
# First check for SamplerCustomAdvanced nodes
sampler_advanced_nodes = []
ksampler_nodes = []
# Scan workflow for sampler nodes
for node_id, node_data in workflow.items():
node_type = node_data.get("class_type")
if node_type == "SamplerCustomAdvanced":
sampler_advanced_nodes.append(node_id)
elif node_type == "KSampler":
ksampler_nodes.append(node_id)
# If we found SamplerCustomAdvanced nodes, return the first one
if sampler_advanced_nodes:
logger.debug(f"Found SamplerCustomAdvanced node: {sampler_advanced_nodes[0]}")
return sampler_advanced_nodes[0]
# If we have KSampler nodes, look for one with denoise=1.0
if ksampler_nodes:
for node_id in ksampler_nodes:
node_data = workflow[node_id]
inputs = node_data.get("inputs", {})
denoise = inputs.get("denoise", 0)
# Check if denoise is 1.0 (allowing for small floating point differences)
if abs(float(denoise) - 1.0) < 0.001:
logger.debug(f"Found KSampler node with denoise=1.0: {node_id}")
return node_id
# If no KSampler with denoise=1.0 found, use the first one
logger.debug(f"No KSampler with denoise=1.0 found, using first KSampler: {ksampler_nodes[0]}")
return ksampler_nodes[0]
# No sampler nodes found
logger.warning("No sampler nodes found in workflow")
return None
def parse_workflow(self, workflow_data: Union[str, Dict], output_path: Optional[str] = None) -> Dict:
"""
Parse the workflow and extract generation parameters
Args:
workflow_data: The workflow data as a dictionary or a file path
output_path: Optional path to save the output JSON
Returns:
Dictionary containing extracted parameters
"""
# Load workflow from file if needed
if isinstance(workflow_data, str):
workflow = load_workflow(workflow_data)
else:
workflow = workflow_data
# Reset the processed nodes tracker and cache
self.processed_nodes = set()
self.node_results_cache = {}
# Find the primary sampler node
sampler_node_id = self.find_primary_sampler_node(workflow)
if not sampler_node_id:
logger.warning("No suitable sampler node found in workflow")
return {}
# Process sampler node to extract parameters
sampler_result = self.process_node(sampler_node_id, workflow)
if not sampler_result:
return {}
# Return the sampler result directly - it's already in the format we need
# This simplifies the structure and makes it easier to use in recipe_routes.py
# Handle standard ComfyUI names vs our output format
if "cfg" in sampler_result:
sampler_result["cfg_scale"] = sampler_result.pop("cfg")
# Add clip_skip = 1 to match reference output if not already present
if "clip_skip" not in sampler_result:
sampler_result["clip_skip"] = "1"
# Ensure the prompt is a string and not a nested dictionary
if "prompt" in sampler_result and isinstance(sampler_result["prompt"], dict):
if "prompt" in sampler_result["prompt"]:
sampler_result["prompt"] = sampler_result["prompt"]["prompt"]
# Save the result if requested
if output_path:
save_output(sampler_result, output_path)
return sampler_result
def parse_workflow(workflow_path: str, output_path: Optional[str] = None) -> Dict:
"""
Parse a ComfyUI workflow file and extract generation parameters
Args:
workflow_path: Path to the workflow JSON file
output_path: Optional path to save the output JSON
Returns:
Dictionary containing extracted parameters
"""
parser = WorkflowParser()
return parser.parse_workflow(workflow_path, output_path)

63
py/workflow/test.py Normal file
View File

@@ -0,0 +1,63 @@
"""
Test script for the ComfyUI workflow parser
"""
import os
import json
import logging
from .parser import parse_workflow
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# Configure paths
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..'))
REFS_DIR = os.path.join(ROOT_DIR, 'refs')
OUTPUT_DIR = os.path.join(ROOT_DIR, 'output')
def test_parse_flux_workflow():
"""Test parsing the flux example workflow"""
# Ensure output directory exists
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Define input and output paths
input_path = os.path.join(REFS_DIR, 'flux_prompt.json')
output_path = os.path.join(OUTPUT_DIR, 'parsed_flux_output.json')
# Parse workflow
logger.info(f"Parsing workflow: {input_path}")
result = parse_workflow(input_path, output_path)
# Print result summary
logger.info(f"Output saved to: {output_path}")
logger.info(f"Parsing completed. Result summary:")
logger.info(f" LoRAs: {result.get('loras', '')}")
gen_params = result.get('gen_params', {})
logger.info(f" Prompt: {gen_params.get('prompt', '')[:50]}...")
logger.info(f" Steps: {gen_params.get('steps', '')}")
logger.info(f" Sampler: {gen_params.get('sampler', '')}")
logger.info(f" Size: {gen_params.get('size', '')}")
# Compare with reference output
ref_output_path = os.path.join(REFS_DIR, 'flux_output.json')
try:
with open(ref_output_path, 'r') as f:
ref_output = json.load(f)
# Simple validation
loras_match = result.get('loras', '') == ref_output.get('loras', '')
prompt_match = gen_params.get('prompt', '') == ref_output.get('gen_params', {}).get('prompt', '')
logger.info(f"Validation against reference:")
logger.info(f" LoRAs match: {loras_match}")
logger.info(f" Prompt match: {prompt_match}")
except Exception as e:
logger.warning(f"Failed to compare with reference output: {e}")
if __name__ == "__main__":
test_parse_flux_workflow()

120
py/workflow/utils.py Normal file
View File

@@ -0,0 +1,120 @@
"""
Utility functions for ComfyUI workflow parsing
"""
import json
import os
import logging
from typing import Dict, List, Any, Optional, Union, Set, Tuple
logger = logging.getLogger(__name__)
def load_workflow(workflow_path: str) -> Dict:
"""Load a workflow from a JSON file"""
try:
with open(workflow_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading workflow from {workflow_path}: {e}")
raise
def save_output(output: Dict, output_path: str) -> None:
"""Save the parsed output to a JSON file"""
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
try:
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(output, f, indent=4)
except Exception as e:
logger.error(f"Error saving output to {output_path}: {e}")
raise
def find_node_by_type(workflow: Dict, node_type: str) -> Optional[str]:
"""Find a node of the specified type in the workflow"""
for node_id, node_data in workflow.items():
if node_data.get("class_type") == node_type:
return node_id
return None
def find_nodes_by_type(workflow: Dict, node_type: str) -> List[str]:
"""Find all nodes of the specified type in the workflow"""
return [node_id for node_id, node_data in workflow.items()
if node_data.get("class_type") == node_type]
def get_input_node_ids(workflow: Dict, node_id: str) -> Dict[str, Tuple[str, int]]:
"""
Get the node IDs for all inputs of the given node
Returns a dictionary mapping input names to (node_id, output_slot) tuples
"""
result = {}
if node_id not in workflow:
return result
node_data = workflow[node_id]
for input_name, input_value in node_data.get("inputs", {}).items():
# Check if this input is connected to another node
if isinstance(input_value, list) and len(input_value) == 2:
# Input is connected to another node's output
# Format: [node_id, output_slot]
ref_node_id, output_slot = input_value
result[input_name] = (str(ref_node_id), output_slot)
return result
def trace_model_path(workflow: Dict, start_node_id: str) -> List[str]:
"""
Trace the model path backward from KSampler to find all LoRA nodes
Args:
workflow: The workflow data
start_node_id: The starting node ID (usually KSampler)
Returns:
List of node IDs in the model path
"""
model_path_nodes = []
# Get the model input from the start node
if start_node_id not in workflow:
return model_path_nodes
# Track visited nodes to avoid cycles
visited = set()
# Stack for depth-first search
stack = []
# Get model input reference if available
start_node = workflow[start_node_id]
if "inputs" in start_node and "model" in start_node["inputs"] and isinstance(start_node["inputs"]["model"], list):
model_ref = start_node["inputs"]["model"]
stack.append(str(model_ref[0]))
# Perform depth-first search
while stack:
node_id = stack.pop()
# Skip if already visited
if node_id in visited:
continue
# Mark as visited
visited.add(node_id)
# Skip if node doesn't exist
if node_id not in workflow:
continue
node = workflow[node_id]
node_type = node.get("class_type", "")
# Add current node to result list if it's a LoRA node
if "Lora" in node_type:
model_path_nodes.append(node_id)
# Add all input nodes that have a "model" or "lora_stack" output to the stack
if "inputs" in node:
for input_name, input_value in node["inputs"].items():
if input_name in ["model", "lora_stack"] and isinstance(input_value, list) and len(input_value) == 2:
stack.append(str(input_value[0]))
return model_path_nodes

View File

@@ -1,13 +1,17 @@
[project]
name = "comfyui-lora-manager"
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.7.37"
version = "0.8.4"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
"jinja2",
"safetensors",
"watchdog"
"watchdog",
"beautifulsoup4",
"piexif",
"Pillow",
"requests"
]
[project.urls]

View File

@@ -0,0 +1,100 @@
{
"id": 1387174,
"modelId": 1231067,
"name": "v1.0",
"createdAt": "2025-02-08T11:15:47.197Z",
"updatedAt": "2025-02-08T11:29:04.526Z",
"status": "Published",
"publishedAt": "2025-02-08T11:29:04.487Z",
"trainedWords": [
"ppstorybook"
],
"trainingStatus": null,
"trainingDetails": null,
"baseModel": "Flux.1 D",
"baseModelType": null,
"earlyAccessEndsAt": null,
"earlyAccessConfig": null,
"description": null,
"uploadType": "Created",
"usageControl": "Download",
"air": "urn:air:flux1:lora:civitai:1231067@1387174",
"stats": {
"downloadCount": 1436,
"ratingCount": 0,
"rating": 0,
"thumbsUpCount": 316
},
"model": {
"name": "Vivid Impressions Storybook Style",
"type": "LORA",
"nsfw": false,
"poi": false
},
"files": [
{
"id": 1289799,
"sizeKB": 18829.1484375,
"name": "pp-storybook_rank2_bf16.safetensors",
"type": "Model",
"pickleScanResult": "Success",
"pickleScanMessage": "No Pickle imports",
"virusScanResult": "Success",
"virusScanMessage": null,
"scannedAt": "2025-02-08T11:21:04.247Z",
"metadata": {
"format": "SafeTensor",
"size": null,
"fp": null
},
"hashes": {
"AutoV1": "F414C813",
"AutoV2": "9753338AB6",
"SHA256": "9753338AB693CA82BF89ED77A5D1912879E40051463EC6E330FB9866CE798668",
"CRC32": "A65AE7B3",
"BLAKE3": "A5F8AB95AC2486345E4ACCAE541FF19D97ED53EFB0A7CC9226636975A0437591",
"AutoV3": "34A22376739D"
},
"primary": true,
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}
],
"images": [
{
"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/42b875cf-c62b-41fa-a349-383b7f074351/width=832/56547310.jpeg",
"nsfwLevel": 1,
"width": 832,
"height": 1216,
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"type": "image",
"metadata": {
"hash": "U5IiO6s-4Vn+0~EO^5xa00VsL#IU_O?E7yWC",
"size": 1361590,
"width": 832,
"height": 1216
},
"meta": {
"Size": "832x1216",
"seed": 1116375220995209,
"Model": "flux_dev_fp8",
"steps": 23,
"hashes": {
"model": ""
},
"prompt": "ppstorybook,A dreamy bunny hopping across a rainbow bridge, with fluffy clouds surrounding it and tiny birds flying alongside, rendered in a magical, soft-focus style with pastel hues and glowing accents.",
"Version": "ComfyUI",
"sampler": "DPM++ 2M",
"cfgScale": 3.5,
"clipSkip": 1,
"resources": [],
"Model hash": ""
},
"availability": "Public",
"hasMeta": true,
"hasPositivePrompt": true,
"onSite": false,
"remixOfId": null
}
],
"downloadUrl": "https://civitai.com/api/download/models/1387174"
}

View File

@@ -0,0 +1,153 @@
{
"resource-stack": {
"class_type": "CheckpointLoaderSimple",
"inputs": { "ckpt_name": "urn:air:sdxl:checkpoint:civitai:827184@1410435" }
},
"resource-stack-1": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1107767@1253442",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack", 0],
"clip": ["resource-stack", 1]
}
},
"resource-stack-2": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:1342708@1516344",
"strength_model": 1,
"strength_clip": 1,
"model": ["resource-stack-1", 0],
"clip": ["resource-stack-1", 1]
}
},
"resource-stack-3": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:122359@135867",
"strength_model": 1.55,
"strength_clip": 1,
"model": ["resource-stack-2", 0],
"clip": ["resource-stack-2", 1]
}
},
"6": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Positive" }
},
"7": {
"class_type": "smZ CLIPTextEncode",
"inputs": {
"text": "bad quality,worst quality,worst detail,sketch,censor",
"parser": "comfy",
"text_g": "",
"text_l": "",
"ascore": 2.5,
"width": 0,
"height": 0,
"crop_w": 0,
"crop_h": 0,
"target_width": 0,
"target_height": 0,
"smZ_steps": 1,
"mean_normalization": true,
"multi_conditioning": true,
"use_old_emphasis_implementation": false,
"with_SDXL": false,
"clip": ["resource-stack-3", 1]
},
"_meta": { "title": "Negative" }
},
"20": {
"class_type": "UpscaleModelLoader",
"inputs": { "model_name": "urn:air:other:upscaler:civitai:147759@164821" },
"_meta": { "title": "Load Upscale Model" }
},
"17": {
"class_type": "LoadImage",
"inputs": {
"image": "https://orchestration.civitai.com/v2/consumer/blobs/5KZ6358TW8CNEGPZKD08NVDB30",
"upload": "image"
},
"_meta": { "title": "Image Load" }
},
"19": {
"class_type": "ImageUpscaleWithModel",
"inputs": { "upscale_model": ["20", 0], "image": ["17", 0] },
"_meta": { "title": "Upscale Image (using Model)" }
},
"23": {
"class_type": "ImageScale",
"inputs": {
"upscale_method": "nearest-exact",
"crop": "disabled",
"width": 1280,
"height": 1856,
"image": ["19", 0]
},
"_meta": { "title": "Upscale Image" }
},
"21": {
"class_type": "VAEEncode",
"inputs": { "pixels": ["23", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Encode" }
},
"11": {
"class_type": "KSampler",
"inputs": {
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"seed": 2088370631,
"steps": 47,
"cfg": 6.5,
"denoise": 0.3,
"model": ["resource-stack-3", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["21", 0]
},
"_meta": { "title": "KSampler" }
},
"13": {
"class_type": "VAEDecode",
"inputs": { "samples": ["11", 0], "vae": ["resource-stack", 2] },
"_meta": { "title": "VAE Decode" }
},
"12": {
"class_type": "SaveImage",
"inputs": { "filename_prefix": "ComfyUI", "images": ["13", 0] },
"_meta": { "title": "Save Image" }
},
"extra": {
"airs": [
"urn:air:other:upscaler:civitai:147759@164821",
"urn:air:sdxl:checkpoint:civitai:827184@1410435",
"urn:air:sdxl:lora:civitai:1107767@1253442",
"urn:air:sdxl:lora:civitai:1342708@1516344",
"urn:air:sdxl:lora:civitai:122359@135867"
]
},
"extraMetadata": "{\u0022prompt\u0022:\u0022masterpiece, best quality, amazing quality, detailed setting, detailed background, 1girl, yunyun (konosuba), nude, red eyes, hair ornament, braid, hair between eyes,low twintails, pink ribbon, bow, hair bow, pussy, frilled skirt, layered skirt, belt, pink thighhighs, (pussy juice), large insertion, vaginal tugging, pussy grip, detailed skin, detailed soles, stretched pussy, feet in stockings, ass, nipples, medium breasts, french kiss, anus, shocked, nervous, penis awe, BREAK Professor\u0027s office, college student, pornographic, 1boy, close eyes, (musscular male, detailed large cock), vaginal sex, college office setting, ass grab, fucking, riding, cowgirl, erotic, side view, deep fucking\u0022,\u0022negativePrompt\u0022:\u0022bad quality,worst quality,worst detail,sketch,censor\u0022,\u0022steps\u0022:47,\u0022cfgScale\u0022:6.5,\u0022sampler\u0022:\u0022euler_ancestral\u0022,\u0022workflowId\u0022:\u0022img2img-hires\u0022,\u0022resources\u0022:[{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1410435,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1253442,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:1516344,\u0022strength\u0022:1},{\u0022modelVersionId\u0022:135867,\u0022strength\u0022:1.55}],\u0022remixOfId\u0022:32140259}"
}

15
refs/flux_output.json Normal file
View File

@@ -0,0 +1,15 @@
{
"loras": "<lora:pp-enchanted-whimsy:0.9> <lora:ral-frctlgmtry_flux:1> <lora:pp-storybook_rank2_bf16:0.8>",
"gen_params": {
"prompt": "in the style of ppWhimsy, ral-frctlgmtry, ppstorybook,Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
"negative_prompt": "",
"steps": "25",
"sampler": "dpmpp_2m",
"scheduler": "beta",
"cfg": "1",
"seed": "48",
"guidance": 3.5,
"size": "896x1152",
"clip_skip": "2"
}
}

314
refs/flux_prompt.json Normal file
View File

@@ -0,0 +1,314 @@
{
"6": {
"inputs": {
"text": [
"46",
0
],
"clip": [
"58",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"8": {
"inputs": {
"samples": [
"31",
0
],
"vae": [
"39",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"27": {
"inputs": {
"width": 896,
"height": 1152,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "EmptySD3LatentImage"
}
},
"31": {
"inputs": {
"seed": 44,
"steps": 25,
"cfg": 1,
"sampler_name": "dpmpp_2m",
"scheduler": "beta",
"denoise": 1,
"model": [
"58",
0
],
"positive": [
"35",
0
],
"negative": [
"33",
0
],
"latent_image": [
"27",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"33": {
"inputs": {
"text": "",
"clip": [
"58",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"35": {
"inputs": {
"guidance": 3.5,
"conditioning": [
"6",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "FluxGuidance"
}
},
"37": {
"inputs": {
"unet_name": "flux\\flux1-dev-fp8-e4m3fn.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"38": {
"inputs": {
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
"clip_name2": "clip_l.safetensors",
"type": "flux",
"device": "default"
},
"class_type": "DualCLIPLoader",
"_meta": {
"title": "DualCLIPLoader"
}
},
"39": {
"inputs": {
"vae_name": "flux1\\ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"46": {
"inputs": {
"string1": [
"59",
0
],
"string2": [
"51",
0
],
"delimiter": ","
},
"class_type": "JoinStrings",
"_meta": {
"title": "Join Strings"
}
},
"50": {
"inputs": {
"images": [
"8",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "Preview Image"
}
},
"51": {
"inputs": {
"string": "Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
"strip_newlines": true
},
"class_type": "StringConstantMultiline",
"_meta": {
"title": "positive"
}
},
"58": {
"inputs": {
"text": "<lora:pp-enchanted-whimsy:0.9><lora:ral-frctlgmtry_flux:1><lora:pp-storybook_rank2_bf16:0.8>",
"loras": [
{
"name": "pp-enchanted-whimsy",
"strength": "0.90",
"active": false
},
{
"name": "ral-frctlgmtry_flux",
"strength": "0.85",
"active": false
},
{
"name": "pp-storybook_rank2_bf16",
"strength": 0.8,
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
],
"model": [
"37",
0
],
"clip": [
"38",
0
]
},
"class_type": "Lora Loader (LoraManager)",
"_meta": {
"title": "Lora Loader (LoraManager)"
}
},
"59": {
"inputs": {
"group_mode": "",
"toggle_trigger_words": [
{
"text": "ppstorybook",
"active": false
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
}
],
"orinalMessage": "ppstorybook",
"trigger_words": [
"58",
2
]
},
"class_type": "TriggerWord Toggle (LoraManager)",
"_meta": {
"title": "TriggerWord Toggle (LoraManager)"
}
},
"61": {
"inputs": {
"add_noise": "enable",
"noise_seed": 1111423448930884,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
"scheduler": "normal",
"start_at_step": 0,
"end_at_step": 10000,
"return_with_leftover_noise": "disable"
},
"class_type": "KSamplerAdvanced",
"_meta": {
"title": "KSampler (Advanced)"
}
},
"62": {
"inputs": {
"sigmas": [
"63",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"63": {
"inputs": {
"scheduler": "normal",
"steps": 20,
"denoise": 1
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"64": {
"inputs": {
"seed": 1089899258710474,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"65": {
"inputs": {
"text": ",Stylized geek cat artist with glasses and a paintbrush, smiling at the viewer while holding a sign that reads 'Stay tuned!', solid white background",
"anything": [
"46",
0
]
},
"class_type": "easy showAnything",
"_meta": {
"title": "Show Any"
}
}
}

View File

@@ -0,0 +1,18 @@
a dynamic and dramatic digital artwork featuring a stylized anthropomorphic white tiger with striking yellow eyes. The tiger is depicted in a powerful stance, wielding a katana with one hand raised above its head. Its fur is detailed with black stripes, and its mane flows wildly, blending with the stormy background. The scene is set amidst swirling dark clouds and flashes of lightning, enhancing the sense of movement and energy. The composition is vertical, with the tiger positioned centrally, creating a sense of depth and intensity. The color palette is dominated by shades of blue, gray, and white, with bright highlights from the lightning. The overall style is reminiscent of fantasy or manga art, with a focus on dynamic action and dramatic lighting.
Negative prompt:
Steps: 30, Sampler: Undefined, CFG scale: 3.5, Seed: 90300501, Size: 832x1216, Clip skip: 2, Created Date: 2025-03-05T13:51:18.1770234Z, Civitai resources: [{"type":"checkpoint","modelVersionId":691639,"modelName":"FLUX","modelVersionName":"Dev"},{"type":"lora","weight":0.4,"modelVersionId":1202162,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Gothic Lines"},{"type":"lora","weight":0.8,"modelVersionId":1470588,"modelName":"Velvet\u0027s Mythic Fantasy Styles | Flux \u002B Pony \u002B illustrious","modelVersionName":"Flux Retro"},{"type":"lora","weight":0.75,"modelVersionId":746484,"modelName":"Elden Ring - Yoshitaka Amano","modelVersionName":"V1"},{"type":"lora","weight":0.2,"modelVersionId":914935,"modelName":"Ink-style","modelVersionName":"ink-dynamic"},{"type":"lora","weight":0.2,"modelVersionId":1189379,"modelName":"Painterly Fantasy by ChronoKnight - [FLUX \u0026 IL]","modelVersionName":"FLUX"},{"type":"lora","weight":0.2,"modelVersionId":757030,"modelName":"Mezzotint Artstyle for Flux - by Ethanar","modelVersionName":"V1"}], Civitai metadata: {}
masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject,
dynamic angle, dutch angle, from below, epic half body portrait, gritty, wabi sabi, looking at viewer, woman is a geisha, parted lips,
holographic skin, holofoil glitter, faint, glowing, ethereal, neon hair, glowing hair, otherworldly glow, she is dangerous
<lora:ck-shadow-circuit-IL:0.78>, <lora:ck-nc-cyberpunk-IL-000011:0.4>, <lora:ck-neon-retrowave-IL:0.2>, <lora:ck-yoneyama-mai-IL-000014:0.4>
Negative prompt: score_6, score_5, score_4, bad quality, worst quality, worst detail, sketch, censorship, furry, window, headphones,
Steps: 30, Sampler: Euler a, Schedule type: Simple, CFG scale: 7, Seed: 1405717592, Size: 832x1216, Model hash: 1ad6ca7f70, Model: waiNSFWIllustrious_v100, Denoising strength: 0.35, Hires CFG Scale: 5, Hires upscale: 1.3, Hires steps: 20, Hires upscaler: 4x-AnimeSharp, Lora hashes: "ck-shadow-circuit-IL: 88e247aa8c3d, ck-nc-cyberpunk-IL-000011: 935e6755554c, ck-neon-retrowave-IL: edafb9df7da1, ck-yoneyama-mai-IL-000014: 1b9305692a2e", Version: f2.0.1v1.10.1-1.10.1, Diffusion in Low Bits: Automatic (fp16 LoRA)
Masterpiece, best quality, high quality, newest, highres, 8K, HDR, absurdres, 1girl, solo, futuristic warrior, sleek exosuit with glowing energy cores, long braided hair flowing behind, gripping a high-tech bow with an energy arrow drawn, standing on a floating platform overlooking a massive space station, planets and nebulae in the distance, soft glow from distant stars, cinematic depth, foreshortening, dynamic pose, dramatic sci-fi lighting.
Negative prompt: worst quality, normal quality, anatomical nonsense, bad anatomy,interlocked fingers, extra fingers,watermark,simple background, loli,
Steps: 20, Sampler: euler_ancestral_karras, CFG scale: 8.0, Seed: 691121152183439, Model: il\waiNSFWIllustrious_v110.safetensors, Model hash: c3688ee04c, Lora_0 Model name: iLLMythAn1m3Style.safetensors, Lora_0 Model hash: ba7a040786, Lora_0 Strength model: 1.0, Lora_0 Strength clip: 1.0, Hashes: {"model": "c3688ee04c", "lora:iLLMythAn1m3Style": "ba7a040786"}
Immerse yourself in the enchanting journey, where harmonious transmutation of Bauhaus art unites photographic precision and contemporary illustration, capturing an enthralling blend between vivid abstract nature and urban landscapes. Let your eyes be captivated by a kaleidoscope of rich, deep reds and yellows, entwined with intriguing shades that beckon a somber atmosphere. As your spirit ventures along this haunting path, witness the mysterious, high-angle perspective dominated by scattered clouds granting you a mesmerizing glimpse into the ever-transforming realm of metamorphosing environments. ,<lora:flux/fav/ck-charcoal-drawing-000014.safetensors:1.0:1.0>
Negative prompt:
Steps: 20, Sampler: Euler, CFG scale: 3.5, Seed: 885491426361006, Size: 832x1216, Model hash: 4610115bb0, Model: flux_dev, Hashes: {"LORA:flux/fav/ck-charcoal-drawing-000014.safetensors": "34d36c17c1", "model": "4610115bb0"}, Version: ComfyUI

3
refs/meta_format.txt Normal file
View File

@@ -0,0 +1,3 @@
In this ethereal masterpiece, metallic sculptures juxtapose effortlessly against a subtle backdrop of misty neutral hues. Exquisite curvatures and geometric shapes converge harmoniously, creating an illuminating realm of polished metallic surfaces. Shimmering copper, gleaming silver, and lustrous gold hues dance in perfect balance, highlighting the intricate play of light and shadow cast upon these celestial forms. A halo of diffused radiance envelops each piece, enhancing their textured depths and metallic brilliance while allowing delicate details to emerge from obscurity. The composition conveys a serene yet mesmerizing atmosphere, as if suspended in a dreamlike limbo between reality and fantasy. The tantalizing interplay of colors within this transcendent realm creates a profound sense of depth and grandeur that invites the viewer into an enchanting voyage through abstract metallic beauty. This captivating artwork evokes emotions of boundless curiosity and reverence reminiscent of the timeless works by artists such as Giorgio de Chirico or Paul Klee, while asserting a unique, modern artistic sensibility. With every observation, a new nuance unfolds, as if a never-ending story waiting to be discovered through the lens of metallic artistry.
Negative prompt:
Steps: 25, Sampler: dpmpp_2m_sgm_uniform, Seed: 471889513588087, Model: Fluxmania V5P.safetensors, Model hash: 8ae0583b06, VAE: ae.sft, VAE hash: afc8e28272, Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, Lora_0 Strength model: 0.65, Lora_0 Strength clip: 0.65, Lora_1 Model name: Kaoru Yamada.safetensors, Lora_1 Model hash: d4893f7202, Lora_1 Strength model: 0.75, Lora_1 Strength clip: 0.75, Hashes: {"model": "8ae0583b06", "vae": "afc8e28272", "lora:ArtVador I": "08f7133a58", "lora:Kaoru Yamada": "d4893f7202"}

11
refs/output.json Normal file
View File

@@ -0,0 +1,11 @@
{
"loras": "<lora:ck-neon-retrowave-IL-000012:0.8> <lora:aorunIllstrious:1> <lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
"prompt": "in the style of ck-rw, aorun, scales, makeup, bare shoulders, pointy ears, dress, claws, in the style of cksc, artist:moriimee, in the style of cknc, masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
"negative_prompt": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
"steps": "20",
"sampler": "euler_ancestral",
"cfg_scale": "8",
"seed": "241",
"size": "832x1216",
"clip_skip": "2"
}

401
refs/prompt.json Normal file
View File

@@ -0,0 +1,401 @@
{
"6": {
"inputs": {
"text": [
"301",
0
],
"clip": [
"299",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
},
"8": {
"inputs": {
"samples": [
"13",
1
],
"vae": [
"10",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"10": {
"inputs": {
"vae_name": "flux1\\ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"11": {
"inputs": {
"clip_name1": "t5xxl_fp8_e4m3fn.safetensors",
"clip_name2": "ViT-L-14-TEXT-detail-improved-hiT-GmP-TE-only-HF.safetensors",
"type": "flux",
"device": "default"
},
"class_type": "DualCLIPLoader",
"_meta": {
"title": "DualCLIPLoader"
}
},
"13": {
"inputs": {
"noise": [
"147",
0
],
"guider": [
"22",
0
],
"sampler": [
"16",
0
],
"sigmas": [
"17",
0
],
"latent_image": [
"48",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"16": {
"inputs": {
"sampler_name": "dpmpp_2m"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"17": {
"inputs": {
"scheduler": "beta",
"steps": [
"246",
0
],
"denoise": 1,
"model": [
"28",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"22": {
"inputs": {
"model": [
"28",
0
],
"conditioning": [
"29",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"28": {
"inputs": {
"max_shift": 1.1500000000000001,
"base_shift": 0.5,
"width": [
"48",
1
],
"height": [
"48",
2
],
"model": [
"299",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "ModelSamplingFlux"
}
},
"29": {
"inputs": {
"guidance": 3.5,
"conditioning": [
"6",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "FluxGuidance"
}
},
"48": {
"inputs": {
"resolution": "832x1216 (0.68)",
"batch_size": 1,
"width_override": 0,
"height_override": 0
},
"class_type": "SDXLEmptyLatentSizePicker+",
"_meta": {
"title": "🔧 SDXL Empty Latent Size Picker"
}
},
"65": {
"inputs": {
"unet_name": "flux\\flux1-dev-fp8-e4m3fn.safetensors",
"weight_dtype": "fp8_e4m3fn_fast"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"147": {
"inputs": {
"noise_seed": 651532572596956
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"148": {
"inputs": {
"wildcard_text": "__some-prompts__",
"populated_text": "A surreal digital artwork showcases a forward-thinking inventor captivated by his intricate mechanical creation through a large magnifying glass. Viewed from an unconventional perspective, the scene reveals an eccentric assembly of gears, springs, and brass instruments within his workshop. Soft, ethereal light radiates from the invention, casting enigmatic shadows on the walls as time appears to bend around its metallic form, invoking a sense of curiosity, wonder, and exhilaration in discovery.",
"mode": "fixed",
"seed": 553084268162351,
"Select to add Wildcard": "Select the Wildcard to add to the text"
},
"class_type": "ImpactWildcardProcessor",
"_meta": {
"title": "ImpactWildcardProcessor"
}
},
"151": {
"inputs": {
"text": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura."
},
"class_type": "Text Multiline",
"_meta": {
"title": "Text Multiline"
}
},
"191": {
"inputs": {
"text": "A cinematic, oil painting masterpiece captures the essence of impressionistic surrealism, inspired by Claude Monet. A mysterious woman in a flowing crimson dress stands at the edge of a tranquil lake, where lily pads shimmer under an ethereal, golden twilight. The waters surface reflects a dreamlike sky, its swirling hues of violet and sapphire melting together like liquid light. The thick, expressive brushstrokes lend depth to the scene, evoking a sense of nostalgia and quiet longing, as if the world itself is caught between reality and a fleeting dream. \nA mesmerizing oil painting masterpiece inspired by Salvador Dalí, blending surrealism with post-impressionist texture. A lone violinist plays atop a melting clock tower, his form distorted by the passage of time. The sky is a cascade of swirling, liquid oranges and deep blues, where floating staircases spiral endlessly into the horizon. The impasto technique gives depth and movement to the surreal elements, making time itself feel fluid, as if the world is dissolving into a dream. \nA stunning impressionistic oil painting evokes the spirit of Edvard Munch, capturing a solitary figure standing on a rain-soaked street, illuminated by the glow of flickering gas lamps. The swirling, chaotic strokes of deep blues and fiery reds reflect the turbulence of emotion, while the blurred reflections in the wet cobblestone suggest a merging of past and present. The faceless figure, draped in a dark overcoat, seems lost in thought, embodying the ephemeral nature of memory and time. \nA breathtaking oil painting masterpiece, inspired by Gustav Klimt, presents a celestial ballroom where faceless dancers swirl in an eternal waltz beneath a gilded, star-speckled sky. Their golden garments shimmer with intricate patterns, blending into the opulent mosaic floor that seems to stretch into infinity. The dreamlike composition, rich in warm amber and deep sapphire hues, captures an otherworldly elegance, as if the dancers are suspended in a moment that transcends time. \nA visionary oil painting inspired by Marc Chagall depicts a dreamlike cityscape where gravity ceases to exist. A couple floats above a crimson-tinted town, their forms dissolving into the swirling strokes of a vast, cerulean sky. The buildings below twist and bend in rhythmic motion, their windows glowing like tiny stars. The thick, textured brushwork conveys a sense of weightlessness and wonder, as if love itself has defied the laws of the universe. \nAn impressionistic oil painting in the style of J.M.W. Turner, depicting a ghostly ship sailing through a sea of swirling golden mist. The waves crash and dissolve into abstract, fiery strokes of orange and deep indigo, blurring the line between ocean and sky. The ship appears almost ethereal, as if drifting between worlds, lost in the ever-changing tides of memory and myth. The dynamic brushstrokes capture the relentless power of nature and the fleeting essence of time. \nA captivating oil painting masterpiece, infused with surrealist impressionism, portrays a grand library where books float midair, their pages unraveling into ribbons of light. The towering shelves twist into the heavens, vanishing into an infinite, starry void. A lone scholar, illuminated by the glow of a suspended lantern, reaches for a book that seems to pulse with life. The scene pulses with mystery, where the impasto textures bring depth to the interplay between knowledge and dreams. \nA luminous impressionistic oil painting captures the melancholic beauty of an abandoned carnival, its faded carousel horses frozen mid-gallop beneath a sky of swirling lavender and gold. The wind carries fragments of forgotten laughter through the empty fairground, where scattered ticket stubs and crumbling banners whisper tales of joy long past. The thick, textured brushstrokes blend nostalgia with an eerie dreamlike quality, as if the carnival exists only in the echoes of memory. \nA surreal oil painting in the spirit of René Magritte, featuring a towering lighthouse that emits not light, but cascading waterfalls from its peak. The swirling sky, painted in deep midnight blues, is punctuated by glowing, crescent moons that defy gravity. A lone figure stands at the waters edge, gazing up in quiet contemplation, as if caught between wonder and the unknown. The paintings rich textures and luminous colors create an enigmatic, dreamlike landscape. \nA striking impressionistic oil painting, reminiscent of Van Gogh, portrays a lone traveler on a winding cobblestone path, their silhouette bathed in the golden glow of lantern-lit cherry blossoms. The petals swirl through the night air like glowing embers, blending with the deep, rhythmic strokes of a star-filled indigo sky. The scene captures a feeling of wistful solitude, as if the traveler is walking not only through the city, but through the fleeting nature of time itself."
},
"class_type": "Text Multiline",
"_meta": {
"title": "Text Multiline"
}
},
"203": {
"inputs": {
"string1": [
"289",
0
],
"string2": [
"293",
0
],
"delimiter": ", "
},
"class_type": "JoinStrings",
"_meta": {
"title": "Join Strings"
}
},
"208": {
"inputs": {
"file_path": "",
"dictionary_name": "[filename]",
"label": "TextBatch",
"mode": "automatic",
"index": 0,
"multiline_text": [
"191",
0
]
},
"class_type": "Text Load Line From File",
"_meta": {
"title": "Text Load Line From File"
}
},
"226": {
"inputs": {
"images": [
"8",
0
]
},
"class_type": "PreviewImage",
"_meta": {
"title": "Preview Image"
}
},
"246": {
"inputs": {
"value": 25
},
"class_type": "INTConstant",
"_meta": {
"title": "Steps"
}
},
"289": {
"inputs": {
"group_mode": true,
"toggle_trigger_words": [
{
"text": "bo-exposure",
"active": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
}
],
"orinalMessage": "bo-exposure",
"trigger_words": [
"299",
2
]
},
"class_type": "TriggerWord Toggle (LoraManager)",
"_meta": {
"title": "TriggerWord Toggle (LoraManager)"
}
},
"293": {
"inputs": {
"input": 1,
"text1": [
"208",
0
],
"text2": [
"151",
0
]
},
"class_type": "easy textSwitch",
"_meta": {
"title": "Text Switch"
}
},
"297": {
"inputs": {
"text": ""
},
"class_type": "Lora Stacker (LoraManager)",
"_meta": {
"title": "Lora Stacker (LoraManager)"
}
},
"298": {
"inputs": {
"anything": [
"297",
0
]
},
"class_type": "easy showAnything",
"_meta": {
"title": "Show Any"
}
},
"299": {
"inputs": {
"text": "<lora:boFLUX Double Exposure Magic v2:0.8> <lora:FluxDFaeTasticDetails:0.65>",
"loras": [
{
"name": "boFLUX Double Exposure Magic v2",
"strength": 0.8,
"active": true
},
{
"name": "FluxDFaeTasticDetails",
"strength": 0.65,
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
],
"model": [
"65",
0
],
"clip": [
"11",
0
],
"lora_stack": [
"297",
0
]
},
"class_type": "Lora Loader (LoraManager)",
"_meta": {
"title": "Lora Loader (LoraManager)"
}
},
"301": {
"inputs": {
"string": "A hyper-realistic close-up portrait of a young woman with shoulder-length black hair styled in edgy, futuristic layers, adorned with glowing tips. She wears mecha eyewear with a neon green visor that transitions into iridescent shades of teal and gold. The frame is sleek, with angular edges and fine mechanical detailing. Her expression is fierce and confident, with flawless skin highlighted by the neon reflections. She wears a high-tech bodysuit with integrated LED lines and metallic panels. The background depicts a hazy rendition of The Great Wave off Kanagawa by Hokusai, its powerful waves blending seamlessly with the neon tones, amplifying her intense, defiant aura.",
"strip_newlines": true
},
"class_type": "StringConstantMultiline",
"_meta": {
"title": "String Constant Multiline"
}
}
}

82
refs/recipe.json Normal file
View File

@@ -0,0 +1,82 @@
{
"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",
"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"
}
],
"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",
"size": "832x1216",
"clip_skip": "2"
}
}

294
refs/test_output.txt Normal file
View File

@@ -0,0 +1,294 @@
Loading workflow from D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\refs\prompt.json
Expected output from D:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\refs\output.json
Expected output:
{
"loras": "<lora:ck-neon-retrowave-IL-000012:0.8> <lora:aorunIllstrious:1> <lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
"gen_params": {
"prompt": "in the style of ck-rw, aorun, scales, makeup, bare shoulders, pointy ears, dress, claws, in the style of cksc, artist:moriimee, in the style of cknc, masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
"negative_prompt": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
"steps": "20",
"sampler": "euler_ancestral",
"cfg_scale": "8",
"seed": "241",
"size": "832x1216",
"clip_skip": "2"
}
}
Sampler node:
{
"inputs": {
"seed": 241,
"steps": 20,
"cfg": 8,
"sampler_name": "euler_ancestral",
"scheduler": "karras",
"denoise": 1,
"model": [
"56",
0
],
"positive": [
"6",
0
],
"negative": [
"7",
0
],
"latent_image": [
"5",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
}
Extracted parameters:
seed: 241
steps: 20
cfg_scale: 8
Positive node (6):
{
"inputs": {
"text": [
"22",
0
],
"clip": [
"56",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
}
Text node (22):
{
"inputs": {
"string1": [
"55",
0
],
"string2": [
"21",
0
],
"delimiter": ", "
},
"class_type": "JoinStrings",
"_meta": {
"title": "Join Strings"
}
}
String1 node (55):
{
"inputs": {
"group_mode": true,
"toggle_trigger_words": [
{
"text": "in the style of ck-rw",
"active": true
},
{
"text": "aorun, scales, makeup, bare shoulders, pointy ears",
"active": true
},
{
"text": "dress",
"active": true
},
{
"text": "claws",
"active": true
},
{
"text": "in the style of cksc",
"active": true
},
{
"text": "artist:moriimee",
"active": true
},
{
"text": "in the style of cknc",
"active": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
},
{
"text": "__dummy_item__",
"active": false,
"_isDummy": true
}
],
"orinalMessage": "in the style of ck-rw,, aorun, scales, makeup, bare shoulders, pointy ears,, dress,, claws,, in the style of cksc,, artist:moriimee,, in the style of cknc",
"trigger_words": [
"56",
2
]
},
"class_type": "TriggerWord Toggle (LoraManager)",
"_meta": {
"title": "TriggerWord Toggle (LoraManager)"
}
}
String2 node (21):
{
"inputs": {
"string": "masterpiece, best quality, good quality, very aesthetic, absurdres, newest, 8K, depth of field, focused subject, close up, stylized, in gold and neon shades, wabi sabi, 1girl, rainbow angel wings, looking at viewer, dynamic angle, from below, from side, relaxing",
"strip_newlines": false
},
"class_type": "StringConstantMultiline",
"_meta": {
"title": "positive"
}
}
Negative node (7):
{
"inputs": {
"text": "bad quality, worst quality, worst detail, sketch ,signature, watermark, patreon logo, nsfw",
"clip": [
"56",
1
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
}
LoRA nodes (3):
LoRA node 56:
{
"inputs": {
"text": "<lora:ck-shadow-circuit-IL-000012:0.78> <lora:MoriiMee_Gothic_Niji_Style_Illustrious_r1:0.45> <lora:ck-nc-cyberpunk-IL-000011:0.4>",
"loras": [
{
"name": "ck-shadow-circuit-IL-000012",
"strength": 0.78,
"active": true
},
{
"name": "MoriiMee_Gothic_Niji_Style_Illustrious_r1",
"strength": 0.45,
"active": true
},
{
"name": "ck-nc-cyberpunk-IL-000011",
"strength": 0.4,
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
],
"model": [
"4",
0
],
"clip": [
"4",
1
],
"lora_stack": [
"57",
0
]
},
"class_type": "Lora Loader (LoraManager)",
"_meta": {
"title": "Lora Loader (LoraManager)"
}
}
LoRA node 57:
{
"inputs": {
"text": "<lora:aorunIllstrious:1>",
"loras": [
{
"name": "aorunIllstrious",
"strength": "0.90",
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
],
"lora_stack": [
"59",
0
]
},
"class_type": "Lora Stacker (LoraManager)",
"_meta": {
"title": "Lora Stacker (LoraManager)"
}
}
LoRA node 59:
{
"inputs": {
"text": "<lora:ck-neon-retrowave-IL-000012:0.8>",
"loras": [
{
"name": "ck-neon-retrowave-IL-000012",
"strength": 0.8,
"active": true
},
{
"name": "__dummy_item1__",
"strength": 0,
"active": false,
"_isDummy": true
},
{
"name": "__dummy_item2__",
"strength": 0,
"active": false,
"_isDummy": true
}
]
},
"class_type": "Lora Stacker (LoraManager)",
"_meta": {
"title": "Lora Stacker (LoraManager)"
}
}
Test completed.

View File

@@ -1,4 +1,8 @@
aiohttp
jinja2
safetensors
watchdog
watchdog
beautifulsoup4
piexif
Pillow
requests

View File

@@ -1,6 +1,8 @@
/* 强制显示滚动条,防止页面跳动 */
html {
overflow-y: scroll;
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden; /* Disable default scrolling */
}
/* 针对Firefox */
@@ -16,6 +18,7 @@ html {
::-webkit-scrollbar-track {
background: transparent;
margin-top: 0;
}
::-webkit-scrollbar-thumb {
@@ -35,6 +38,7 @@ html {
--lora-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(95% 0.02 256);
--lora-error: oklch(75% 0.32 29);
--lora-warning: oklch(75% 0.25 80); /* Add warning color for deleted LoRAs */
/* Spacing Scale */
--space-1: calc(8px * 1);
@@ -43,6 +47,7 @@ html {
/* Z-index Scale */
--z-base: 10;
--z-header: 100;
--z-modal: 1000;
--z-overlay: 2000;
@@ -64,11 +69,14 @@ html {
--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); /* Add warning color for dark theme too */
}
body {
margin: 0;
font-family: 'Segoe UI', sans-serif;
background: var(--bg-color);
color: var(--text-color);
display: flex;
flex-direction: column;
padding-top: 0; /* Remove the padding-top */
}

View File

@@ -20,6 +20,9 @@
aspect-ratio: 896/1152;
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
margin: 0 auto;
cursor: pointer; /* Added from recipe-card */
display: flex; /* Added from recipe-card */
flex-direction: column; /* Added from recipe-card */
}
.lora-card:hover {
@@ -274,4 +277,55 @@
border-radius: var(--border-radius-xs);
backdrop-filter: blur(2px);
font-size: 0.85em;
}
/* Recipe specific elements - migrated from recipe-card.css */
.recipe-indicator {
position: absolute;
top: 6px;
left: 8px;
width: 24px;
height: 24px;
background: var(--lora-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
z-index: 2;
}
.base-model-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin-left: 32px; /* For accommodating the recipe indicator */
}
.lora-count {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.2);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
position: relative;
}
.lora-count.ready {
background: rgba(46, 204, 113, 0.3);
}
.lora-count.missing {
background: rgba(231, 76, 60, 0.3);
}
.placeholder-message {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
background: var(--lora-surface-alt);
border-radius: var(--border-radius-base);
}

View File

@@ -23,12 +23,6 @@
color: var(--text-color);
}
.error-message {
color: var(--lora-error);
font-size: 0.9em;
margin-top: 4px;
}
/* Version List Styles */
.version-list {
max-height: 400px;
@@ -104,6 +98,7 @@
.version-info {
display: flex;
flex-wrap: wrap;
flex-direction: row !important;
gap: 8px;
align-items: center;
font-size: 0.9em;
@@ -130,50 +125,6 @@
gap: 4px;
}
/* Local Version Badge */
.local-badge {
display: inline-flex;
align-items: center;
background: var(--lora-accent);
color: var(--lora-text);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
}
.local-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
min-width: 200px;
max-width: 300px;
}
.local-badge:hover .local-path {
display: block;
}
/* Folder Browser Styles */
.folder-browser {
border: 1px solid var(--border-color);
@@ -251,47 +202,4 @@
.version-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.local-badge {
display: inline-flex;
align-items: center;
background: var(--lora-accent);
color: var(--lora-text);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
}
.local-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
min-width: 200px;
max-width: 300px;
}
.local-badge:hover .local-path {
display: block;
}
}

View File

@@ -0,0 +1,177 @@
.app-header {
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
position: fixed;
top: 0;
z-index: var(--z-header);
height: 48px; /* Reduced height */
width: 100%;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.header-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 15px;
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
}
/* Logo and title styling */
.header-branding {
display: flex;
align-items: center;
flex-shrink: 0;
}
.logo-link {
display: flex;
align-items: center;
text-decoration: none;
color: var(--text-color);
gap: 8px;
}
.app-logo {
width: 24px;
height: 24px;
}
.app-title {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
/* Navigation styling */
.main-nav {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
margin-right: 1rem;
}
.nav-item {
padding: 0.25rem 0.75rem;
border-radius: var(--border-radius-xs);
color: var(--text-color);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
transition: all 0.2s ease;
font-size: 0.9rem;
}
.nav-item:hover {
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
}
.nav-item.active {
background-color: var(--lora-accent);
color: white;
}
/* Header search */
.header-search {
flex: 1;
max-width: 400px;
margin: 0 1rem;
}
/* Header controls (formerly corner controls) */
.header-controls {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.header-controls > div {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.header-controls > div:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.theme-toggle {
position: relative; /* Ensure relative positioning for the container */
}
.theme-toggle .light-icon,
.theme-toggle .dark-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Center perfectly */
opacity: 0;
transition: opacity 0.3s ease;
}
.theme-toggle .dark-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .light-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .dark-icon {
opacity: 0;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.app-title {
display: none; /* Hide text title on mobile */
}
.header-controls {
gap: 4px;
}
.header-controls > div {
width: 28px;
height: 28px;
}
.header-search {
max-width: none;
margin: 0 0.5rem;
}
.main-nav {
margin-right: 0.5rem;
}
}
/* For very small screens */
@media (max-width: 600px) {
.header-container {
padding: 0 8px;
}
.main-nav {
display: none; /* Hide navigation on very small screens */
}
.header-search {
flex: 1;
}
}

View File

@@ -0,0 +1,735 @@
/* Import Modal Styles */
.import-step {
margin: var(--space-2) 0;
transition: none !important; /* Disable any transitions that might affect display */
}
/* Import Mode Toggle */
.import-mode-toggle {
display: flex;
margin-bottom: var(--space-3);
border-radius: var(--border-radius-sm);
overflow: hidden;
border: 1px solid var(--border-color);
}
.toggle-btn {
flex: 1;
padding: 10px 16px;
background: var(--bg-color);
color: var(--text-color);
border: none;
cursor: pointer;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background-color 0.2s, color 0.2s;
}
.toggle-btn:first-child {
border-right: 1px solid var(--border-color);
}
.toggle-btn.active {
background: var(--lora-accent);
color: var(--lora-text);
}
.toggle-btn:hover:not(.active) {
background: var(--lora-surface);
}
.import-section {
margin-bottom: var(--space-3);
}
/* File Input Styles */
.file-input-wrapper {
position: relative;
margin-bottom: var(--space-1);
}
.file-input-wrapper input[type="file"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.file-input-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.file-input-button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
.file-input-wrapper:hover .file-input-button {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
/* Recipe Details Layout */
.recipe-details-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.recipe-image-container {
width: 100%;
height: 200px;
border-radius: var(--border-radius-sm);
overflow: hidden;
background: var(--lora-surface);
border: 1px solid var(--border-color);
}
.recipe-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.recipe-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.recipe-form-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
/* Tags Input Styles */
.tag-input-container {
display: flex;
gap: 8px;
margin-bottom: var(--space-1);
}
.tag-input-container input {
flex: 1;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: var(--space-1);
min-height: 32px;
}
.recipe-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
}
.recipe-tag i {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.recipe-tag i:hover {
opacity: 1;
color: var(--lora-error);
}
.empty-tags {
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
font-style: italic;
}
/* LoRAs List Styles */
.loras-list {
max-height: 300px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.lora-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--bg-color);
margin: 1px;
}
.lora-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.lora-item.missing-locally {
border-left: 4px solid var(--lora-error);
}
.lora-item.is-deleted {
background: oklch(var(--lora-warning) / 0.05);
border-left: 4px solid var(--lora-warning);
}
.lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05);
border-left: 4px solid #00B87A;
}
.lora-item.missing-locally {
border-left: 4px solid var(--lora-error);
}
.lora-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.lora-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.lora-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.lora-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.lora-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
flex: 1;
}
.lora-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 0.9em;
}
.lora-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.lora-version {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.7;
}
.weight-badge {
background: var(--lora-surface);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
}
/* Missing LoRAs List */
.missing-loras-list {
max-height: 200px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--space-1);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
}
.missing-lora-item {
display: flex;
gap: var(--space-2);
padding: var(--space-1);
border-bottom: 1px solid var(--border-color);
}
.missing-lora-item:last-child {
border-bottom: none;
}
.missing-lora-item.is-early-access {
background: rgba(0, 184, 122, 0.05);
border-left: 3px solid #00B87A;
padding-left: 10px;
}
.missing-badge {
display: inline-flex;
align-items: center;
background: var(--lora-error);
color: white;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.missing-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.lora-count-info {
font-size: 0.85em;
opacity: 0.8;
font-weight: normal;
margin-left: 8px;
}
/* Location Selection Styles */
.location-selection {
margin: var(--space-2) 0;
padding: var(--space-2);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
}
/* Reuse folder browser and path preview styles from download-modal.css */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
/* Input Group Styles */
.input-group {
margin-bottom: var(--space-2);
}
.input-with-button {
display: flex;
gap: 8px;
}
.input-with-button input {
flex: 1;
min-width: 0;
}
.input-with-button button {
flex-shrink: 0;
white-space: nowrap;
padding: 8px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: background-color 0.2s;
}
.input-with-button button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
.input-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
}
.input-group input,
.input-group select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
}
/* Dark theme adjustments */
[data-theme="dark"] .lora-item {
background: var(--lora-surface);
}
[data-theme="dark"] .recipe-tag {
background: var(--card-bg);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.recipe-details-layout {
grid-template-columns: 1fr;
}
.recipe-image-container {
height: 150px;
}
}
/* Size badge for LoRA items */
.size-badge {
background: var(--lora-surface);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
}
/* Improved Missing LoRAs summary section */
.missing-loras-summary {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
}
.summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0;
}
.summary-header h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
display: flex;
align-items: center;
gap: var(--space-1);
}
.lora-count-badge {
font-size: 0.9em;
font-weight: normal;
opacity: 0.7;
}
.total-size-badge {
font-size: 0.85em;
font-weight: normal;
background: var(--lora-surface);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
margin-left: var(--space-1);
}
.toggle-list-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-color);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
}
.toggle-list-btn:hover {
background: var(--lora-surface);
}
.missing-loras-list {
max-height: 200px;
overflow-y: auto;
transition: max-height 0.3s ease, margin-top 0.3s ease, padding-top 0.3s ease;
margin-top: 0;
padding-top: 0;
}
.missing-loras-list.collapsed {
max-height: 0;
overflow: hidden;
padding-top: 0;
}
.missing-loras-list:not(.collapsed) {
margin-top: var(--space-1);
padding-top: var(--space-1);
border-top: 1px solid var(--border-color);
}
.missing-lora-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--border-color);
}
.missing-lora-item:last-child {
border-bottom: none;
}
.missing-lora-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.missing-lora-name {
font-weight: 500;
}
.lora-base-model {
font-size: 0.85em;
color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.1);
padding: 2px 6px;
border-radius: var(--border-radius-xs);
display: inline-block;
}
.missing-lora-size {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
}
/* Recipe name input select-all behavior */
#recipeName:focus {
outline: 2px solid var(--lora-accent);
}
/* Prevent layout shift with scrollbar */
.modal-content {
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 */
}
}
/* Deleted LoRA styles - Fix layout issues */
.lora-item.is-deleted {
background: oklch(var(--lora-warning) / 0.05);
border-left: 4px solid var(--lora-warning);
}
.deleted-badge {
display: inline-flex;
align-items: center;
background: var(--lora-warning);
color: white;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.deleted-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.exclude-lora-checkbox {
display: none;
}
/* Deleted LoRAs warning - redesigned to not interfere with modal buttons */
.deleted-loras-warning {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: oklch(var(--lora-warning) / 0.1);
border: 1px solid var(--lora-warning);
border-radius: var(--border-radius-sm);
color: var(--text-color);
margin-bottom: var(--space-2);
}
.warning-icon {
color: var(--lora-warning);
font-size: 1.2em;
padding-top: 2px;
}
.warning-content {
flex: 1;
}
.warning-title {
font-weight: 600;
margin-bottom: 4px;
}
.warning-text {
font-size: 0.9em;
line-height: 1.4;
}
/* Remove the old warning-message styles that were causing layout issues */
.warning-message {
display: none; /* Hide the old style */
}
/* Update deleted badge to be more prominent */
.deleted-badge {
display: inline-flex;
align-items: center;
background: var(--lora-warning);
color: white;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.deleted-badge i {
margin-right: 4px;
font-size: 0.9em;
}
/* Error message styling */
.error-message {
color: var(--lora-error);
font-size: 0.9em;
margin-top: 8px;
min-height: 20px; /* Ensure there's always space for the error message */
font-weight: 500;
}
.early-access-warning {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: rgba(0, 184, 122, 0.1);
border: 1px solid #00B87A;
border-radius: var(--border-radius-sm);
color: var(--text-color);
margin-bottom: var(--space-2);
}
/* Add special styling for early access badge in the missing loras list */
.missing-lora-item .early-access-badge {
padding: 2px 6px;
font-size: 0.75em;
margin-top: 4px;
display: inline-flex;
}
/* Specific styling for the early access warning container in import modal */
.early-access-warning .warning-icon {
color: #00B87A;
font-size: 1.2em;
}
.early-access-warning .warning-title {
font-weight: 600;
margin-bottom: 4px;
}
.early-access-warning .warning-text {
font-size: 0.9em;
line-height: 1.4;
}

View File

@@ -56,6 +56,53 @@
transition: width 200ms ease-out;
}
/* Enhanced progress display */
.progress-details-container {
margin-top: var(--space-3);
width: 100%;
text-align: left;
}
.overall-progress-label {
font-size: 0.9rem;
margin-bottom: var(--space-1);
color: var(--text-color);
}
.current-item-progress {
margin-top: var(--space-2);
}
.current-item-label {
font-size: 0.9rem;
margin-bottom: var(--space-1);
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.current-item-bar-container {
height: 8px;
background-color: var(--lora-border);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--space-1);
}
.current-item-bar {
height: 100%;
background-color: var(--lora-accent);
transition: width 200ms ease-out;
width: 0%;
}
.current-item-percent {
font-size: 0.8rem;
color: var(--text-color-secondary, var(--text-color));
opacity: 0.7;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
@@ -63,7 +110,8 @@
@media (prefers-reduced-motion: reduce) {
.lora-card,
.progress-bar {
.progress-bar,
.current-item-bar {
transition: none;
}
}

View File

@@ -99,6 +99,7 @@
width: 100%;
background: var(--lora-surface);
margin-bottom: var(--space-2);
overflow: hidden; /* Ensure metadata panel is contained */
}
.media-wrapper:last-child {
@@ -542,25 +543,53 @@
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
position: relative;
}
.file-name-wrapper:hover {
background: oklch(var(--lora-accent) / 0.1);
}
.file-name-wrapper i {
color: var(--text-color);
opacity: 0.5;
transition: opacity 0.2s;
.file-name-content {
padding: 2px 4px;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
flex: 1;
}
.file-name-wrapper:hover i {
opacity: 1;
color: var(--lora-accent);
.file-name-wrapper.editing .file-name-content {
border: 1px solid var(--lora-accent);
background: var(--bg-color);
outline: none;
}
.edit-file-name-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-file-name-btn.visible,
.file-name-wrapper:hover .edit-file-name-btn {
opacity: 0.5;
}
.edit-file-name-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-file-name-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Base Model and Size combined styles */
@@ -573,6 +602,59 @@
flex: 2; /* 分配更多空间给base model */
}
/* Base model display and editing styles */
.base-model-display {
display: flex;
align-items: center;
position: relative;
}
.base-model-content {
padding: 2px 4px;
border-radius: var(--border-radius-xs);
border: 1px solid transparent;
color: var(--text-color);
flex: 1;
}
.edit-base-model-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-base-model-btn.visible,
.base-model-display:hover .edit-base-model-btn {
opacity: 0.5;
}
.edit-base-model-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-base-model-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.base-model-selector {
width: 100%;
padding: 3px 5px;
background: var(--bg-color);
border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs);
color: var(--text-color);
font-size: 0.9em;
outline: none;
margin-right: var(--space-1);
}
.size-wrapper {
flex: 1;
border-left: 1px solid var(--lora-border);
@@ -593,56 +675,59 @@
/* Model name field styles - complete replacement */
.model-name-field {
display: flex;
align-items: center;
gap: var(--space-2);
width: calc(100% - 40px); /* Reduce width to avoid overlap with close button */
position: relative; /* Add position relative for absolute positioning of save button */
display: none;
}
.model-name-field h2 {
/* New Model Name Header Styles */
.model-name-header {
display: flex;
align-items: center;
width: calc(100% - 40px); /* Avoid overlap with close button */
position: relative;
}
.model-name-content {
margin: 0;
padding: var(--space-1);
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
flex: 1;
font-size: 1.5em !important; /* Increased and forced size */
font-weight: 600; /* Make it bolder */
min-height: 1.5em;
box-sizing: border-box;
border: 1px solid transparent;
font-size: 1.5em !important;
font-weight: 600;
line-height: 1.2;
color: var(--text-color); /* Ensure correct color */
}
.model-name-field h2:hover {
background: oklch(var(--lora-accent) / 0.1);
cursor: text;
}
.model-name-field h2:focus {
color: var(--text-color);
border: 1px solid transparent;
outline: none;
background: var(--bg-color);
flex: 1;
}
.model-name-content:focus {
border: 1px solid var(--lora-accent);
background: var(--bg-color);
}
.model-name-field .save-btn {
position: absolute;
right: 10px; /* Position closer to the end of the field */
top: 50%;
transform: translateY(-50%);
.edit-model-name-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
transition: opacity 0.2s;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.model-name-field:hover .save-btn,
.model-name-field h2:focus ~ .save-btn {
opacity: 1;
.edit-model-name-btn.visible,
.model-name-header:hover .edit-model-name-btn {
opacity: 0.5;
}
/* Ensure close button is accessible */
.modal-content .close {
z-index: 10; /* Ensure close button is above other elements */
.edit-model-name-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-model-name-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Tab System Styling */
@@ -796,12 +881,6 @@
display: none !important;
}
.error-message {
color: var(--lora-error);
text-align: center;
padding: var(--space-2);
}
.no-examples {
text-align: center;
padding: var(--space-3);
@@ -913,7 +992,6 @@
/* Updated Model Tags styles - improved visibility in light theme */
.model-tags-container {
position: relative;
margin-top: 4px;
}
.model-tags-compact {
@@ -1034,4 +1112,172 @@
/* Make sure media wrapper maintains position: relative for absolute positioning of children */
.carousel .media-wrapper {
position: relative;
}
/* Image Metadata Panel Styles */
.image-metadata-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
padding: var(--space-2);
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
z-index: 5;
max-height: 50%; /* Reduced to take less space */
overflow-y: auto;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
opacity: 0;
pointer-events: none;
}
/* Show metadata panel only on hover */
.media-wrapper:hover .image-metadata-panel {
transform: translateY(0);
opacity: 0.98;
pointer-events: auto;
}
/* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel {
background: var(--card-bg);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
}
.metadata-content {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Styling for parameters tags */
.params-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: var(--space-1);
padding-bottom: var(--space-1);
border-bottom: 1px solid var(--lora-border);
}
.param-tag {
display: inline-flex;
align-items: center;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: 2px 6px;
font-size: 0.8em;
line-height: 1.2;
white-space: nowrap;
}
.param-tag .param-name {
font-weight: 600;
color: var(--text-color);
margin-right: 4px;
opacity: 0.8;
}
.param-tag .param-value {
color: var(--lora-accent);
}
/* Special styling for prompt row */
.metadata-row.prompt-row {
flex-direction: column;
padding-top: 0;
}
.metadata-row.prompt-row + .metadata-row.prompt-row {
margin-top: var(--space-2);
}
.metadata-label {
font-weight: 600;
color: var(--text-color);
opacity: 0.8;
font-size: 0.85em;
display: block;
margin-bottom: 4px;
}
.metadata-prompt-wrapper {
position: relative;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: 6px 30px 6px 8px;
margin-top: 2px;
max-height: 80px; /* Reduced from 120px */
overflow-y: auto;
word-break: break-word;
width: 100%;
box-sizing: border-box;
}
.metadata-prompt {
color: var(--text-color);
font-family: monospace;
font-size: 0.85em;
white-space: pre-wrap;
}
.copy-prompt-btn {
position: absolute;
top: 6px;
right: 6px;
background: transparent;
border: none;
color: var(--text-color);
opacity: 0.6;
cursor: pointer;
padding: 3px;
transition: all 0.2s ease;
}
.copy-prompt-btn:hover {
opacity: 1;
color: var(--lora-accent);
}
/* Scrollbar styling for metadata panel */
.image-metadata-panel::-webkit-scrollbar {
width: 6px;
}
.image-metadata-panel::-webkit-scrollbar-track {
background: transparent;
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* For Firefox */
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* No metadata message styling */
.no-metadata-message {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
color: var(--text-color);
opacity: 0.7;
text-align: center;
font-style: italic;
gap: 8px;
}
.no-metadata-message i {
font-size: 1.1em;
color: var(--lora-accent);
opacity: 0.8;
}

View File

@@ -2,13 +2,13 @@
.modal {
display: none;
position: fixed;
top: 0;
top: 48px; /* Start below the header */
left: 0;
width: 100%;
height: 100%;
height: calc(100% - 48px); /* Adjust height to exclude header */
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
z-index: var(--z-modal);
overflow: hidden; /* 改为 hidden防止双滚动条 */
overflow: auto; /* Change from hidden to auto to allow scrolling */
}
/* 当模态窗口打开时禁止body滚动 */
@@ -23,8 +23,8 @@ body.modal-open {
position: relative;
max-width: 800px;
height: auto;
max-height: 90vh;
margin: 2rem auto;
max-height: calc(90vh - 48px); /* Adjust to account for header height */
margin: 1rem auto; /* Keep reduced top margin */
background: var(--lora-surface);
border-radius: var(--border-radius-base);
padding: var(--space-3);
@@ -341,8 +341,7 @@ body.modal-open {
.setting-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-direction: column;
margin-bottom: var(--space-2);
padding: var(--space-1);
border-radius: var(--border-radius-xs);
@@ -357,7 +356,8 @@ body.modal-open {
}
.setting-info {
flex: 1;
margin-bottom: var(--space-1);
width: 100%;
}
.setting-info label {
@@ -367,7 +367,39 @@ body.modal-open {
}
.setting-control {
padding-left: var(--space-2);
width: 100%;
margin-bottom: var(--space-1);
}
/* Select Control Styles */
.select-control {
width: 100%;
}
.select-control select {
width: 100%;
padding: 6px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
}
/* Fix dark theme select dropdown text color */
[data-theme="dark"] .select-control select {
background-color: rgba(30, 30, 30, 0.9);
color: var(--text-color);
}
[data-theme="dark"] .select-control select option {
background-color: #2d2d2d;
color: var(--text-color);
}
.select-control select:focus {
border-color: var(--lora-accent);
outline: none;
}
/* Toggle Switch */
@@ -443,4 +475,83 @@ input:checked + .toggle-slider:before {
.nsfw-blur:hover {
filter: blur(8px);
}
/* Add styles for delete preview image */
.delete-preview {
max-width: 150px;
margin: 0 auto var(--space-2);
overflow: hidden;
}
.delete-preview img {
width: 100%;
height: auto;
max-height: 150px;
object-fit: contain;
border-radius: var(--border-radius-sm);
}
.delete-info {
text-align: center;
}
.delete-info h3 {
margin-bottom: var(--space-1);
word-break: break-word;
}
.delete-info p {
margin: var(--space-1) 0;
font-size: 0.9em;
opacity: 0.8;
}
.delete-note {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
font-style: italic;
margin-top: var(--space-1);
text-align: center;
}
/* Add styles for markdown elements in changelog */
.changelog-item ul {
padding-left: 20px;
margin-top: 8px;
}
.changelog-item li {
margin-bottom: 6px;
line-height: 1.4;
}
.changelog-item strong {
font-weight: 600;
}
.changelog-item em {
font-style: italic;
}
.changelog-item code {
background: rgba(0, 0, 0, 0.05);
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
[data-theme="dark"] .changelog-item code {
background: rgba(255, 255, 255, 0.1);
}
.changelog-item a {
color: var(--lora-accent);
text-decoration: none;
}
.changelog-item a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,184 @@
.recipe-tag-container {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.recipe-tag {
background: var(--lora-surface-hover);
color: var(--lora-text-secondary);
padding: 0.25rem 0.5rem;
border-radius: var(--border-radius-sm);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
}
.recipe-tag:hover, .recipe-tag.active {
background: var(--lora-primary);
color: var(--lora-text-on-primary);
}
.recipe-card {
position: relative;
background: var(--lora-surface);
border-radius: var(--border-radius-base);
overflow: hidden;
box-shadow: var(--shadow-sm);
transition: all 0.2s ease;
aspect-ratio: 896/1152;
cursor: pointer;
display: flex;
flex-direction: column;
}
.recipe-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
.recipe-card:focus-visible {
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
}
.recipe-indicator {
position: absolute;
top: 6px;
left: 8px;
width: 24px;
height: 24px;
background: var(--lora-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
z-index: 2;
}
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.placeholder-message {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
background: var(--lora-surface-alt);
border-radius: var(--border-radius-base);
}
.card-preview {
position: relative;
width: 100%;
height: 100%;
border-radius: var(--border-radius-base);
overflow: hidden;
}
.card-preview img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
}
.card-header {
position: absolute;
top: 0;
left: 0;
right: 0;
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
backdrop-filter: blur(8px);
color: white;
padding: var(--space-1);
display: flex;
justify-content: space-between;
align-items: center;
z-index: 1;
min-height: 20px;
}
.base-model-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin-left: 32px;
}
.card-actions {
display: flex;
gap: 8px;
}
.card-actions i {
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.card-actions i:hover {
opacity: 1;
}
.card-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
backdrop-filter: blur(8px);
color: white;
padding: var(--space-1);
display: flex;
justify-content: space-between;
align-items: flex-start;
min-height: 32px;
gap: var(--space-1);
}
.lora-count {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.2);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
position: relative;
}
.lora-count.ready {
background: rgba(46, 204, 113, 0.3);
}
.lora-count.missing {
background: rgba(231, 76, 60, 0.3);
}
/* 响应式设计 */
@media (max-width: 1400px) {
.recipe-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.recipe-card {
max-width: 240px;
}
}
@media (max-width: 768px) {
.recipe-grid {
grid-template-columns: minmax(260px, 1fr);
}
.recipe-card {
max-width: 100%;
}
}

View File

@@ -0,0 +1,832 @@
.recipe-modal-header {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
border-bottom: 1px solid var(--lora-border);
padding-bottom: 10px;
margin-bottom: 10px;
}
.recipe-modal-header h2 {
font-size: 1.4em; /* Reduced from default h2 size */
line-height: 1.3;
margin: 0;
max-height: 2.6em; /* Limit to 2 lines */
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
width: calc(100% - 20px);
}
/* Editable content styles */
.editable-content {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.editable-content.hide {
display: none;
}
.editable-content .content-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-icon {
background: none;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 4px 8px;
margin-left: 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.editable-content:hover .edit-icon {
opacity: 0.6;
}
.edit-icon:hover {
opacity: 1 !important;
background: var(--lora-surface);
}
/* Content editor styles */
.content-editor {
display: none;
width: 100%;
padding: 4px 0;
}
.content-editor.active {
display: flex;
align-items: center;
gap: 8px;
}
.content-editor input {
flex: 1;
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: 6px 8px;
font-size: 1em;
color: var(--text-color);
min-width: 0;
}
.content-editor.tags-editor input {
font-size: 0.9em;
}
/* 删除不再需要的按钮样式 */
.editor-actions {
display: none;
}
/* Special styling for tags content */
.tags-content {
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 8px;
}
.tags-display {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
}
.no-tags {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.6;
font-style: italic;
}
/* Recipe Tags styles */
.recipe-tags-container {
position: relative;
margin-top: 6px;
margin-bottom: 10px;
}
.recipe-tags-compact {
display: flex;
flex-wrap: nowrap;
gap: 6px;
align-items: center;
}
.recipe-tag-compact {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
color: var(--text-color);
white-space: nowrap;
}
[data-theme="dark"] .recipe-tag-compact {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.recipe-tag-more {
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
padding: 2px 8px;
font-size: 0.75em;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
}
.recipe-tags-tooltip {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
padding: 10px 14px;
max-width: 400px;
z-index: 10;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: all 0.2s ease;
pointer-events: none;
}
.recipe-tags-tooltip.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
pointer-events: auto;
}
.tooltip-content {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.tooltip-tag {
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-xs);
padding: 3px 8px;
font-size: 0.75em;
color: var(--text-color);
}
[data-theme="dark"] .tooltip-tag {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
/* Top Section: Preview and Gen Params */
.recipe-top-section {
display: grid;
grid-template-columns: 280px 1fr;
gap: var(--space-2);
flex-shrink: 0;
margin-bottom: var(--space-2);
}
/* Recipe Preview */
.recipe-preview-container {
width: 100%;
height: 360px;
border-radius: var(--border-radius-sm);
overflow: hidden;
background: var(--lora-surface);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
}
.recipe-preview-container img,
.recipe-preview-container video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.recipe-preview-media {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Generation Parameters */
.recipe-gen-params {
height: 360px;
display: flex;
flex-direction: column;
}
.recipe-gen-params h3 {
margin-top: 0;
margin-bottom: var(--space-2);
font-size: 1.2em;
color: var(--text-color);
padding-bottom: var(--space-1);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.gen-params-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
overflow-y: auto;
flex: 1;
}
.param-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.param-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.param-header label {
font-weight: 500;
color: var(--text-color);
}
.copy-btn {
background: none;
border: none;
color: var(--text-color);
opacity: 0.6;
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
transition: all 0.2s;
}
.copy-btn:hover {
opacity: 1;
background: var(--lora-surface);
}
.param-content {
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-2);
color: var(--text-color);
font-size: 0.9em;
line-height: 1.5;
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Other Parameters */
.other-params {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: var(--space-1);
}
.param-tag {
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px 8px;
font-size: 0.85em;
color: var(--text-color);
display: flex;
align-items: center;
gap: 6px;
}
.param-tag .param-name {
font-weight: 500;
opacity: 0.8;
}
/* Bottom Section: Resources */
.recipe-bottom-section {
max-height: 320px;
display: flex;
flex-direction: column;
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
}
.recipe-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
padding-bottom: var(--space-1);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.recipe-section-header h3 {
margin: 0;
font-size: 1.2em;
color: var(--text-color);
display: flex;
align-items: center;
gap: 8px;
}
.recipe-status {
display: inline-flex;
align-items: center;
font-size: 0.85em;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
margin-left: var(--space-1);
}
.recipe-status.ready {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
}
.recipe-status.missing {
background: oklch(var(--lora-error) / 0.1);
color: var(--lora-error);
}
.recipe-status i {
margin-right: 4px;
}
.recipe-section-actions {
display: flex;
align-items: center;
gap: var(--space-1);
}
#recipeLorasCount {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
display: flex;
align-items: center;
gap: 6px;
}
#recipeLorasCount i {
font-size: 1em;
}
/* LoRAs List */
.recipe-loras-list {
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
flex: 1;
}
.recipe-lora-item {
display: flex;
gap: var(--space-2);
padding: 10px var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--bg-color);
/* Add will-change to create a new stacking context and force hardware acceleration */
will-change: transform;
/* Create a new containing block for absolutely positioned descendants */
transform: translateZ(0);
}
.recipe-lora-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.recipe-lora-item.missing-locally {
border-left: 4px solid var(--lora-error);
}
.recipe-lora-item.is-deleted {
background: rgba(127, 127, 127, 0.05);
border-left: 4px solid #777;
opacity: 0.8;
}
.recipe-lora-thumbnail {
width: 46px;
height: 46px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
display: flex;
align-items: center;
justify-content: center;
}
.recipe-lora-thumbnail img,
.recipe-lora-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.recipe-lora-content {
display: flex;
flex-direction: column;
gap: 3px;
flex: 1;
min-width: 0;
}
.recipe-lora-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
position: relative;
min-height: 28px;
/* Ensure badges don't move during scroll in Chrome */
transform: translateZ(0);
}
.recipe-lora-content h4 {
margin: 0;
font-size: 1em;
color: var(--text-color);
flex: 1;
max-width: calc(100% - 120px); /* Make room for the badge */
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* Limit to 2 lines */
-webkit-box-orient: vertical;
line-height: 1.3;
}
.recipe-lora-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 0.85em;
margin-top: 4px;
padding-right: 4px;
}
.recipe-lora-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.recipe-lora-version {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.recipe-lora-weight {
background: var(--lora-surface);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
color: var(--lora-accent);
}
.local-badge,
.missing-badge {
position: absolute;
right: 0;
top: 0;
/* Force hardware acceleration for Chrome */
transform: translateZ(0);
backface-visibility: hidden;
}
/* Specific styles for recipe modal badges - update z-index */
.recipe-lora-header .local-badge,
.recipe-lora-header .missing-badge {
z-index: 2; /* Ensure the badge is above other elements */
backface-visibility: hidden;
}
/* Ensure local-path tooltip is properly positioned and won't move during scroll */
.recipe-lora-header .local-badge .local-path {
z-index: 3;
top: calc(100% + 4px); /* Position tooltip below the badge */
right: -4px; /* Align with the badge */
max-width: 250px;
/* Force hardware acceleration for Chrome */
transform: translateZ(0);
}
.missing-badge {
display: inline-flex;
align-items: center;
background: var(--lora-error);
color: white;
padding: 3px 6px;
border-radius: var(--border-radius-xs);
font-size: 0.75em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.missing-badge i {
margin-right: 4px;
font-size: 0.9em;
}
/* Deleted badge with reconnect functionality */
.deleted-badge {
display: inline-flex;
align-items: center;
background: #777;
color: white;
padding: 3px 6px;
border-radius: var(--border-radius-xs);
font-size: 0.75em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.deleted-badge i {
margin-right: 4px;
font-size: 0.9em;
}
/* Add reconnect functionality styles */
.deleted-badge.reconnectable {
position: relative;
cursor: pointer;
transition: background-color 0.2s ease;
}
.deleted-badge.reconnectable:hover {
background-color: var(--lora-accent);
}
.deleted-badge .reconnect-tooltip {
position: absolute;
display: none;
background-color: var(--card-bg);
color: var(--text-color);
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;
font-size: 0.85rem;
font-weight: normal;
top: calc(100% + 5px);
left: 0;
margin-left: -100px;
}
.deleted-badge.reconnectable:hover .reconnect-tooltip {
display: block;
}
/* LoRA reconnect container */
.lora-reconnect-container {
display: none;
flex-direction: column;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 12px;
margin-top: 10px;
gap: 10px;
}
.lora-reconnect-container.active {
display: flex;
}
.reconnect-instructions {
display: flex;
flex-direction: column;
gap: 5px;
}
.reconnect-instructions p {
margin: 0;
font-size: 0.95em;
font-weight: 500;
color: var(--text-color);
}
.reconnect-instructions small {
color: var(--text-color);
opacity: 0.7;
font-size: 0.85em;
}
.reconnect-instructions code {
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
[data-theme="dark"] .reconnect-instructions code {
background: rgba(255, 255, 255, 0.1);
}
.reconnect-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.reconnect-input {
width: calc(100% - 20px);
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9em;
}
.reconnect-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.reconnect-cancel-btn,
.reconnect-confirm-btn {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.reconnect-cancel-btn {
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.reconnect-confirm-btn {
background: var(--lora-accent);
color: white;
}
.reconnect-cancel-btn:hover {
background: var(--lora-surface);
}
.reconnect-confirm-btn:hover {
background: color-mix(in oklch, var(--lora-accent), black 10%);
}
/* Recipe status partial state */
.recipe-status.partial {
background: rgba(127, 127, 127, 0.1);
color: #777;
}
/* 标题输入框特定的样式 */
.title-input {
font-size: 1.2em !important; /* 调整为更合适的大小 */
line-height: 1.2;
font-weight: 500;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.recipe-top-section {
grid-template-columns: 1fr;
}
.recipe-preview-container {
height: 200px;
}
.recipe-gen-params {
height: auto;
max-height: 300px;
}
}
.badge-container {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
min-width: 110px;
z-index: 2;
}
/* Update the local-badge and missing-badge to be positioned within the badge-container */
.badge-container .local-badge,
.badge-container .missing-badge,
.badge-container .deleted-badge {
position: static; /* Override absolute positioning */
transform: none; /* Remove the transform */
}
/* Ensure the tooltip is still properly positioned */
.badge-container .local-badge .local-path {
position: fixed; /* Keep as fixed for Chrome */
z-index: 100;
}
/* Add styles for missing LoRAs download feature */
.recipe-status.missing {
position: relative;
cursor: pointer;
transition: background-color 0.2s ease;
}
.recipe-status.missing:hover {
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
}
.recipe-status.missing .missing-tooltip {
position: absolute;
display: none;
background-color: var(--card-bg);
color: var(--text-color);
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;
font-size: 0.85rem;
font-weight: normal;
margin-left: -100px;
margin-top: -65px;
}
.recipe-status.missing:hover .missing-tooltip {
display: block;
}
.recipe-status.clickable {
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
}
.recipe-status.clickable:hover {
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
}

View File

@@ -1,9 +1,7 @@
/* Search Container Styles */
.search-container {
position: relative;
width: 250px;
margin-left: auto;
flex-shrink: 0; /* 防止搜索框被压缩 */
width: 100%;
display: flex;
align-items: center;
gap: 4px;
@@ -12,14 +10,14 @@
/* 调整搜索框样式以匹配其他控件 */
.search-container input {
width: 100%;
padding: 6px 75px 6px 12px; /* Increased right padding to accommodate both buttons */
border: 1px solid oklch(65% 0.02 256); /* 更深的边框颜色,提高对比度 */
padding: 6px 35px 6px 12px; /* Reduced right padding */
border: 1px solid oklch(65% 0.02 256);
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
font-size: 0.9em;
height: 32px;
box-sizing: border-box; /* 确保padding不会增加总宽度 */
box-sizing: border-box;
}
.search-container input:focus {
@@ -34,7 +32,7 @@
transform: translateY(-50%);
color: oklch(var(--text-color) / 0.5);
pointer-events: none;
line-height: 1; /* 防止图标影响容器高度 */
line-height: 1;
}
/* 修改清空按钮样式 */
@@ -47,8 +45,8 @@
cursor: pointer;
border: none;
background: none;
padding: 4px 8px; /* 增加点击区域 */
display: none; /* 默认隐藏 */
padding: 4px 8px;
display: none;
line-height: 1;
transition: color 0.2s ease;
}
@@ -144,19 +142,19 @@
/* Filter Panel Styles */
.filter-panel {
position: absolute;
top: 140px; /* Adjust to be closer to the filter button */
position: fixed;
right: 20px;
width: 300px;
top: 50px; /* Position below header */
width: 320px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay); /* Increase z-index to be above cards */
z-index: var(--z-overlay);
padding: 16px;
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: top right;
max-height: calc(100vh - 160px);
max-height: calc(100vh - 70px); /* Adjusted for header height */
overflow-y: auto;
}
@@ -312,7 +310,7 @@
width: calc(100% - 40px);
left: 20px;
right: 20px;
top: 140px;
top: 160px; /* Adjusted for mobile layout */
}
}
@@ -351,10 +349,10 @@
/* Search Options Panel */
.search-options-panel {
position: absolute;
top: 140px;
right: 65px; /* Position it closer to the search options button */
width: 280px; /* Slightly wider to accommodate tags better */
position: fixed;
right: 20px;
top: 50px; /* Position below header */
width: 280px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
@@ -363,6 +361,7 @@
padding: 16px;
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: top right;
display: block; /* Ensure it's block by default */
}
.search-options-panel.hidden {
@@ -507,4 +506,15 @@ input:checked + .slider:before {
.slider.round:before {
border-radius: 50%;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.search-options-panel,
.filter-panel {
width: calc(100% - 40px);
left: 20px;
right: 20px;
top: 160px; /* Adjusted for mobile layout */
}
}

View File

@@ -0,0 +1,111 @@
/* Local Version Badge */
.local-badge {
display: inline-flex;
align-items: center;
background: var(--lora-accent);
color: var(--lora-text);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
/* Force hardware acceleration to prevent Chrome scroll issues */
transform: translateZ(0);
will-change: transform;
}
.local-badge i {
margin-right: 4px;
font-size: 0.9em;
}
/* Early Access Badge */
.early-access-badge {
display: inline-flex;
align-items: center;
background: #00B87A; /* Green for early access */
color: white;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
/* Force hardware acceleration to prevent Chrome scroll issues */
transform: translateZ(0);
will-change: transform;
}
.early-access-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.early-access-info {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid #00B87A;
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 100; /* Higher z-index to ensure it's above other elements */
min-width: 300px;
max-width: 300px;
/* Create a separate layer with hardware acceleration */
transform: translateZ(0);
/* Use a fixed position to ensure it's in a separate layer from scrollable content */
position: fixed;
pointer-events: none; /* Don't block mouse events */
}
.early-access-badge:hover .early-access-info {
display: block;
pointer-events: auto; /* Allow interaction with the tooltip when visible */
}
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 100; /* Higher z-index to ensure it's above other elements */
min-width: 200px;
max-width: 300px;
/* Create a separate layer with hardware acceleration */
transform: translateZ(0);
/* Use a fixed position to ensure it's in a separate layer from scrollable content */
position: fixed;
pointer-events: none; /* Don't block mouse events */
}
.local-badge:hover .local-path {
display: block;
pointer-events: auto; /* Allow interaction with the tooltip when visible */
}
.error-message {
color: var(--lora-error);
font-size: 0.9em;
margin-top: 4px;
}

View File

@@ -1,6 +1,6 @@
/* Support Modal Styles */
.support-modal {
max-width: 550px;
max-width: 570px;
}
.support-header {
@@ -141,7 +141,7 @@
.support-toggle:hover {
background: var(--lora-accent);
color: white;
color: var(--lora-error) !important;
transform: translateY(-2px);
}

View File

@@ -120,4 +120,63 @@
.tooltip:hover::after {
opacity: 1;
}
/* Toast Container for stacked notifications */
.toast-container {
position: fixed;
top: 0;
right: 0;
z-index: calc(var(--z-overlay) + 10);
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
pointer-events: none; /* Allow clicking through the container */
width: 400px;
max-width: 100%;
}
/* Ensure each toast has pointer events */
.toast-container .toast {
pointer-events: auto;
position: relative; /* Override fixed positioning */
top: 0 !important; /* Let the container handle positioning */
right: 0 !important;
margin-bottom: 10px;
}
/* Add missing warning toast style */
.toast-warning {
border-left: 4px solid var(--lora-warning);
}
.toast-warning::before {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ff9800'%3E%3Cpath d='M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z'/%3E%3C/svg%3E");
}
/* Improve toast animation */
.toast {
transform: translateX(120%);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.toast.show {
transform: translateX(0);
opacity: 1;
}
/* Responsive adjustments */
@media (max-width: 480px) {
.toast-container {
width: 100%;
padding: 10px;
}
.toast {
width: 100%;
max-width: none;
}
}

View File

@@ -153,56 +153,43 @@
border-top: 1px solid var(--lora-border);
margin-top: var(--space-2);
padding-top: var(--space-2);
}
/* Toggle switch styles */
.toggle-switch {
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-start;
}
/* Override toggle switch styles for update preferences */
.update-preferences .toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
width: auto;
height: 24px;
cursor: pointer;
user-select: none;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-slider {
.update-preferences .toggle-slider {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
background-color: var(--border-color);
border-radius: 20px;
transition: .4s;
width: 50px;
height: 24px;
flex-shrink: 0;
margin-right: 10px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
transition: .4s;
.update-preferences .toggle-label {
margin-left: 0;
white-space: nowrap;
line-height: 24px;
}
input:checked + .toggle-slider {
background-color: var(--lora-accent);
}
input:checked + .toggle-slider:before {
transform: translateX(20px);
}
.toggle-label {
font-size: 0.9em;
color: var(--text-color);
@media (max-width: 480px) {
.update-preferences {
flex-direction: row;
flex-wrap: wrap;
}
.update-preferences .toggle-label {
margin-top: 5px;
}
}

View File

@@ -1,7 +1,18 @@
.page-content {
height: calc(100vh - 48px); /* Full height minus header */
margin-top: 48px; /* Push down below header */
overflow-y: auto; /* Enable scrolling here */
width: 100%;
position: relative;
overflow-y: scroll;
}
.container {
max-width: 1400px;
margin: 20px auto;
padding: 0 15px;
position: relative;
z-index: var(--z-base);
}
.controls {
@@ -14,69 +25,17 @@
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: nowrap;
width: 100%;
}
/* Search and filter styles moved to components/search-filter.css */
/* Update corner-controls for collapsible behavior */
.corner-controls {
position: fixed;
top: 20px;
right: 20px;
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
align-items: center;
transition: all 0.3s ease;
}
.corner-controls-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
.action-buttons {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
z-index: 2;
margin-bottom: 10px;
}
.corner-controls-toggle:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.corner-controls-items {
display: flex;
flex-direction: column;
gap: 10px;
opacity: 0;
transform: translateY(-10px) scale(0.9);
transition: all 0.3s ease;
pointer-events: none;
}
/* Expanded state */
.corner-controls.expanded .corner-controls-items {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: all;
}
/* Expanded state - only expand on hover if not already expanded by click */
.corner-controls:hover:not(.expanded) .corner-controls-items {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: all;
gap: var(--space-2);
flex-wrap: nowrap;
}
/* Ensure hidden class works properly */
@@ -84,46 +43,6 @@
display: none !important;
}
/* Update toggle button styles */
.update-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color); /* Changed from var(--lora-accent) to match other toggles */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.update-toggle:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* Update badge styles */
.update-badge {
position: absolute;
top: -3px;
right: -3px;
background-color: var(--lora-error);
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 0 2px var(--card-bg);
}
/* Badge on corner toggle */
.corner-badge {
top: 0;
right: 0;
}
.folder-tags-container {
position: relative;
width: 100%;
@@ -131,11 +50,14 @@
}
.folder-tags {
display: flex;
gap: 4px;
padding: 2px 0;
flex-wrap: wrap;
transition: max-height 0.3s ease, opacity 0.2s ease;
max-height: 150px; /* Limit height to prevent overflow */
opacity: 1;
overflow-y: auto; /* Enable vertical scrolling */
padding-right: 40px; /* Make space for the toggle button */
margin-bottom: 5px; /* Add margin below the tags */
}
@@ -144,13 +66,15 @@
opacity: 0;
margin: 0;
padding-bottom: 0;
overflow: hidden;
}
.toggle-folders-container {
margin-left: auto;
}
/* Toggle Folders Button */
.toggle-folders-btn {
position: absolute;
bottom: 0; /* 固定在容器底部 */
right: 0; /* 固定在容器右侧 */
width: 36px;
height: 36px;
border-radius: 50%;
@@ -162,7 +86,6 @@
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
z-index: 2;
}
.toggle-folders-btn:hover {
@@ -175,25 +98,18 @@
transition: transform 0.3s ease;
}
/* 折叠状态样式 */
.folder-tags.collapsed + .toggle-folders-btn {
position: static;
margin-right: auto; /* 确保按钮在左侧 */
transform: translateY(0);
/* Icon-only button style */
.icon-only {
min-width: unset !important;
width: 36px !important;
padding: 0 !important;
}
.folder-tags.collapsed + .toggle-folders-btn i {
/* Rotate icon when folders are collapsed */
.folder-tags.collapsed ~ .actions .toggle-folders-btn i {
transform: rotate(180deg);
}
/* 文件夹标签样式 */
.folder-tags {
display: flex;
gap: 4px;
padding: 2px 0;
flex-wrap: wrap;
}
/* Add custom scrollbar for better visibility */
.folder-tags::-webkit-scrollbar {
width: 6px;
@@ -263,124 +179,32 @@
transform: translateY(-2px);
}
.theme-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.theme-toggle:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.support-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--lora-error);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.support-toggle:hover {
background: var(--lora-error);
color: white;
transform: translateY(-2px);
}
.support-toggle i {
font-size: 1.1em;
position: relative;
top: 1px;
left: -0.5px;
}
.theme-toggle img {
width: 20px;
height: 20px;
}
.theme-toggle .theme-icon {
width: 20px;
height: 20px;
position: absolute;
transition: opacity 0.2s ease;
}
.theme-toggle .light-icon {
opacity: 0;
}
.theme-toggle .dark-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .light-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .dark-icon {
opacity: 0;
}
@media (max-width: 768px) {
.actions {
flex-wrap: wrap;
gap: var(--space-1);
}
.controls {
flex-direction: column;
gap: 15px;
.action-buttons {
flex-wrap: wrap;
gap: var(--space-1);
width: 100%;
}
.toggle-folders-container {
margin-left: 0;
width: 100%;
display: flex;
justify-content: flex-end;
}
.folder-tags-container {
order: -1;
}
.toggle-folders-btn {
position: absolute;
bottom: 0;
right: 0;
transform: none; /* 移除transform防止hover时的位移 */
}
.toggle-folders-btn:hover {
transform: none; /* 移动端下禁用hover效果 */
}
.folder-tags.collapsed + .toggle-folders-btn {
position: relative;
transform: none;
}
.corner-controls {
top: 10px;
right: 10px;
}
.corner-controls-items {
display: none;
}
.corner-controls.expanded .corner-controls-items {
display: flex;
}
.back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */

View File

@@ -5,6 +5,7 @@
@import 'layout.css';
/* Import Components */
@import 'components/header.css';
@import 'components/card.css';
@import 'components/modal.css';
@import 'components/download-modal.css';
@@ -16,6 +17,7 @@
@import 'components/support-modal.css';
@import 'components/search-filter.css';
@import 'components/bulk.css';
@import 'components/shared.css';
.initialization-notice {
display: flex;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{"name":"","short_name":"","icons":[{"src":"/loras_static/images/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/loras_static/images/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -1,65 +1,76 @@
import { state } from '../state/index.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { createLoraCard } from '../components/LoraCard.js';
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
import { showDeleteModal } from '../utils/modalUtils.js';
import { toggleFolder } from '../utils/uiHelpers.js';
export async function loadMoreLoras(boolUpdateFolders = false) {
if (state.isLoading || !state.hasMore) return;
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
const pageState = getCurrentPageState();
state.isLoading = true;
if (pageState.isLoading || (!pageState.hasMore && !resetPage)) return;
pageState.isLoading = true;
try {
// Reset to first page if requested
if (resetPage) {
pageState.currentPage = 1;
// Clear grid if resetting
const grid = document.getElementById('loraGrid');
if (grid) grid.innerHTML = '';
initializeInfiniteScroll();
}
const params = new URLSearchParams({
page: state.currentPage,
page: pageState.currentPage,
page_size: 20,
sort_by: state.sortBy
sort_by: pageState.sortBy
});
// 使用 state 中的 searchManager 获取递归搜索状态
const isRecursiveSearch = state.searchManager?.isRecursiveSearch ?? false;
if (state.activeFolder !== null) {
params.append('folder', state.activeFolder);
params.append('recursive', isRecursiveSearch.toString());
if (pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder);
}
// Add search parameters if there's a search term
const searchInput = document.getElementById('searchInput');
if (searchInput && searchInput.value.trim()) {
params.append('search', searchInput.value.trim());
if (pageState.filters?.search) {
params.append('search', pageState.filters.search);
params.append('fuzzy', 'true');
// Add search option parameters if available
if (pageState.searchOptions) {
params.append('search_filename', pageState.searchOptions.filename.toString());
params.append('search_modelname', pageState.searchOptions.modelname.toString());
params.append('search_tags', (pageState.searchOptions.tags || false).toString());
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
}
}
// Add filter parameters if active
if (state.filters) {
if (state.filters.tags && state.filters.tags.length > 0) {
if (pageState.filters) {
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
// Convert the array of tags to a comma-separated string
params.append('tags', state.filters.tags.join(','));
params.append('tags', pageState.filters.tags.join(','));
}
if (state.filters.baseModel && state.filters.baseModel.length > 0) {
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
// Convert the array of base models to a comma-separated string
params.append('base_models', state.filters.baseModel.join(','));
params.append('base_models', pageState.filters.baseModel.join(','));
}
}
console.log('Loading loras with params:', params.toString());
const response = await fetch(`/api/loras?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch loras: ${response.statusText}`);
}
const data = await response.json();
console.log('Received data:', data);
if (data.items.length === 0 && state.currentPage === 1) {
if (data.items.length === 0 && pageState.currentPage === 1) {
const grid = document.getElementById('loraGrid');
grid.innerHTML = '<div class="no-results">No loras found in this folder</div>';
state.hasMore = false;
pageState.hasMore = false;
} else if (data.items.length > 0) {
state.hasMore = state.currentPage < data.total_pages;
state.currentPage++;
pageState.hasMore = pageState.currentPage < data.total_pages;
pageState.currentPage++;
appendLoraCards(data.items);
const sentinel = document.getElementById('scroll-sentinel');
@@ -67,10 +78,10 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
state.observer.observe(sentinel);
}
} else {
state.hasMore = false;
pageState.hasMore = false;
}
if (boolUpdateFolders && data.folders) {
if (updateFolders && data.folders) {
updateFolderTags(data.folders);
}
@@ -78,7 +89,7 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
console.error('Error loading loras:', error);
showToast('Failed to load loras: ' + error.message, 'error');
} finally {
state.isLoading = false;
pageState.isLoading = false;
}
}
@@ -87,7 +98,8 @@ function updateFolderTags(folders) {
if (!folderTagsContainer) return;
// Keep track of currently selected folder
const currentFolder = state.activeFolder;
const pageState = getCurrentPageState();
const currentFolder = pageState.activeFolder;
// Create HTML for folder tags
const tagsHTML = folders.map(folder => {
@@ -260,31 +272,19 @@ export function appendLoraCards(loras) {
loras.forEach(lora => {
const card = createLoraCard(lora);
if (sentinel) {
grid.insertBefore(card, sentinel);
} else {
grid.appendChild(card);
}
grid.appendChild(card);
});
}
export async function resetAndReload(boolUpdateFolders = false) {
console.log('Resetting with state:', { ...state });
state.currentPage = 1;
state.hasMore = true;
state.isLoading = false;
const grid = document.getElementById('loraGrid');
grid.innerHTML = '';
const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel';
grid.appendChild(sentinel);
export async function resetAndReload(updateFolders = false) {
const pageState = getCurrentPageState();
console.log('Resetting with state:', { ...pageState });
// Initialize infinite scroll - will reset the observer
initializeInfiniteScroll();
await loadMoreLoras(boolUpdateFolders);
// Load more loras with reset flag
await loadMoreLoras(true, updateFolders);
}
export async function refreshLoras() {

36
static/js/checkpoints.js Normal file
View File

@@ -0,0 +1,36 @@
import { appCore } from './core.js';
import { state, initPageState } from './state/index.js';
// Initialize the Checkpoints page
class CheckpointsPageManager {
constructor() {
// Initialize any necessary state
this.initialized = false;
}
async initialize() {
if (this.initialized) return;
// Initialize page state
initPageState('checkpoints');
// Initialize core application
await appCore.initialize();
// Initialize page-specific components
this._initializeWorkInProgress();
this.initialized = true;
}
_initializeWorkInProgress() {
// Add any work-in-progress specific initialization here
console.log('Checkpoints Manager is under development');
}
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
const checkpointsPage = new CheckpointsPageManager();
await checkpointsPage.initialize();
});

View File

@@ -1,6 +1,7 @@
import { refreshSingleLoraMetadata } from '../api/loraApi.js';
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { getStorageItem } from '../utils/storageHelpers.js';
export class LoraContextMenu {
constructor() {
@@ -149,7 +150,7 @@ export class LoraContextMenu {
updateCardBlurEffect(card, level) {
// Get user settings for blur threshold
const blurThreshold = parseInt(localStorage.getItem('nsfwBlurLevel') || '4');
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
// Get card preview container
const previewContainer = card.querySelector('.card-preview');

View File

@@ -0,0 +1,82 @@
import { updateService } from '../managers/UpdateService.js';
import { toggleTheme } from '../utils/uiHelpers.js';
import { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js';
import { initPageState } from '../state/index.js';
/**
* Header.js - Manages the application header behavior across different pages
* Handles initialization of appropriate search and filter managers based on current page
*/
export class HeaderManager {
constructor() {
this.currentPage = this.detectCurrentPage();
initPageState(this.currentPage);
this.searchManager = null;
this.filterManager = null;
// Initialize appropriate managers based on current page
this.initializeManagers();
// Set up common header functionality
this.initializeCommonElements();
}
detectCurrentPage() {
const path = window.location.pathname;
if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}
initializeManagers() {
// Initialize SearchManager for all page types
this.searchManager = new SearchManager({ page: this.currentPage });
window.searchManager = this.searchManager;
// Initialize FilterManager for all page types that have filters
if (document.getElementById('filterButton')) {
this.filterManager = new FilterManager({ page: this.currentPage });
window.filterManager = this.filterManager;
}
}
initializeCommonElements() {
// Handle theme toggle
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
if (typeof toggleTheme === 'function') {
toggleTheme();
}
});
}
// Handle settings toggle
const settingsToggle = document.querySelector('.settings-toggle');
if (settingsToggle) {
settingsToggle.addEventListener('click', () => {
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
});
}
// Handle update toggle
const updateToggle = document.getElementById('updateToggleBtn');
if (updateToggle) {
updateToggle.addEventListener('click', () => {
updateService.toggleUpdateModal();
});
}
// Handle support toggle
const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) {
supportToggle.addEventListener('click', () => {
// Handle support panel logic
});
}
}
}

View File

@@ -1,6 +1,7 @@
import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { modalManager } from '../managers/ModalManager.js';
import { NSFW_LEVELS, BASE_MODELS } from '../utils/constants.js';
export function showLoraModal(lora) {
const escapedWords = lora.civitai?.trainedWords?.length ?
@@ -10,10 +11,10 @@ export function showLoraModal(lora) {
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('loraModal')">&times;</button>
<header class="modal-header">
<div class="editable-field model-name-field">
<div class="model-name-header">
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${lora.model_name}</h2>
<button class="save-btn" onclick="saveModelName('${lora.file_path}')">
<i class="fas fa-save"></i>
<button class="edit-model-name-btn" title="Edit model name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
${renderCompactTags(lora.tags || [])}
@@ -28,9 +29,11 @@ export function showLoraModal(lora) {
</div>
<div class="info-item">
<label>File Name</label>
<div class="file-name-wrapper" onclick="copyFileName('${lora.file_name}')">
<span id="file-name">${lora.file_name || 'N/A'}</span>
<i class="fas fa-copy" title="Copy file name"></i>
<div class="file-name-wrapper">
<span id="file-name" class="file-name-content">${lora.file_name || 'N/A'}</span>
<button class="edit-file-name-btn" title="Edit file name">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="info-item location-size">
@@ -42,7 +45,12 @@ export function showLoraModal(lora) {
<div class="info-item base-size">
<div class="base-wrapper">
<label>Base Model</label>
<span>${lora.base_model || 'N/A'}</span>
<div class="base-model-display">
<span class="base-model-content">${lora.base_model || 'N/A'}</span>
<button class="edit-base-model-btn" title="Edit base model">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
</div>
<div class="size-wrapper">
<label>Size</label>
@@ -122,6 +130,9 @@ export function showLoraModal(lora) {
setupTabSwitching();
setupTagTooltip();
setupTriggerWordsEditMode();
setupModelNameEditing();
setupBaseModelEditing();
setupFileNameEditing();
// If we have a model ID but no description, fetch it
if (lora.civitai?.modelId && !lora.modelDescription) {
@@ -200,61 +211,165 @@ function renderShowcaseContent(images) {
nsfwText = "R-rated Content";
}
if (img.type === 'video') {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" data-src="${img.url}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-src="${img.url}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
</div>
`;
}
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-src="${img.url}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${img.width}"
height="${img.height}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
// Extract metadata from the image
const meta = img.meta || {};
const prompt = meta.prompt || '';
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
const size = meta.Size || `${img.width}x${img.height}`;
const seed = meta.seed || '';
const model = meta.Model || '';
const steps = meta.steps || '';
const sampler = meta.sampler || '';
const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt;
// If no metadata available, show a message
if (!hasParams && !hasPrompts) {
const metadataPanel = `
<div class="image-metadata-panel">
<div class="metadata-content">
<div class="no-metadata-message">
<i class="fas fa-info-circle"></i>
<span>No generation parameters available</span>
</div>
</div>
` : ''}
</div>
`;
if (img.type === 'video') {
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
}
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
}
// Create a data attribute with the prompt for copying instead of trying to handle it in the onclick
// This avoids issues with quotes and special characters
const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15);
// Create parameter tags HTML
const paramTags = `
<div class="params-tags">
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
</div>
`;
// Metadata panel HTML
const metadataPanel = `
<div class="image-metadata-panel">
<div class="metadata-content">
${hasParams ? paramTags : ''}
${!hasParams && !hasPrompts ? `
<div class="no-metadata-message">
<i class="fas fa-info-circle"></i>
<span>No generation parameters available</span>
</div>
` : ''}
${prompt ? `
<div class="metadata-row prompt-row">
<span class="metadata-label">Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${prompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
` : ''}
${negativePrompt ? `
<div class="metadata-row prompt-row">
<span class="metadata-label">Negative Prompt:</span>
<div class="metadata-prompt-wrapper">
<div class="metadata-prompt">${negativePrompt}</div>
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
` : ''}
</div>
</div>
`;
if (img.type === 'video') {
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
}
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
}).join('')}
</div>
</div>
`;
}
// Helper function to generate video wrapper HTML
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" data-src="${img.url}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-src="${img.url}" type="video/mp4">
Your browser does not support video playback
</video>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}
// Helper function to generate image wrapper HTML
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-src="${img.url}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${img.width}"
height="${img.height}"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
${metadataPanel}
</div>
`;
}
// New function to handle tab switching
function setupTabSwitching() {
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
@@ -405,61 +520,18 @@ function setupEditableFields() {
editableFields.forEach(field => {
field.addEventListener('focus', function() {
if (this.textContent === 'Add your notes here...' ||
this.textContent === 'Save usage tips here..') {
if (this.textContent === 'Add your notes here...') {
this.textContent = '';
}
});
field.addEventListener('blur', function() {
if (this.textContent.trim() === '') {
if (this.classList.contains('model-name-content')) {
// Restore original model name if empty
const filePath = document.querySelector('.modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
this.textContent = loraCard.dataset.model_name;
}
} else if (this.classList.contains('usage-tips-content')) {
this.textContent = 'Save usage tips here..';
} else {
if (this.classList.contains('notes-content')) {
this.textContent = 'Add your notes here...';
}
}
});
// Add input validation for model name
if (field.classList.contains('model-name-content')) {
field.addEventListener('input', function() {
// Limit model name length
if (this.textContent.length > 100) {
this.textContent = this.textContent.substring(0, 100);
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.setStart(this.childNodes[0], 100);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
showToast('Model name is limited to 100 characters', 'warning');
}
});
field.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const filePath = document.querySelector('.modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
.querySelector('#file-name').textContent + '.safetensors';
saveModelName(filePath);
}
});
}
});
const presetSelector = document.getElementById('preset-selector');
@@ -471,9 +543,9 @@ function setupEditableFields() {
const selected = this.value;
if (selected) {
presetValue.style.display = 'inline-block';
presetValue.min = selected.includes('strength') ? 0 : 1;
presetValue.max = selected.includes('strength') ? 1 : 12;
presetValue.step = selected.includes('strength') ? 0.01 : 1;
presetValue.min = selected.includes('strength') ? -10 : 0;
presetValue.max = selected.includes('strength') ? 10 : 10;
presetValue.step = 0.5;
if (selected === 'clip_skip') {
presetValue.type = 'number';
presetValue.step = 1;
@@ -491,10 +563,10 @@ function setupEditableFields() {
if (!key || !value) return;
const filePath = document.querySelector('.modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('#loraModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
@@ -524,9 +596,9 @@ function setupEditableFields() {
return;
}
e.preventDefault();
const filePath = document.querySelector('.modal-content')
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
document.querySelector('#loraModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
await saveNotes(filePath);
}
@@ -661,13 +733,74 @@ export function toggleShowcase(element) {
// Initialize NSFW content blur toggle handlers
initNsfwBlurHandlers(carousel);
// Initialize metadata panel interaction handlers
initMetadataPanelHandlers(carousel);
} else {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`;
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
// Make sure any open metadata panels get closed
const carouselContainer = carousel.querySelector('.carousel-container');
if (carouselContainer) {
carouselContainer.style.height = '0';
setTimeout(() => {
carouselContainer.style.height = '';
}, 300);
}
}
}
// Function to initialize metadata panel interactions
function initMetadataPanelHandlers(container) {
// Find all media wrappers
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
// Prevent events from the metadata panel from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt button clicks
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation(); // Prevent bubbling
if (!promptElement) return;
try {
await navigator.clipboard.writeText(promptElement.textContent);
showToast('Prompt copied to clipboard', 'success');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
});
// Prevent scrolling in the metadata panel from scrolling the whole modal
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
});
}
// New function to initialize blur toggle handlers for showcase images/videos
function initNsfwBlurHandlers(container) {
// Handle toggle blur buttons
@@ -747,9 +880,10 @@ function initLazyLoading(container) {
}
export function setupShowcaseScroll() {
// Change from modal-content to window/document level
// Add event listener to document for wheel events
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector('.modal-content');
// Find the active modal content
const modalContent = document.querySelector('#loraModal .modal-content');
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
@@ -766,24 +900,52 @@ export function setupShowcaseScroll() {
event.preventDefault();
}
}
}, { passive: false }); // Add passive: false option here
}, { passive: false });
// Keep the existing scroll tracking code
const modalContent = document.querySelector('.modal-content');
if (modalContent) {
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
// Use MutationObserver instead of deprecated DOMNodeInserted
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
// Check if loraModal content was added
const loraModal = document.getElementById('loraModal');
if (loraModal && loraModal.querySelector('.modal-content')) {
setupBackToTopButton(loraModal.querySelector('.modal-content'));
}
}
});
}
});
// Start observing the document body for changes
observer.observe(document.body, { childList: true, subtree: true });
// Also try to set up the button immediately in case the modal is already open
const modalContent = document.querySelector('#loraModal .modal-content');
if (modalContent) {
setupBackToTopButton(modalContent);
}
}
// New helper function to set up the back to top button
function setupBackToTopButton(modalContent) {
// Remove any existing scroll listeners to avoid duplicates
modalContent.onscroll = null;
// Add new scroll listener
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
}
});
// Trigger a scroll event to check initial position
modalContent.dispatchEvent(new Event('scroll'));
}
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
@@ -795,7 +957,7 @@ export function scrollToTop(button) {
}
function parsePresets(usageTips) {
if (!usageTips || usageTips === 'Save usage tips here..') return {};
if (!usageTips) return {};
try {
return JSON.parse(usageTips);
} catch {
@@ -819,9 +981,9 @@ function formatPresetKey(key) {
}
window.removePreset = async function(key) {
const filePath = document.querySelector('.modal-content')
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
document.querySelector('#loraModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
@@ -852,17 +1014,6 @@ function formatFileSize(bytes) {
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
// Add tag copy functionality
window.copyTag = async function(tag) {
try {
await navigator.clipboard.writeText(tag);
showToast('Tag copied to clipboard', 'success');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
};
// New function to render compact tags with tooltip
function renderCompactTags(tags) {
if (!tags || tags.length === 0) return '';
@@ -1161,4 +1312,422 @@ window.copyTriggerWord = async function(word) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
};
};
// New function to handle model name editing
function setupModelNameEditing() {
const modelNameContent = document.querySelector('.model-name-content');
const editBtn = document.querySelector('.edit-model-name-btn');
if (!modelNameContent || !editBtn) return;
// Show edit button on hover
const modelNameHeader = document.querySelector('.model-name-header');
modelNameHeader.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
modelNameHeader.addEventListener('mouseleave', () => {
if (!modelNameContent.getAttribute('data-editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
modelNameContent.setAttribute('data-editing', 'true');
modelNameContent.focus();
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
if (modelNameContent.childNodes.length > 0) {
range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
editBtn.classList.add('visible');
});
// Handle focus out
modelNameContent.addEventListener('blur', function() {
this.removeAttribute('data-editing');
editBtn.classList.remove('visible');
if (this.textContent.trim() === '') {
// Restore original model name if empty
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('#loraModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
this.textContent = loraCard.dataset.model_name;
}
}
});
// Handle enter key
modelNameContent.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('#loraModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
saveModelName(filePath);
this.blur();
}
});
// Limit model name length
modelNameContent.addEventListener('input', function() {
// Limit model name length
if (this.textContent.length > 100) {
this.textContent = this.textContent.substring(0, 100);
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.setStart(this.childNodes[0], 100);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
showToast('Model name is limited to 100 characters', 'warning');
}
});
}
// Add save model base model function
window.saveBaseModel = async function(filePath, originalValue) {
const baseModelElement = document.querySelector('.base-model-content');
const newBaseModel = baseModelElement.textContent.trim();
// Only save if the value has actually changed
if (newBaseModel === originalValue) {
return; // No change, no need to save
}
try {
await saveModelMetadata(filePath, { base_model: newBaseModel });
// Update the corresponding lora card's dataset
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
loraCard.dataset.base_model = newBaseModel;
}
showToast('Base model updated successfully', 'success');
} catch (error) {
showToast('Failed to update base model', 'error');
}
};
// New function to handle base model editing
function setupBaseModelEditing() {
const baseModelContent = document.querySelector('.base-model-content');
const editBtn = document.querySelector('.edit-base-model-btn');
if (!baseModelContent || !editBtn) return;
// Show edit button on hover
const baseModelDisplay = document.querySelector('.base-model-display');
baseModelDisplay.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
baseModelDisplay.addEventListener('mouseleave', () => {
if (!baseModelDisplay.classList.contains('editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
baseModelDisplay.classList.add('editing');
// Store the original value to check for changes later
const originalValue = baseModelContent.textContent.trim();
// Create dropdown selector to replace the base model content
const currentValue = originalValue;
const dropdown = document.createElement('select');
dropdown.className = 'base-model-selector';
// Flag to track if a change was made
let valueChanged = false;
// Add options from BASE_MODELS constants
const baseModelCategories = {
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.UNKNOWN
]
};
// Create option groups for better organization
Object.entries(baseModelCategories).forEach(([category, models]) => {
const group = document.createElement('optgroup');
group.label = category;
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
option.selected = model === currentValue;
group.appendChild(option);
});
dropdown.appendChild(group);
});
// Replace content with dropdown
baseModelContent.style.display = 'none';
baseModelDisplay.insertBefore(dropdown, editBtn);
// Hide edit button during editing
editBtn.style.display = 'none';
// Focus the dropdown
dropdown.focus();
// Handle dropdown change
dropdown.addEventListener('change', function() {
const selectedModel = this.value;
baseModelContent.textContent = selectedModel;
// Mark that a change was made if the value differs from original
if (selectedModel !== originalValue) {
valueChanged = true;
} else {
valueChanged = false;
}
});
// Function to save changes and exit edit mode
const saveAndExit = function() {
// Check if dropdown still exists and remove it
if (dropdown && dropdown.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(dropdown);
}
// Show the content and edit button
baseModelContent.style.display = '';
editBtn.style.display = '';
// Remove editing class
baseModelDisplay.classList.remove('editing');
// Only save if the value has actually changed
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
// Get file path for saving
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent +
document.querySelector('#loraModal .modal-content')
.querySelector('#file-name').textContent + '.safetensors';
// Save the changes, passing the original value for comparison
saveBaseModel(filePath, originalValue);
}
// Remove this event listener
document.removeEventListener('click', outsideClickHandler);
};
// Handle outside clicks to save and exit
const outsideClickHandler = function(e) {
// If click is outside the dropdown and base model display
if (!baseModelDisplay.contains(e.target)) {
saveAndExit();
}
};
// Add delayed event listener for outside clicks
setTimeout(() => {
document.addEventListener('click', outsideClickHandler);
}, 0);
// Also handle dropdown blur event
dropdown.addEventListener('blur', function(e) {
// Only save if the related target is not the edit button or inside the baseModelDisplay
if (!baseModelDisplay.contains(e.relatedTarget)) {
saveAndExit();
}
});
});
}
// New function to handle file name editing
function setupFileNameEditing() {
const fileNameContent = document.querySelector('.file-name-content');
const editBtn = document.querySelector('.edit-file-name-btn');
if (!fileNameContent || !editBtn) return;
// Show edit button on hover
const fileNameWrapper = document.querySelector('.file-name-wrapper');
fileNameWrapper.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
fileNameWrapper.addEventListener('mouseleave', () => {
if (!fileNameWrapper.classList.contains('editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
fileNameWrapper.classList.add('editing');
fileNameContent.setAttribute('contenteditable', 'true');
fileNameContent.focus();
// Store original value for comparison later
fileNameContent.dataset.originalValue = fileNameContent.textContent.trim();
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(fileNameContent);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
editBtn.classList.add('visible');
});
// Handle keyboard events in edit mode
fileNameContent.addEventListener('keydown', function(e) {
if (!this.getAttribute('contenteditable')) return;
if (e.key === 'Enter') {
e.preventDefault();
this.blur(); // Trigger save on Enter
} else if (e.key === 'Escape') {
e.preventDefault();
// Restore original value
this.textContent = this.dataset.originalValue;
exitEditMode();
}
});
// Handle input validation
fileNameContent.addEventListener('input', function() {
if (!this.getAttribute('contenteditable')) return;
// Replace invalid characters for filenames
const invalidChars = /[\\/:*?"<>|]/g;
if (invalidChars.test(this.textContent)) {
const cursorPos = window.getSelection().getRangeAt(0).startOffset;
this.textContent = this.textContent.replace(invalidChars, '');
// Restore cursor position
const range = document.createRange();
const sel = window.getSelection();
const newPos = Math.min(cursorPos, this.textContent.length);
if (this.firstChild) {
range.setStart(this.firstChild, newPos);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
showToast('Invalid characters removed from filename', 'warning');
}
});
// Handle focus out - save changes
fileNameContent.addEventListener('blur', async function() {
if (!this.getAttribute('contenteditable')) return;
const newFileName = this.textContent.trim();
const originalValue = this.dataset.originalValue;
// Basic validation
if (!newFileName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('File name cannot be empty', 'error');
exitEditMode();
return;
}
if (newFileName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Get the full file path
const filePath = document.querySelector('#loraModal .modal-content')
.querySelector('.file-path').textContent + originalValue + '.safetensors';
// Call API to rename the file
const response = await fetch('/api/rename_lora', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
new_file_name: newFileName
})
});
const result = await response.json();
if (result.success) {
showToast('File name updated successfully', 'success');
// Update card in the gallery
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
// Update the card's filepath attribute to the new path
loraCard.dataset.filepath = result.new_file_path;
loraCard.dataset.file_name = newFileName;
// Update the filename display in the card
const cardFileName = loraCard.querySelector('.card-filename');
if (cardFileName) {
cardFileName.textContent = newFileName;
}
}
// Handle the case where we need to reload the page
if (result.reload_required) {
showToast('Reloading page to apply changes...', 'info');
setTimeout(() => {
window.location.reload();
}, 1500);
}
} else {
// Show error and restore original filename
showToast(result.error || 'Failed to update file name', 'error');
this.textContent = originalValue;
}
} catch (error) {
console.error('Error saving filename:', error);
showToast('Failed to update file name', 'error');
this.textContent = originalValue;
} finally {
exitEditMode();
}
});
function exitEditMode() {
fileNameContent.removeAttribute('contenteditable');
fileNameWrapper.classList.remove('editing');
editBtn.classList.remove('visible');
}
}

View File

@@ -0,0 +1,282 @@
// Recipe Card Component
import { showToast } from '../utils/uiHelpers.js';
import { modalManager } from '../managers/ModalManager.js';
class RecipeCard {
constructor(recipe, clickHandler) {
this.recipe = recipe;
this.clickHandler = clickHandler;
this.element = this.createCardElement();
}
createCardElement() {
const card = document.createElement('div');
card.className = 'lora-card';
card.dataset.filePath = this.recipe.file_path;
card.dataset.title = this.recipe.title;
card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || '';
// Get base model
const baseModel = this.recipe.base_model || '';
// Ensure loras array exists
const loras = this.recipe.loras || [];
const lorasCount = loras.length;
// Check if all LoRAs are available in the library
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
// Ensure file_url exists, fallback to file_path if needed
const imageUrl = this.recipe.file_url ||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png');
card.innerHTML = `
<div class="recipe-indicator" title="Recipe">R</div>
<div class="card-preview">
<img src="${imageUrl}" alt="${this.recipe.title}">
<div class="card-header">
<div class="base-model-wrapper">
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
</div>
<div class="card-actions">
<i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
<i class="fas fa-trash" title="Delete Recipe"></i>
</div>
</div>
<div class="card-footer">
<div class="model-info">
<span class="model-name">${this.recipe.title}</span>
</div>
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
<i class="fas fa-layer-group"></i> ${lorasCount}
</div>
</div>
</div>
`;
this.attachEventListeners(card);
return card;
}
getLoraStatusTitle(totalCount, missingCount) {
if (totalCount === 0) return "No LoRAs in this recipe";
if (missingCount === 0) return "All LoRAs available - Ready to use";
return `${missingCount} of ${totalCount} LoRAs missing`;
}
attachEventListeners(card) {
// Recipe card click event
card.addEventListener('click', () => {
this.clickHandler(this.recipe);
});
// Share button click event - prevent propagation to card
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
e.stopPropagation();
this.shareRecipe();
});
// Copy button click event - prevent propagation to card
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
e.stopPropagation();
this.copyRecipeSyntax();
});
// Delete button click event - prevent propagation to card
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
e.stopPropagation();
this.showDeleteConfirmation();
});
}
copyRecipeSyntax() {
try {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
return;
}
// Fallback if button not found
fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
return navigator.clipboard.writeText(data.syntax);
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.then(() => {
showToast('Recipe syntax copied to clipboard', 'success');
})
.catch(err => {
console.error('Failed to copy: ', err);
showToast('Failed to copy recipe syntax', 'error');
});
} catch (error) {
console.error('Error copying recipe syntax:', error);
showToast('Error copying recipe syntax', 'error');
}
}
showDeleteConfirmation() {
try {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('Cannot delete recipe: Missing recipe ID', 'error');
return;
}
// Create delete modal content
const deleteModalContent = `
<div class="modal-content delete-modal-content">
<h2>Delete Recipe</h2>
<p class="delete-message">Are you sure you want to delete this recipe?</p>
<div class="delete-model-info">
<div class="delete-preview">
<img src="${this.recipe.file_url || '/loras_static/images/no-preview.png'}" alt="${this.recipe.title}">
</div>
<div class="delete-info">
<h3>${this.recipe.title}</h3>
<p>This action cannot be undone.</p>
</div>
</div>
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
<div class="modal-actions">
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
</div>
</div>
`;
// Show the modal with custom content and setup callbacks
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete';
deleteBtn.disabled = false;
});
// Set up the delete and cancel buttons with proper event handlers
const deleteModal = document.getElementById('deleteModal');
const cancelBtn = deleteModal.querySelector('.cancel-btn');
const deleteBtn = deleteModal.querySelector('.delete-btn');
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('Error showing delete confirmation', 'error');
}
}
confirmDeleteRecipe() {
const deleteModal = document.getElementById('deleteModal');
const recipeId = deleteModal.dataset.recipeId;
if (!recipeId) {
showToast('Cannot delete recipe: Missing recipe ID', 'error');
modalManager.closeModal('deleteModal');
return;
}
// Show loading state
const deleteBtn = deleteModal.querySelector('.delete-btn');
const originalText = deleteBtn.textContent;
deleteBtn.textContent = 'Deleting...';
deleteBtn.disabled = true;
// Call API to delete the recipe
fetch(`/api/recipe/${recipeId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete recipe');
}
return response.json();
})
.then(data => {
showToast('Recipe deleted successfully', 'success');
window.recipeManager.loadRecipes();
modalManager.closeModal('deleteModal');
})
.catch(error => {
console.error('Error deleting recipe:', error);
showToast('Error deleting recipe: ' + error.message, 'error');
// Reset button state
deleteBtn.textContent = originalText;
deleteBtn.disabled = false;
});
}
shareRecipe() {
try {
// Get recipe ID
const recipeId = this.recipe.id;
if (!recipeId) {
showToast('Cannot share recipe: Missing recipe ID', 'error');
return;
}
// Show loading toast
showToast('Preparing recipe for sharing...', 'info');
// Call the API to process the image with metadata
fetch(`/api/recipe/${recipeId}/share`)
.then(response => {
if (!response.ok) {
throw new Error('Failed to prepare recipe for sharing');
}
return response.json();
})
.then(data => {
if (!data.success) {
throw new Error(data.error || 'Unknown error');
}
// Create a temporary anchor element for download
const downloadLink = document.createElement('a');
downloadLink.href = data.download_url;
downloadLink.download = data.filename;
// Append to body, click and remove
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
showToast('Recipe download started', 'success');
})
.catch(error => {
console.error('Error sharing recipe:', error);
showToast('Error sharing recipe: ' + error.message, 'error');
});
} catch (error) {
console.error('Error sharing recipe:', error);
showToast('Error preparing recipe for sharing', 'error');
}
}
}
export { RecipeCard };

File diff suppressed because it is too large Load Diff

79
static/js/core.js Normal file
View File

@@ -0,0 +1,79 @@
// Core application functionality
import { state } from './state/index.js';
import { LoadingManager } from './managers/LoadingManager.js';
import { modalManager } from './managers/ModalManager.js';
import { updateService } from './managers/UpdateService.js';
import { HeaderManager } from './components/Header.js';
import { SettingsManager } from './managers/SettingsManager.js';
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
// Core application class
export class AppCore {
constructor() {
this.initialized = false;
}
// Initialize core functionality
async initialize() {
if (this.initialized) return;
console.log('AppCore: Initializing...');
// Initialize managers
state.loadingManager = new LoadingManager();
modalManager.initialize();
updateService.initialize();
window.modalManager = modalManager;
window.settingsManager = new SettingsManager();
// Initialize UI components
window.headerManager = new HeaderManager();
initTheme();
initBackToTop();
// Mark as initialized
this.initialized = true;
// Return the core instance for chaining
return this;
}
// Get the current page type
getPageType() {
const body = document.body;
return body.dataset.page || 'unknown';
}
// Show toast messages
showToast(message, type = 'info') {
showToast(message, type);
}
// Initialize common UI features based on page type
initializePageFeatures() {
const pageType = this.getPageType();
// Initialize lazy loading for images on all pages
lazyLoadImages();
// Initialize infinite scroll for pages that need it
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
initializeInfiniteScroll(pageType);
}
return this;
}
}
document.addEventListener('DOMContentLoaded', () => {
// Migrate localStorage items to use the namespace prefix
migrateStorageItems();
});
// Create and export a singleton instance
export const appCore = new AppCore();
// Export common utilities for global use
export { showToast, lazyLoadImages, initializeInfiniteScroll };

123
static/js/loras.js Normal file
View File

@@ -0,0 +1,123 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
import { showLoraModal, toggleShowcase, scrollToTop } from './components/LoraModal.js';
import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js';
import {
restoreFolderFilter,
toggleFolder,
copyTriggerWord,
openCivitai,
toggleFolderTags,
initFolderTagsVisibility,
} from './utils/uiHelpers.js';
import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
import { DownloadManager } from './managers/DownloadManager.js';
import { toggleApiKeyVisibility } from './managers/SettingsManager.js';
import { LoraContextMenu } from './components/ContextMenu.js';
import { moveManager } from './managers/MoveManager.js';
import { updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
import { setStorageItem, getStorageItem } from './utils/storageHelpers.js';
// Initialize the LoRA page
class LoraPageManager {
constructor() {
// Add bulk mode to state
state.bulkMode = false;
state.selectedLoras = new Set();
// Initialize managers
this.downloadManager = new DownloadManager();
// Expose necessary functions to the page
this._exposeGlobalFunctions();
}
_exposeGlobalFunctions() {
// Only expose what's needed for the page
window.loadMoreLoras = loadMoreLoras;
window.fetchCivitai = fetchCivitai;
window.deleteModel = deleteModel;
window.replacePreview = replacePreview;
window.toggleFolder = toggleFolder;
window.copyTriggerWord = copyTriggerWord;
window.showLoraModal = showLoraModal;
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.refreshLoras = refreshLoras;
window.openCivitai = openCivitai;
window.toggleFolderTags = toggleFolderTags;
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
window.downloadManager = this.downloadManager;
window.moveManager = moveManager;
window.toggleShowcase = toggleShowcase;
window.scrollToTop = scrollToTop;
// Bulk operations
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
window.clearSelection = () => bulkManager.clearSelection();
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager;
}
async initialize() {
// Initialize page-specific components
this.initEventListeners();
restoreFolderFilter();
initFolderTagsVisibility();
new LoraContextMenu();
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize the bulk manager
bulkManager.initialize();
// Initialize common page features (lazy loading, infinite scroll)
appCore.initializePageFeatures();
}
loadSortPreference() {
const savedSort = getStorageItem('loras_sort');
if (savedSort) {
state.sortBy = savedSort;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = savedSort;
}
}
}
saveSortPreference(sortValue) {
setStorageItem('loras_sort', sortValue);
}
initEventListeners() {
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = state.sortBy;
this.loadSortPreference();
sortSelect.addEventListener('change', async (e) => {
state.sortBy = e.target.value;
this.saveSortPreference(e.target.value);
await resetAndReload();
});
}
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
tag.addEventListener('click', toggleFolder);
});
}
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
// Initialize core application
await appCore.initialize();
// Initialize page-specific functionality
const loraPage = new LoraPageManager();
await loraPage.initialize();
});

View File

@@ -1,115 +0,0 @@
import { debounce } from './utils/debounce.js';
import { LoadingManager } from './managers/LoadingManager.js';
import { modalManager } from './managers/ModalManager.js';
import { updateService } from './managers/UpdateService.js';
import { state, initSettings } from './state/index.js';
import { showLoraModal } from './components/LoraModal.js';
import { toggleShowcase, scrollToTop } from './components/LoraModal.js';
import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js';
import {
showToast,
lazyLoadImages,
restoreFolderFilter,
initTheme,
toggleTheme,
toggleFolder,
copyTriggerWord,
openCivitai,
toggleFolderTags,
initFolderTagsVisibility,
initBackToTop
} from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { showDeleteModal, confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
import { SearchManager } from './utils/search.js';
import { DownloadManager } from './managers/DownloadManager.js';
import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js';
import { LoraContextMenu } from './components/ContextMenu.js';
import { moveManager } from './managers/MoveManager.js';
import { FilterManager } from './managers/FilterManager.js';
import { createLoraCard, updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
// Add bulk mode to state
state.bulkMode = false;
state.selectedLoras = new Set();
// Export functions to global window object
window.loadMoreLoras = loadMoreLoras;
window.fetchCivitai = fetchCivitai;
window.deleteModel = deleteModel;
window.replacePreview = replacePreview;
window.toggleTheme = toggleTheme;
window.toggleFolder = toggleFolder;
window.copyTriggerWord = copyTriggerWord;
window.showLoraModal = showLoraModal;
window.modalManager = modalManager;
window.state = state;
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.refreshLoras = refreshLoras;
window.openCivitai = openCivitai;
window.showToast = showToast
window.toggleFolderTags = toggleFolderTags;
window.settingsManager = new SettingsManager();
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
window.moveManager = moveManager;
window.toggleShowcase = toggleShowcase;
window.scrollToTop = scrollToTop;
// Export bulk manager methods to window
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
window.clearSelection = () => bulkManager.clearSelection();
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager;
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
// Ensure settings are initialized
initSettings();
state.loadingManager = new LoadingManager();
modalManager.initialize(); // Initialize modalManager after DOM is loaded
updateService.initialize(); // Initialize updateService after modalManager
window.downloadManager = new DownloadManager(); // Move this after modalManager initialization
window.filterManager = new FilterManager(); // Initialize filter manager
// Initialize state filters from filterManager if available
if (window.filterManager && window.filterManager.filters) {
state.filters = { ...window.filterManager.filters };
}
initializeInfiniteScroll();
initializeEventListeners();
lazyLoadImages();
restoreFolderFilter();
initTheme();
initFolderTagsVisibility();
initBackToTop();
window.searchManager = new SearchManager();
new LoraContextMenu();
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize the bulk manager
bulkManager.initialize();
});
// Initialize event listeners
function initializeEventListeners() {
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.value = state.sortBy;
sortSelect.addEventListener('change', async (e) => {
state.sortBy = e.target.value;
await resetAndReload();
});
}
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
tag.addEventListener('click', toggleFolder);
});
}

View File

@@ -91,16 +91,17 @@ export class BulkManager {
// Set text content without the icon
countElement.textContent = `${state.selectedLoras.size} selected `;
// Re-add the caret icon with proper direction
const caretIcon = document.createElement('i');
// Use down arrow if strip is visible, up arrow if not
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
countElement.appendChild(caretIcon);
// If there are no selections, hide the thumbnail strip
if (state.selectedLoras.size === 0) {
this.hideThumbnailStrip();
// Update caret icon if it exists
const existingCaret = countElement.querySelector('.dropdown-caret');
if (existingCaret) {
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
existingCaret.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
} else {
// Create new caret icon if it doesn't exist
const caretIcon = document.createElement('i');
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
countElement.appendChild(caretIcon);
}
}
}
@@ -252,12 +253,20 @@ export class BulkManager {
hideThumbnailStrip() {
const strip = document.querySelector('.selected-thumbnails-strip');
if (strip) {
if (strip && this.isStripVisible) { // Only hide if actually visible
strip.classList.remove('visible');
// Update strip visibility state and caret direction
// Update strip visibility state
this.isStripVisible = false;
this.updateSelectedCount(); // Update caret
// Update caret without triggering another hide
const countElement = document.getElementById('selectedCount');
if (countElement) {
const caret = countElement.querySelector('.dropdown-caret');
if (caret) {
caret.className = 'fas fa-caret-up dropdown-caret';
}
}
// Wait for animation to complete before removing
setTimeout(() => {
@@ -340,4 +349,4 @@ export class BulkManager {
}
// Create a singleton instance
export const bulkManager = new BulkManager();
export const bulkManager = new BulkManager();

View File

@@ -0,0 +1,150 @@
/**
* CheckpointSearchManager - Specialized search manager for the Checkpoints page
* Extends the base SearchManager with checkpoint-specific functionality
*/
import { SearchManager } from './SearchManager.js';
import { state } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
export class CheckpointSearchManager extends SearchManager {
constructor(options = {}) {
super({
page: 'checkpoints',
...options
});
this.currentSearchTerm = '';
// Store this instance in the state
if (state) {
state.searchManager = this;
}
}
async performSearch() {
const searchTerm = this.searchInput.value.trim().toLowerCase();
if (searchTerm === this.currentSearchTerm && !this.isSearching) {
return; // Avoid duplicate searches
}
this.currentSearchTerm = searchTerm;
const grid = document.getElementById('checkpointGrid');
if (!searchTerm) {
if (state) {
state.currentPage = 1;
}
this.resetAndReloadCheckpoints();
return;
}
try {
this.isSearching = true;
if (state && state.loadingManager) {
state.loadingManager.showSimpleLoading('Searching checkpoints...');
}
// Store current scroll position
const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
if (state) {
state.currentPage = 1;
state.hasMore = true;
}
const url = new URL('/api/checkpoints', window.location.origin);
url.searchParams.set('page', '1');
url.searchParams.set('page_size', '20');
url.searchParams.set('sort_by', state ? state.sortBy : 'name');
url.searchParams.set('search', searchTerm);
url.searchParams.set('fuzzy', 'true');
// Add search options
const searchOptions = this.getActiveSearchOptions();
url.searchParams.set('search_filename', searchOptions.filename.toString());
url.searchParams.set('search_modelname', searchOptions.modelname.toString());
// Always send folder parameter if there is an active folder
if (state && state.activeFolder) {
url.searchParams.set('folder', state.activeFolder);
// Add recursive parameter when recursive search is enabled
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
url.searchParams.set('recursive', recursive.toString());
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
if (searchTerm === this.currentSearchTerm && grid) {
grid.innerHTML = '';
if (data.items.length === 0) {
grid.innerHTML = '<div class="no-results">No matching checkpoints found</div>';
if (state) {
state.hasMore = false;
}
} else {
this.appendCheckpointCards(data.items);
if (state) {
state.hasMore = state.currentPage < data.total_pages;
state.currentPage++;
}
}
// Restore scroll position after content is loaded
setTimeout(() => {
window.scrollTo({
top: scrollPosition,
behavior: 'instant' // Use 'instant' to prevent animation
});
}, 10);
}
} catch (error) {
console.error('Checkpoint search error:', error);
showToast('Checkpoint search failed', 'error');
} finally {
this.isSearching = false;
if (state && state.loadingManager) {
state.loadingManager.hide();
}
}
}
resetAndReloadCheckpoints() {
// This function would be implemented in the checkpoints page
if (typeof window.loadCheckpoints === 'function') {
window.loadCheckpoints();
} else {
// Fallback to reloading the page
window.location.reload();
}
}
appendCheckpointCards(checkpoints) {
// This function would be implemented in the checkpoints page
const grid = document.getElementById('checkpointGrid');
if (!grid) return;
if (typeof window.appendCheckpointCards === 'function') {
window.appendCheckpointCards(checkpoints);
} else {
// Fallback implementation
checkpoints.forEach(checkpoint => {
const card = document.createElement('div');
card.className = 'checkpoint-card';
card.innerHTML = `
<h3>${checkpoint.name}</h3>
<p>${checkpoint.filename || 'No filename'}</p>
`;
grid.appendChild(card);
});
}
}
}

View File

@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
import { LoadingManager } from './LoadingManager.js';
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { getStorageItem } from '../utils/storageHelpers.js';
export class DownloadManager {
constructor() {
this.currentVersion = null;
@@ -120,20 +120,42 @@ export class DownloadManager {
versionList.innerHTML = this.versions.map(version => {
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
const fileSize = (version.files[0]?.sizeKB / 1024).toFixed(2);
const existsLocally = version.files[0]?.existsLocally;
const localPath = version.files[0]?.localPath;
// Use version-level size or fallback to first file
const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) :
(version.files[0]?.sizeKB / 1024).toFixed(2);
// 更新本地状态指示器为badge样式
// Use version-level existsLocally flag
const existsLocally = version.existsLocally;
const localPath = version.localPath;
// Check if this is an early access version
const isEarlyAccess = version.availability === 'EarlyAccess';
// Create early access badge if needed
let earlyAccessBadge = '';
if (isEarlyAccess) {
earlyAccessBadge = `
<div class="early-access-badge" title="Early access required">
<i class="fas fa-clock"></i> Early Access
</div>
`;
}
console.log(earlyAccessBadge);
// Status badge for local models
const localStatus = existsLocally ?
`<div class="local-badge">
<i class="fas fa-check"></i> In Library
<div class="local-path">${localPath}</div>
<div class="local-path">${localPath || ''}</div>
</div>` : '';
return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''} ${existsLocally ? 'exists-locally' : ''}"
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}
${existsLocally ? 'exists-locally' : ''}
${isEarlyAccess ? 'is-early-access' : ''}"
onclick="downloadManager.selectVersion('${version.id}')">
<div class="version-thumbnail">
<img src="${thumbnailUrl}" alt="Version preview">
@@ -145,6 +167,7 @@ export class DownloadManager {
</div>
<div class="version-info">
${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''}
${earlyAccessBadge}
</div>
<div class="version-meta">
<span><i class="fas fa-calendar"></i> ${new Date(version.createdAt).toLocaleDateString()}</span>
@@ -177,12 +200,12 @@ export class DownloadManager {
this.updateNextButtonState();
}
// Add new method to update Next button state
// Update this method to use version-level existsLocally
updateNextButtonState() {
const nextButton = document.querySelector('#versionStep .primary-btn');
if (!nextButton) return;
const existsLocally = this.currentVersion?.files[0]?.existsLocally;
const existsLocally = this.currentVersion?.existsLocally;
if (existsLocally) {
nextButton.disabled = true;
@@ -202,7 +225,7 @@ export class DownloadManager {
}
// Double-check if the version exists locally
const existsLocally = this.currentVersion.files[0]?.existsLocally;
const existsLocally = this.currentVersion.existsLocally;
if (existsLocally) {
showToast('This version already exists in your library', 'info');
return;
@@ -223,6 +246,12 @@ export class DownloadManager {
`<option value="${root}">${root}</option>`
).join('');
// Set default lora root if available
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
if (defaultRoot && data.roots.includes(defaultRoot)) {
loraRoot.value = defaultRoot;
}
// Initialize folder browser after loading roots
this.initializeFolderBrowser();
} catch (error) {
@@ -265,19 +294,37 @@ export class DownloadManager {
throw new Error('No download URL available');
}
// Show loading with progress bar for download
this.loadingManager.show('Downloading LoRA...', 0);
// Show enhanced loading with progress details
const updateProgress = this.loadingManager.showDownloadProgress(1);
updateProgress(0, 0, this.currentVersion.name);
// Setup WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'progress') {
this.loadingManager.setProgress(data.progress);
this.loadingManager.setStatus(`Downloading: ${data.progress}%`);
// Update progress display with current progress
updateProgress(data.progress, 0, this.currentVersion.name);
// Add more detailed status messages based on progress
if (data.progress < 3) {
this.loadingManager.setStatus(`Preparing download...`);
} else if (data.progress === 3) {
this.loadingManager.setStatus(`Downloaded preview image`);
} else if (data.progress > 3 && data.progress < 100) {
this.loadingManager.setStatus(`Downloading LoRA file`);
} else {
this.loadingManager.setStatus(`Finalizing download...`);
}
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
// Continue with download even if WebSocket fails
};
// Start download
const response = await fetch('/api/download-lora', {

View File

@@ -1,11 +1,19 @@
import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js';
import { state } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { resetAndReload } from '../api/loraApi.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { loadMoreLoras } from '../api/loraApi.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
export class FilterManager {
constructor() {
this.filters = {
constructor(options = {}) {
this.options = {
...options
};
this.currentPage = options.page || document.body.dataset.page || 'loras';
const pageState = getCurrentPageState();
this.filters = pageState.filters || {
baseModel: [],
tags: []
};
@@ -13,17 +21,32 @@ export class FilterManager {
this.filterPanel = document.getElementById('filterPanel');
this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.tagsLoaded = false;
this.initialize();
// Store this instance in the state
if (pageState) {
pageState.filterManager = this;
}
}
initialize() {
// Create base model filter tags
this.createBaseModelTags();
// Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) {
this.createBaseModelTags();
}
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
this.toggleFilterPanel();
});
}
// Close filter panel when clicking outside
document.addEventListener('click', (e) => {
if (!this.filterPanel.contains(e.target) &&
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
e.target !== this.filterButton &&
!this.filterButton.contains(e.target) &&
!this.filterPanel.classList.contains('hidden')) {
@@ -39,15 +62,20 @@ export class FilterManager {
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
if (!tagsContainer) return;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// Determine the API endpoint based on the page type
let tagsEndpoint = '/api/loras/top-tags?limit=20';
if (this.currentPage === 'recipes') {
tagsEndpoint = '/api/recipes/top-tags?limit=20';
}
const response = await fetch('/api/top-tags?limit=20');
const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags');
const data = await response.json();
console.log('Top tags:', data);
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
@@ -72,14 +100,13 @@ export class FilterManager {
tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = '<div class="no-tags">No tags available</div>';
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
return;
}
tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
// {tag: "name", count: number}
const tagName = tag.tag;
tagEl.dataset.tag = tagName;
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
@@ -110,50 +137,93 @@ export class FilterManager {
const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return;
baseModelTagsContainer.innerHTML = '';
// Set the appropriate API endpoint based on current page
let apiEndpoint = '';
if (this.currentPage === 'loras') {
apiEndpoint = '/api/loras/base-models';
} else if (this.currentPage === 'recipes') {
apiEndpoint = '/api/recipes/base-models';
} else {
return; // No API endpoint for other pages
}
Object.entries(BASE_MODELS).forEach(([key, value]) => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag ${BASE_MODEL_CLASSES[value]}`;
tag.dataset.baseModel = value;
tag.innerHTML = value;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(value)) {
this.filters.baseModel.push(value);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(model => model !== value);
// Fetch base models
fetch(apiEndpoint)
.then(response => response.json())
.then(data => {
if (data.success && data.base_models) {
baseModelTagsContainer.innerHTML = '';
data.base_models.forEach(model => {
const tag = document.createElement('div');
// Add base model classes only for the loras page
const baseModelClass = (this.currentPage === 'loras' && BASE_MODEL_CLASSES[model.name])
? BASE_MODEL_CLASSES[model.name]
: '';
tag.className = `filter-tag base-model-tag ${baseModelClass}`;
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
// Update selections based on stored filters
this.updateTagSelections();
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
})
.catch(error => {
console.error(`Error fetching base models for ${this.currentPage}:`, error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
baseModelTagsContainer.appendChild(tag);
});
}
toggleFilterPanel() {
const wasHidden = this.filterPanel.classList.contains('hidden');
this.filterPanel.classList.toggle('hidden');
// If the panel is being opened, load the top tags and update selections
if (wasHidden) {
this.loadTopTags();
this.updateTagSelections();
toggleFilterPanel() {
if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden');
if (isHidden) {
// Update panel positions before showing
updatePanelPositions();
this.filterPanel.classList.remove('hidden');
this.filterButton.classList.add('active');
// Load tags if they haven't been loaded yet
if (!this.tagsLoaded) {
this.loadTopTags();
this.tagsLoaded = true;
}
} else {
this.closeFilterPanel();
}
}
}
closeFilterPanel() {
this.filterPanel.classList.add('hidden');
if (this.filterPanel) {
this.filterPanel.classList.add('hidden');
}
if (this.filterButton) {
this.filterButton.classList.remove('active');
}
}
updateTagSelections() {
@@ -183,23 +253,35 @@ export class FilterManager {
updateActiveFiltersCount() {
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
this.activeFiltersCount.style.display = 'inline-flex';
} else {
this.activeFiltersCount.style.display = 'none';
if (this.activeFiltersCount) {
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
this.activeFiltersCount.style.display = 'inline-flex';
} else {
this.activeFiltersCount.style.display = 'none';
}
}
}
async applyFilters(showToastNotification = true) {
const pageState = getCurrentPageState();
const storageKey = `${this.currentPage}_filters`;
// Save filters to localStorage
localStorage.setItem('loraFilters', JSON.stringify(this.filters));
setStorageItem(storageKey, this.filters);
// Update state with current filters
state.filters = { ...this.filters };
pageState.filters = { ...this.filters };
// Reload loras with filters applied
await resetAndReload();
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras') {
// For loras page, reset the page and reload
await loadMoreLoras(true, true);
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
await window.checkpointManager.loadCheckpoints(true);
}
// Update filter button to show active state
if (this.hasActiveFilters()) {
@@ -235,32 +317,48 @@ export class FilterManager {
};
// Update state
state.filters = { ...this.filters };
const pageState = getCurrentPageState();
pageState.filters = { ...this.filters };
// Update UI
this.updateTagSelections();
this.updateActiveFiltersCount();
// Remove from localStorage
localStorage.removeItem('loraFilters');
// Remove from local Storage
const storageKey = `${this.currentPage}_filters`;
removeStorageItem(storageKey);
// Update UI and reload data
// Update UI
this.filterButton.classList.remove('active');
await resetAndReload();
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras') {
await loadMoreLoras(true, true);
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
await window.checkpointManager.loadCheckpoints(true);
}
showToast(`Filters cleared`, 'info');
}
loadFiltersFromStorage() {
const savedFilters = localStorage.getItem('loraFilters');
const storageKey = `${this.currentPage}_filters`;
const savedFilters = getStorageItem(storageKey);
if (savedFilters) {
try {
const parsedFilters = JSON.parse(savedFilters);
// Ensure backward compatibility with older filter format
this.filters = {
baseModel: parsedFilters.baseModel || [],
tags: parsedFilters.tags || []
baseModel: savedFilters.baseModel || [],
tags: savedFilters.tags || []
};
// Update state with loaded filters
const pageState = getCurrentPageState();
pageState.filters = { ...this.filters };
this.updateTagSelections();
this.updateActiveFiltersCount();
@@ -268,7 +366,7 @@ export class FilterManager {
this.filterButton.classList.add('active');
}
} catch (error) {
console.error('Error loading filters from storage:', error);
console.error(`Error loading ${this.currentPage} filters from storage:`, error);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,17 +4,22 @@ export class LoadingManager {
this.overlay = document.getElementById('loading-overlay');
this.progressBar = this.overlay.querySelector('.progress-bar');
this.statusText = this.overlay.querySelector('.loading-status');
this.detailsContainer = null; // Will be created when needed
}
show(message = 'Loading...', progress = 0) {
this.overlay.style.display = 'flex';
this.setProgress(progress);
this.setStatus(message);
// Remove any existing details container
this.removeDetailsContainer();
}
hide() {
this.overlay.style.display = 'none';
this.reset();
this.removeDetailsContainer();
}
setProgress(percent) {
@@ -29,6 +34,101 @@ export class LoadingManager {
reset() {
this.setProgress(0);
this.setStatus('');
this.removeDetailsContainer();
}
// Create a details container for enhanced progress display
createDetailsContainer() {
// Remove existing container if any
this.removeDetailsContainer();
// Create new container
this.detailsContainer = document.createElement('div');
this.detailsContainer.className = 'progress-details-container';
// Insert after the main progress bar
const loadingContent = this.overlay.querySelector('.loading-content');
if (loadingContent) {
loadingContent.appendChild(this.detailsContainer);
}
return this.detailsContainer;
}
// Remove details container
removeDetailsContainer() {
if (this.detailsContainer) {
this.detailsContainer.remove();
this.detailsContainer = null;
}
}
// Show enhanced progress for downloads
showDownloadProgress(totalItems = 1) {
this.show('Preparing download...', 0);
// Create details container
const detailsContainer = this.createDetailsContainer();
// Create current item progress
const currentItemContainer = document.createElement('div');
currentItemContainer.className = 'current-item-progress';
const currentItemLabel = document.createElement('div');
currentItemLabel.className = 'current-item-label';
currentItemLabel.textContent = 'Current file:';
const currentItemBar = document.createElement('div');
currentItemBar.className = 'current-item-bar-container';
const currentItemProgress = document.createElement('div');
currentItemProgress.className = 'current-item-bar';
currentItemProgress.style.width = '0%';
const currentItemPercent = document.createElement('span');
currentItemPercent.className = 'current-item-percent';
currentItemPercent.textContent = '0%';
currentItemBar.appendChild(currentItemProgress);
currentItemContainer.appendChild(currentItemLabel);
currentItemContainer.appendChild(currentItemBar);
currentItemContainer.appendChild(currentItemPercent);
// Create overall progress elements if multiple items
let overallLabel = null;
if (totalItems > 1) {
overallLabel = document.createElement('div');
overallLabel.className = 'overall-progress-label';
overallLabel.textContent = `Overall progress (0/${totalItems} complete):`;
detailsContainer.appendChild(overallLabel);
}
// Add current item progress to container
detailsContainer.appendChild(currentItemContainer);
// Return update function
return (currentProgress, currentIndex = 0, currentName = '') => {
// Update current item progress
currentItemProgress.style.width = `${currentProgress}%`;
currentItemPercent.textContent = `${Math.floor(currentProgress)}%`;
// Update current item label if name provided
if (currentName) {
currentItemLabel.textContent = `Downloading: ${currentName}`;
}
// Update overall label if multiple items
if (totalItems > 1 && overallLabel) {
overallLabel.textContent = `Overall progress (${currentIndex}/${totalItems} complete):`;
// Calculate and update overall progress
const overallProgress = Math.floor((currentIndex + currentProgress/100) / totalItems * 100);
this.setProgress(overallProgress);
} else {
// Single item, just update main progress
this.setProgress(currentProgress);
}
};
}
async showWithProgress(callback, options = {}) {

View File

@@ -10,67 +10,114 @@ export class ModalManager {
this.boundHandleEscape = this.handleEscape.bind(this);
// Register all modals
this.registerModal('loraModal', {
element: document.getElementById('loraModal'),
onClose: () => {
this.getModal('loraModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
// Register all modals - only if they exist in the current page
const loraModal = document.getElementById('loraModal');
if (loraModal) {
this.registerModal('loraModal', {
element: loraModal,
onClose: () => {
this.getModal('loraModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
this.registerModal('deleteModal', {
element: document.getElementById('deleteModal'),
onClose: () => {
this.getModal('deleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
const deleteModal = document.getElementById('deleteModal');
if (deleteModal) {
this.registerModal('deleteModal', {
element: deleteModal,
onClose: () => {
this.getModal('deleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add downloadModal registration
this.registerModal('downloadModal', {
element: document.getElementById('downloadModal'),
onClose: () => {
this.getModal('downloadModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
const downloadModal = document.getElementById('downloadModal');
if (downloadModal) {
this.registerModal('downloadModal', {
element: downloadModal,
onClose: () => {
this.getModal('downloadModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add settingsModal registration
this.registerModal('settingsModal', {
element: document.getElementById('settingsModal'),
onClose: () => {
this.getModal('settingsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
this.registerModal('settingsModal', {
element: settingsModal,
onClose: () => {
this.getModal('settingsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add moveModal registration
this.registerModal('moveModal', {
element: document.getElementById('moveModal'),
onClose: () => {
this.getModal('moveModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
const moveModal = document.getElementById('moveModal');
if (moveModal) {
this.registerModal('moveModal', {
element: moveModal,
onClose: () => {
this.getModal('moveModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add supportModal registration
this.registerModal('supportModal', {
element: document.getElementById('supportModal'),
onClose: () => {
this.getModal('supportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
const supportModal = document.getElementById('supportModal');
if (supportModal) {
this.registerModal('supportModal', {
element: supportModal,
onClose: () => {
this.getModal('supportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add updateModal registration
this.registerModal('updateModal', {
element: document.getElementById('updateModal'),
onClose: () => {
this.getModal('updateModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
const updateModal = document.getElementById('updateModal');
if (updateModal) {
this.registerModal('updateModal', {
element: updateModal,
onClose: () => {
this.getModal('updateModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add importModal registration
const importModal = document.getElementById('importModal');
if (importModal) {
this.registerModal('importModal', {
element: importModal,
onClose: () => {
this.getModal('importModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
}
// Add recipeModal registration
const recipeModal = document.getElementById('recipeModal');
if (recipeModal) {
this.registerModal('recipeModal', {
element: recipeModal,
onClose: () => {
this.getModal('recipeModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Set up event listeners for modal toggles
const supportToggle = document.getElementById('supportToggleBtn');
@@ -89,8 +136,8 @@ export class ModalManager {
isOpen: false
});
// Only add click outside handler if it's the lora modal
if (id == 'loraModal') {
// Add click outside handler if specified in config
if (config.closeOnOutsideClick) {
config.element.addEventListener('click', (e) => {
if (e.target === config.element) {
this.closeModal(id);

View File

@@ -1,6 +1,8 @@
import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { modalManager } from './ModalManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
class MoveManager {
constructor() {
@@ -86,6 +88,12 @@ class MoveManager {
`<option value="${root}">${root}</option>`
).join('');
// Set default lora root if available
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
if (defaultRoot && data.roots.includes(defaultRoot)) {
this.loraRootSelect.value = defaultRoot;
}
this.updatePathPreview();
modalManager.showModal('moveModal');
@@ -165,11 +173,20 @@ class MoveManager {
})
});
const result = await response.json();
if (!response.ok) {
if (result && result.error) {
throw new Error(result.error);
}
throw new Error('Failed to move model');
}
showToast('Model moved successfully', 'success');
if (result && result.message) {
showToast(result.message, 'info');
} else {
showToast('Model moved successfully', 'success');
}
}
async moveBulkModels(filePaths, targetPath) {
@@ -194,11 +211,44 @@ class MoveManager {
})
});
const result = await response.json();
if (!response.ok) {
throw new Error('Failed to move models');
}
showToast(`Successfully moved ${movedPaths.length} models`, 'success');
// Display results with more details
if (result.success) {
if (result.failure_count > 0) {
// Some files failed to move
showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning');
// Log details about failures
console.log('Move operation results:', result.results);
// Get list of failed files with reasons
const failedFiles = result.results
.filter(r => !r.success)
.map(r => {
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
return `${fileName}: ${r.message}`;
});
// Show first few failures in a toast
if (failedFiles.length > 0) {
const failureMessage = failedFiles.length <= 3
? failedFiles.join('\n')
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
}
} else {
// All files moved successfully
showToast(`Successfully moved ${result.success_count} models`, 'success');
}
} else {
throw new Error(result.message || 'Failed to move models');
}
}
}

View File

@@ -0,0 +1,324 @@
import { updatePanelPositions } from "../utils/uiHelpers.js";
import { getCurrentPageState } from "../state/index.js";
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
/**
* SearchManager - Handles search functionality across different pages
* Each page can extend or customize this base functionality
*/
export class SearchManager {
constructor(options = {}) {
this.options = {
searchDelay: 300,
minSearchLength: 2,
...options
};
this.searchInput = document.getElementById('searchInput');
this.searchOptionsToggle = document.getElementById('searchOptionsToggle');
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
this.closeSearchOptions = document.getElementById('closeSearchOptions');
this.searchOptionTags = document.querySelectorAll('.search-option-tag');
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
this.searchTimeout = null;
this.currentPage = options.page || document.body.dataset.page || 'loras';
this.isSearching = false;
// Create clear button for search input
this.createClearButton();
this.initEventListeners();
this.loadSearchPreferences();
updatePanelPositions();
// Add resize listener
window.addEventListener('resize', updatePanelPositions);
}
initEventListeners() {
// Search input event
if (this.searchInput) {
this.searchInput.addEventListener('input', () => {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => this.performSearch(), this.options.searchDelay);
this.updateClearButtonVisibility();
});
// Clear search with Escape key
this.searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.searchInput.value = '';
this.updateClearButtonVisibility();
this.performSearch();
}
});
}
// Search options toggle
if (this.searchOptionsToggle) {
this.searchOptionsToggle.addEventListener('click', () => {
this.toggleSearchOptionsPanel();
});
}
// Close search options
if (this.closeSearchOptions) {
this.closeSearchOptions.addEventListener('click', () => {
this.closeSearchOptionsPanel();
});
}
// Search option tags
if (this.searchOptionTags) {
this.searchOptionTags.forEach(tag => {
tag.addEventListener('click', () => {
// Check if clicking would deselect the last active option
const activeOptions = document.querySelectorAll('.search-option-tag.active');
if (activeOptions.length === 1 && activeOptions[0] === tag) {
// Don't allow deselecting the last option
if (typeof showToast === 'function') {
showToast('At least one search option must be selected', 'info');
}
return;
}
tag.classList.toggle('active');
this.saveSearchPreferences();
this.performSearch();
});
});
}
// Recursive search toggle
if (this.recursiveSearchToggle) {
this.recursiveSearchToggle.addEventListener('change', () => {
this.saveSearchPreferences();
this.performSearch();
});
}
// Add global click handler to close panels when clicking outside
document.addEventListener('click', (e) => {
// Close search options panel when clicking outside
if (this.searchOptionsPanel &&
!this.searchOptionsPanel.contains(e.target) &&
e.target !== this.searchOptionsToggle &&
!this.searchOptionsToggle.contains(e.target)) {
this.closeSearchOptionsPanel();
}
// Close filter panel when clicking outside (if filterManager exists)
const filterPanel = document.getElementById('filterPanel');
const filterButton = document.getElementById('filterButton');
if (filterPanel &&
!filterPanel.contains(e.target) &&
e.target !== filterButton &&
!filterButton.contains(e.target) &&
window.filterManager) {
window.filterManager.closeFilterPanel();
}
});
}
createClearButton() {
// Create clear button if it doesn't exist
if (!this.searchInput) return;
// Check if clear button already exists
let clearButton = this.searchInput.parentNode.querySelector('.search-clear');
if (!clearButton) {
// Create clear button
clearButton = document.createElement('button');
clearButton.className = 'search-clear';
clearButton.innerHTML = '<i class="fas fa-times"></i>';
clearButton.title = 'Clear search';
// Add click handler
clearButton.addEventListener('click', () => {
this.searchInput.value = '';
this.updateClearButtonVisibility();
this.performSearch();
});
// Insert after search input
this.searchInput.parentNode.appendChild(clearButton);
}
this.clearButton = clearButton;
// Set initial visibility
this.updateClearButtonVisibility();
}
updateClearButtonVisibility() {
if (this.clearButton) {
this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0);
}
}
toggleSearchOptionsPanel() {
if (this.searchOptionsPanel) {
const isHidden = this.searchOptionsPanel.classList.contains('hidden');
if (isHidden) {
// Update position before showing
updatePanelPositions();
this.searchOptionsPanel.classList.remove('hidden');
this.searchOptionsToggle.classList.add('active');
// Ensure the panel is visible
this.searchOptionsPanel.style.display = 'block';
} else {
this.closeSearchOptionsPanel();
}
}
}
closeSearchOptionsPanel() {
if (this.searchOptionsPanel) {
this.searchOptionsPanel.classList.add('hidden');
this.searchOptionsToggle.classList.remove('active');
}
}
loadSearchPreferences() {
try {
const preferences = getStorageItem(`${this.currentPage}_search_prefs`) || {};
// Apply search options
if (preferences.options) {
this.searchOptionTags.forEach(tag => {
const option = tag.dataset.option;
if (preferences.options[option] !== undefined) {
tag.classList.toggle('active', preferences.options[option]);
}
});
}
// Apply recursive search - only if the toggle exists
if (this.recursiveSearchToggle && preferences.recursive !== undefined) {
this.recursiveSearchToggle.checked = preferences.recursive;
}
// Ensure at least one search option is selected
this.validateSearchOptions();
} catch (error) {
console.error('Error loading search preferences:', error);
// Set default options if loading fails
this.setDefaultSearchOptions();
}
}
validateSearchOptions() {
// Check if at least one search option is active
const hasActiveOption = Array.from(this.searchOptionTags).some(tag =>
tag.classList.contains('active')
);
// If no search options are active, activate default options
if (!hasActiveOption) {
this.setDefaultSearchOptions();
}
}
setDefaultSearchOptions() {
// Default to filename search option if available
const filenameOption = Array.from(this.searchOptionTags).find(tag =>
tag.dataset.option === 'filename'
);
if (filenameOption) {
filenameOption.classList.add('active');
} else if (this.searchOptionTags.length > 0) {
// Otherwise, select the first option
this.searchOptionTags[0].classList.add('active');
}
// Save the default preferences
this.saveSearchPreferences();
}
saveSearchPreferences() {
try {
const options = {};
this.searchOptionTags.forEach(tag => {
options[tag.dataset.option] = tag.classList.contains('active');
});
const preferences = {
options
};
// Only add recursive option if the toggle exists
if (this.recursiveSearchToggle) {
preferences.recursive = this.recursiveSearchToggle.checked;
}
setStorageItem(`${this.currentPage}_search_prefs`, preferences);
} catch (error) {
console.error('Error saving search preferences:', error);
}
}
getActiveSearchOptions() {
const options = {};
this.searchOptionTags.forEach(tag => {
options[tag.dataset.option] = tag.classList.contains('active');
});
return options;
}
performSearch() {
const query = this.searchInput.value.trim();
const options = this.getActiveSearchOptions();
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
// Update the state with search parameters
const pageState = getCurrentPageState();
// Set search query in filters
if (pageState && pageState.filters) {
pageState.filters.search = query;
}
// Update search options based on page type
if (pageState && pageState.searchOptions) {
if (this.currentPage === 'recipes') {
pageState.searchOptions = {
title: options.title || false,
tags: options.tags || false,
loraName: options.loraName || false,
loraModel: options.loraModel || false
};
} else if (this.currentPage === 'loras') {
pageState.searchOptions = {
filename: options.filename || false,
modelname: options.modelname || false,
tags: options.tags || false,
recursive: recursive
};
} else if (this.currentPage === 'checkpoints') {
pageState.searchOptions = {
filename: options.filename || false,
modelname: options.modelname || false,
recursive: recursive
};
}
}
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true); // true to reset pagination
} else if (this.currentPage === 'loras' && window.loadMoreLoras) {
// Reset loras page and reload
if (pageState) {
pageState.currentPage = 1;
pageState.hasMore = true;
}
window.loadMoreLoras(true); // true to reset pagination
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
window.checkpointManager.loadCheckpoints(true); // true to reset pagination
}
}
}

View File

@@ -1,7 +1,8 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { state, saveSettings } from '../state/index.js';
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
export class SettingsManager {
constructor() {
@@ -9,9 +10,24 @@ export class SettingsManager {
this.isOpen = false;
// Add initialization to sync with modal state
this.currentPage = document.body.dataset.page || 'loras';
// Ensure settings are loaded from localStorage
this.loadSettingsFromStorage();
this.initialize();
}
loadSettingsFromStorage() {
// Get saved settings from localStorage
const savedSettings = getStorageItem('settings');
// Apply saved settings to state if available
if (savedSettings) {
state.global.settings = { ...state.global.settings, ...savedSettings };
}
}
initialize() {
if (this.initialized) return;
@@ -37,22 +53,64 @@ export class SettingsManager {
this.initialized = true;
}
loadSettingsToUI() {
async loadSettingsToUI() {
// Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
if (blurMatureContentCheckbox) {
blurMatureContentCheckbox.checked = state.settings.blurMatureContent;
blurMatureContentCheckbox.checked = state.global.settings.blurMatureContent;
}
const showOnlySFWCheckbox = document.getElementById('showOnlySFW');
if (showOnlySFWCheckbox) {
// Sync with state (backend will set this via template)
state.settings.show_only_sfw = showOnlySFWCheckbox.checked;
state.global.settings.show_only_sfw = showOnlySFWCheckbox.checked;
}
// Load default lora root
await this.loadLoraRoots();
// Backend settings are loaded from the template directly
}
async loadLoraRoots() {
try {
const defaultLoraRootSelect = document.getElementById('defaultLoraRoot');
if (!defaultLoraRootSelect) return;
// Fetch lora roots
const response = await fetch('/api/lora-roots');
if (!response.ok) {
throw new Error('Failed to fetch LoRA roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No LoRA roots found');
}
// Clear existing options except the first one (No Default)
const noDefaultOption = defaultLoraRootSelect.querySelector('option[value=""]');
defaultLoraRootSelect.innerHTML = '';
defaultLoraRootSelect.appendChild(noDefaultOption);
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultLoraRootSelect.appendChild(option);
});
// Set selected value from settings
const defaultRoot = state.global.settings.default_loras_root || '';
defaultLoraRootSelect.value = defaultRoot;
} catch (error) {
console.error('Error loading LoRA roots:', error);
showToast('Failed to load LoRA roots: ' + error.message, 'error');
}
}
toggleSettings() {
if (this.isOpen) {
modalManager.closeModal('settingsModal');
@@ -65,15 +123,19 @@ export class SettingsManager {
async saveSettings() {
// Get frontend settings from UI
const blurMatureContent = document.getElementById('blurMatureContent').checked;
const showOnlySFW = document.getElementById('showOnlySFW').checked;
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
// Get backend settings
const apiKey = document.getElementById('civitaiApiKey').value;
const showOnlySFW = document.getElementById('showOnlySFW').checked;
// Update frontend state and save to localStorage
state.settings.blurMatureContent = blurMatureContent;
state.settings.show_only_sfw = showOnlySFW;
saveSettings();
state.global.settings.blurMatureContent = blurMatureContent;
state.global.settings.show_only_sfw = showOnlySFW;
state.global.settings.default_loras_root = defaultLoraRoot;
// Save settings to localStorage
setStorageItem('settings', state.global.settings);
try {
// Save backend settings via API
@@ -98,8 +160,16 @@ export class SettingsManager {
// Apply frontend settings immediately
this.applyFrontendSettings();
// Reload the loras without updating folders
await resetAndReload(false);
if (this.currentPage === 'loras') {
// Reload the loras without updating folders
await resetAndReload(false);
} else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders
await window.recipeManager.loadRecipes();
} else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders
await window.checkpointsManager.loadCheckpoints();
}
} catch (error) {
showToast('Failed to save settings: ' + error.message, 'error');
}
@@ -107,7 +177,7 @@ export class SettingsManager {
applyFrontendSettings() {
// Apply blur setting to existing content
const blurSetting = state.settings.blurMatureContent;
const blurSetting = state.global.settings.blurMatureContent;
document.querySelectorAll('.lora-card[data-nsfw="true"] .card-image').forEach(img => {
if (blurSetting) {
img.classList.add('nsfw-blur');

View File

@@ -1,4 +1,5 @@
import { modalManager } from './ModalManager.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
export class UpdateService {
constructor() {
@@ -7,22 +8,18 @@ export class UpdateService {
this.latestVersion = "v0.0.0"; // Initialize with default values
this.updateInfo = null;
this.updateAvailable = false;
this.updateNotificationsEnabled = localStorage.getItem('show_update_notifications') !== 'false';
this.lastCheckTime = parseInt(localStorage.getItem('last_update_check') || '0');
this.updateNotificationsEnabled = getStorageItem('show_update_notifications');
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
}
initialize() {
// Initialize update preferences from localStorage
const showUpdates = localStorage.getItem('show_update_notifications');
this.updateNotificationsEnabled = showUpdates === null || showUpdates === 'true';
// Register event listener for update notification toggle
const updateCheckbox = document.getElementById('updateNotifications');
if (updateCheckbox) {
updateCheckbox.checked = this.updateNotificationsEnabled;
updateCheckbox.addEventListener('change', (e) => {
this.updateNotificationsEnabled = e.target.checked;
localStorage.setItem('show_update_notifications', e.target.checked);
setStorageItem('show_update_notifications', e.target.checked);
this.updateBadgeVisibility();
});
}
@@ -34,10 +31,10 @@ export class UpdateService {
});
// Set up event listener for update button
const updateToggle = document.getElementById('updateToggleBtn');
if (updateToggle) {
updateToggle.addEventListener('click', () => this.toggleUpdateModal());
}
// const updateToggle = document.getElementById('updateToggleBtn');
// if (updateToggle) {
// updateToggle.addEventListener('click', () => this.toggleUpdateModal());
// }
// Immediately update modal content with current values (even if from default)
this.updateModalContent();
@@ -71,7 +68,7 @@ export class UpdateService {
// Update last check time
this.lastCheckTime = now;
localStorage.setItem('last_update_check', now.toString());
setStorageItem('last_update_check', now.toString());
// Update UI
this.updateBadgeVisibility();
@@ -181,7 +178,8 @@ export class UpdateService {
if (this.updateInfo.changelog && this.updateInfo.changelog.length > 0) {
this.updateInfo.changelog.forEach(item => {
const listItem = document.createElement('li');
listItem.textContent = item;
// Parse markdown in changelog items
listItem.innerHTML = this.parseMarkdown(item);
changelogList.appendChild(listItem);
});
} else {
@@ -204,6 +202,25 @@ export class UpdateService {
}
}
// Simple markdown parser for changelog items
parseMarkdown(text) {
if (!text) return '';
// Handle bold text (**text**)
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Handle italic text (*text*)
text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Handle inline code (`code`)
text = text.replace(/`(.*?)`/g, '<code>$1</code>');
// Handle links [text](url)
text = text.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>');
return text;
}
toggleUpdateModal() {
const updateModal = modalManager.getModal('updateModal');
@@ -226,10 +243,6 @@ export class UpdateService {
});
}
showUpdateModal() {
this.toggleUpdateModal();
}
async manualCheckForUpdates() {
this.lastCheckTime = 0; // Reset last check time to force check
await this.checkForUpdates();

187
static/js/recipes.js Normal file
View File

@@ -0,0 +1,187 @@
// Recipe manager module
import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js';
import { RecipeCard } from './components/RecipeCard.js';
import { RecipeModal } from './components/RecipeModal.js';
import { getCurrentPageState } from './state/index.js';
import { toggleApiKeyVisibility } from './managers/SettingsManager.js';
class RecipeManager {
constructor() {
// Get page state
this.pageState = getCurrentPageState();
// Initialize ImportManager
this.importManager = new ImportManager();
// Initialize RecipeModal
this.recipeModal = new RecipeModal();
// Add state tracking for infinite scroll
this.pageState.isLoading = false;
this.pageState.hasMore = true;
}
async initialize() {
// Initialize event listeners
this.initEventListeners();
// Set default search options if not already defined
this._initSearchOptions();
// Load initial set of recipes
await this.loadRecipes();
// Expose necessary functions to the page
this._exposeGlobalFunctions();
// Initialize common page features (lazy loading, infinite scroll)
appCore.initializePageFeatures();
}
_initSearchOptions() {
// Ensure recipes search options are properly initialized
if (!this.pageState.searchOptions) {
this.pageState.searchOptions = {
title: true, // Recipe title
tags: true, // Recipe tags
loraName: true, // LoRA file name
loraModel: true // LoRA model name
};
}
}
_exposeGlobalFunctions() {
// Only expose what's needed for the page
window.recipeManager = this;
window.importManager = this.importManager;
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
}
initEventListeners() {
// Sort select
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;
this.loadRecipes();
});
}
}
async loadRecipes(resetPage = true) {
try {
// Show loading indicator
document.body.classList.add('loading');
this.pageState.isLoading = true;
// Reset to first page if requested
if (resetPage) {
this.pageState.currentPage = 1;
// Clear grid if resetting
const grid = document.getElementById('recipeGrid');
if (grid) grid.innerHTML = '';
}
// Build query parameters
const params = new URLSearchParams({
page: this.pageState.currentPage,
page_size: this.pageState.pageSize || 20,
sort_by: this.pageState.sortBy
});
// Add search filter if present
if (this.pageState.filters.search) {
params.append('search', this.pageState.filters.search);
// Add search option parameters
if (this.pageState.searchOptions) {
params.append('search_title', this.pageState.searchOptions.title.toString());
params.append('search_tags', this.pageState.searchOptions.tags.toString());
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
params.append('fuzzy', 'true');
}
}
// Add base model filters
if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) {
params.append('base_models', this.pageState.filters.baseModel.join(','));
}
// Add tag filters
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
params.append('tags', this.pageState.filters.tags.join(','));
}
// Fetch recipes
const response = await fetch(`/api/recipes?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`);
}
const data = await response.json();
// Update recipes grid
this.updateRecipesGrid(data, resetPage);
// Update pagination state based on current page and total pages
this.pageState.hasMore = data.page < data.total_pages;
} catch (error) {
console.error('Error loading recipes:', error);
appCore.showToast('Failed to load recipes', 'error');
} finally {
// Hide loading indicator
document.body.classList.remove('loading');
this.pageState.isLoading = false;
}
}
updateRecipesGrid(data, resetGrid = true) {
const grid = document.getElementById('recipeGrid');
if (!grid) return;
// Check if data exists and has items
if (!data.items || data.items.length === 0) {
if (resetGrid) {
grid.innerHTML = `
<div class="placeholder-message">
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
</div>
`;
}
return;
}
// Clear grid if resetting
if (resetGrid) {
grid.innerHTML = '';
}
// Create recipe cards
data.items.forEach(recipe => {
const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe));
grid.appendChild(recipeCard.element);
});
}
showRecipeDetails(recipe) {
this.recipeModal.showRecipeDetails(recipe);
}
}
// Initialize components
document.addEventListener('DOMContentLoaded', async () => {
// Initialize core application
await appCore.initialize();
// Initialize recipe manager
const recipeManager = new RecipeManager();
await recipeManager.initialize();
});
// Export for use in other modules
export { RecipeManager };

View File

@@ -1,53 +1,154 @@
// Create the new hierarchical state structure
import { getStorageItem } from '../utils/storageHelpers.js';
// Load settings from localStorage or use defaults
const savedSettings = getStorageItem('settings', {
blurMatureContent: true,
show_only_sfw: false
});
export const state = {
currentPage: 1,
isLoading: false,
hasMore: true,
sortBy: 'name',
activeFolder: null,
loadingManager: null,
observer: null,
previewVersions: new Map(),
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
recursive: false
// Global state
global: {
settings: savedSettings,
loadingManager: null,
observer: null,
},
filters: {
baseModel: [],
tags: []
// Page-specific states
pages: {
loras: {
currentPage: 1,
isLoading: false,
hasMore: true,
sortBy: 'name',
activeFolder: null,
previewVersions: new Map(),
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
recursive: false
},
filters: {
baseModel: [],
tags: []
},
bulkMode: false,
selectedLoras: new Set(),
loraMetadataCache: new Map(),
},
recipes: {
currentPage: 1,
isLoading: false,
hasMore: true,
sortBy: 'date',
searchManager: null,
searchOptions: {
title: true,
tags: true,
loraName: true,
loraModel: true
},
filters: {
baseModel: [],
tags: [],
search: ''
},
pageSize: 20
},
checkpoints: {
currentPage: 1,
isLoading: false,
hasMore: true,
sortBy: 'name',
activeFolder: null,
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
recursive: false
},
filters: {
baseModel: [],
tags: []
}
}
},
bulkMode: false,
selectedLoras: new Set(),
loraMetadataCache: new Map(),
settings: {
blurMatureContent: true,
show_only_sfw: false
}
// Current active page
currentPageType: 'loras',
// Backward compatibility - proxy properties
get currentPage() { return this.pages[this.currentPageType].currentPage; },
set currentPage(value) { this.pages[this.currentPageType].currentPage = value; },
get isLoading() { return this.pages[this.currentPageType].isLoading; },
set isLoading(value) { this.pages[this.currentPageType].isLoading = value; },
get hasMore() { return this.pages[this.currentPageType].hasMore; },
set hasMore(value) { this.pages[this.currentPageType].hasMore = value; },
get sortBy() { return this.pages[this.currentPageType].sortBy; },
set sortBy(value) { this.pages[this.currentPageType].sortBy = value; },
get activeFolder() { return this.pages[this.currentPageType].activeFolder; },
set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; },
get loadingManager() { return this.global.loadingManager; },
set loadingManager(value) { this.global.loadingManager = value; },
get observer() { return this.global.observer; },
set observer(value) { this.global.observer = value; },
get previewVersions() { return this.pages.loras.previewVersions; },
set previewVersions(value) { this.pages.loras.previewVersions = value; },
get searchManager() { return this.pages[this.currentPageType].searchManager; },
set searchManager(value) { this.pages[this.currentPageType].searchManager = value; },
get searchOptions() { return this.pages[this.currentPageType].searchOptions; },
set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; },
get filters() { return this.pages[this.currentPageType].filters; },
set filters(value) { this.pages[this.currentPageType].filters = value; },
get bulkMode() { return this.pages.loras.bulkMode; },
set bulkMode(value) { this.pages.loras.bulkMode = value; },
get selectedLoras() { return this.pages.loras.selectedLoras; },
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
get settings() { return this.global.settings; },
set settings(value) { this.global.settings = value; }
};
// Initialize settings from localStorage if available
export function initSettings() {
try {
const savedSettings = localStorage.getItem('loraManagerSettings');
if (savedSettings) {
const parsedSettings = JSON.parse(savedSettings);
state.settings = { ...state.settings, ...parsedSettings };
}
} catch (error) {
console.error('Error loading settings from localStorage:', error);
}
// Get the current page state
export function getCurrentPageState() {
return state.pages[state.currentPageType];
}
// Save settings to localStorage
export function saveSettings() {
try {
localStorage.setItem('loraManagerSettings', JSON.stringify(state.settings));
} catch (error) {
console.error('Error saving settings to localStorage:', error);
// Set the current page type
export function setCurrentPageType(pageType) {
if (state.pages[pageType]) {
state.currentPageType = pageType;
return true;
}
console.warn(`Unknown page type: ${pageType}`);
return false;
}
// Initialize settings on load
initSettings();
// Initialize page state when a page loads
export function initPageState(pageType) {
if (setCurrentPageType(pageType)) {
console.log(`Initialized state for page: ${pageType}`);
return getCurrentPageState();
}
return null;
}

View File

@@ -31,7 +31,7 @@ export const BASE_MODELS = {
LUMINA: "Lumina",
KOLORS: "Kolors",
NOOBAI: "NoobAI",
IL: "IL",
ILLUSTRIOUS: "Illustrious",
PONY: "Pony",
// Video models
@@ -82,7 +82,7 @@ export const BASE_MODEL_CLASSES = {
[BASE_MODELS.LUMINA]: "lumina",
[BASE_MODELS.KOLORS]: "kolors",
[BASE_MODELS.NOOBAI]: "noobai",
[BASE_MODELS.IL]: "il",
[BASE_MODELS.ILLUSTRIOUS]: "il",
[BASE_MODELS.PONY]: "pony",
// Default

View File

@@ -1,29 +1,88 @@
import { state } from '../state/index.js';
import { state, getCurrentPageState } from '../state/index.js';
import { loadMoreLoras } from '../api/loraApi.js';
import { debounce } from './debounce.js';
export function initializeInfiniteScroll() {
export function initializeInfiniteScroll(pageType = 'loras') {
if (state.observer) {
state.observer.disconnect();
}
// Set the current page type
state.currentPageType = pageType;
// Get the current page state
const pageState = getCurrentPageState();
// Determine the load more function and grid ID based on page type
let loadMoreFunction;
let gridId;
switch (pageType) {
case 'recipes':
loadMoreFunction = () => {
if (!pageState.isLoading && pageState.hasMore) {
pageState.currentPage++;
window.recipeManager.loadRecipes(false); // false to not reset pagination
}
};
gridId = 'recipeGrid';
break;
case 'checkpoints':
loadMoreFunction = () => {
if (!pageState.isLoading && pageState.hasMore) {
pageState.currentPage++;
window.checkpointManager.loadCheckpoints(false); // false to not reset pagination
}
};
gridId = 'checkpointGrid';
break;
case 'loras':
default:
loadMoreFunction = () => loadMoreLoras(false); // false to not reset
gridId = 'loraGrid';
break;
}
const debouncedLoadMore = debounce(loadMoreFunction, 100);
state.observer = new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting && !state.isLoading && state.hasMore) {
loadMoreLoras();
if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) {
debouncedLoadMore();
}
},
{ threshold: 0.1 }
);
const grid = document.getElementById(gridId);
if (!grid) {
console.warn(`Grid with ID "${gridId}" not found for infinite scroll`);
return;
}
const existingSentinel = document.getElementById('scroll-sentinel');
if (existingSentinel) {
state.observer.observe(existingSentinel);
} else {
// Create a wrapper div that will be placed after the grid
const sentinelWrapper = document.createElement('div');
sentinelWrapper.style.width = '100%';
sentinelWrapper.style.height = '10px';
sentinelWrapper.style.margin = '0';
sentinelWrapper.style.padding = '0';
// Create the actual sentinel element
const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel';
sentinel.style.height = '10px';
document.getElementById('loraGrid').appendChild(sentinel);
// Add the sentinel to the wrapper
sentinelWrapper.appendChild(sentinel);
// Insert the wrapper after the grid instead of inside it
grid.parentNode.insertBefore(sentinelWrapper, grid.nextSibling);
state.observer.observe(sentinel);
}
}

53
static/js/utils/routes.js Normal file
View File

@@ -0,0 +1,53 @@
// API routes configuration
export const apiRoutes = {
// LoRA routes
loras: {
list: '/api/loras',
detail: (id) => `/api/loras/${id}`,
delete: (id) => `/api/loras/${id}`,
update: (id) => `/api/loras/${id}`,
civitai: (id) => `/api/loras/${id}/civitai`,
download: '/api/download-lora',
move: '/api/move-lora',
scan: '/api/scan-loras'
},
// Recipe routes
recipes: {
list: '/api/recipes',
detail: (id) => `/api/recipes/${id}`,
delete: (id) => `/api/recipes/${id}`,
update: (id) => `/api/recipes/${id}`,
analyze: '/api/analyze-recipe-image',
save: '/api/save-recipe'
},
// Checkpoint routes
checkpoints: {
list: '/api/checkpoints',
detail: (id) => `/api/checkpoints/${id}`,
delete: (id) => `/api/checkpoints/${id}`,
update: (id) => `/api/checkpoints/${id}`
},
// WebSocket routes
ws: {
fetchProgress: (protocol) => `${protocol}://${window.location.host}/ws/fetch-progress`
}
};
// Page routes
export const pageRoutes = {
loras: '/loras',
recipes: '/loras/recipes',
checkpoints: '/checkpoints'
};
// Helper function to get current page type
export function getCurrentPageType() {
const path = window.location.pathname;
if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}

View File

@@ -1,262 +0,0 @@
import { appendLoraCards } from '../api/loraApi.js';
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { showToast } from './uiHelpers.js';
export class SearchManager {
constructor() {
// Initialize search manager
this.searchInput = document.getElementById('searchInput');
this.searchOptionsToggle = document.getElementById('searchOptionsToggle');
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
this.searchDebounceTimeout = null;
this.currentSearchTerm = '';
this.isSearching = false;
// Add clear button
this.createClearButton();
// Add this instance to state
state.searchManager = this;
if (this.searchInput) {
this.searchInput.addEventListener('input', this.handleSearch.bind(this));
// Update clear button visibility on input
this.searchInput.addEventListener('input', () => {
this.updateClearButtonVisibility();
});
}
// Initialize search options
this.initSearchOptions();
}
initSearchOptions() {
// Load recursive search state from localStorage
state.searchOptions.recursive = localStorage.getItem('recursiveSearch') === 'true';
if (this.recursiveSearchToggle) {
this.recursiveSearchToggle.checked = state.searchOptions.recursive;
this.recursiveSearchToggle.addEventListener('change', (e) => {
state.searchOptions.recursive = e.target.checked;
localStorage.setItem('recursiveSearch', state.searchOptions.recursive);
// Rerun search if there's an active search term
if (this.currentSearchTerm) {
this.performSearch(this.currentSearchTerm);
}
});
}
// Setup search options toggle
if (this.searchOptionsToggle) {
this.searchOptionsToggle.addEventListener('click', () => {
this.toggleSearchOptionsPanel();
});
}
// Close button for search options panel
const closeButton = document.getElementById('closeSearchOptions');
if (closeButton) {
closeButton.addEventListener('click', () => {
this.closeSearchOptionsPanel();
});
}
// Setup search option tags
const optionTags = document.querySelectorAll('.search-option-tag');
optionTags.forEach(tag => {
const option = tag.dataset.option;
// Initialize tag state from state
tag.classList.toggle('active', state.searchOptions[option]);
tag.addEventListener('click', () => {
// Check if clicking would deselect the last active option
const activeOptions = document.querySelectorAll('.search-option-tag.active');
if (activeOptions.length === 1 && activeOptions[0] === tag) {
// Don't allow deselecting the last option and show toast
showToast('At least one search option must be selected', 'info');
return;
}
tag.classList.toggle('active');
state.searchOptions[option] = tag.classList.contains('active');
// Save to localStorage
localStorage.setItem(`searchOption_${option}`, state.searchOptions[option]);
// Rerun search if there's an active search term
if (this.currentSearchTerm) {
this.performSearch(this.currentSearchTerm);
}
});
// Load option state from localStorage or use default
const savedState = localStorage.getItem(`searchOption_${option}`);
if (savedState !== null) {
state.searchOptions[option] = savedState === 'true';
tag.classList.toggle('active', state.searchOptions[option]);
}
});
// Ensure at least one search option is selected
this.validateSearchOptions();
// Close panel when clicking outside
document.addEventListener('click', (e) => {
if (this.searchOptionsPanel &&
!this.searchOptionsPanel.contains(e.target) &&
e.target !== this.searchOptionsToggle &&
!this.searchOptionsToggle.contains(e.target)) {
this.closeSearchOptionsPanel();
}
});
}
// Add method to validate search options
validateSearchOptions() {
const hasActiveOption = Object.values(state.searchOptions)
.some(value => value === true && value !== state.searchOptions.recursive);
// If no search options are active, activate at least one default option
if (!hasActiveOption) {
state.searchOptions.filename = true;
localStorage.setItem('searchOption_filename', 'true');
// Update UI to match
const fileNameTag = document.querySelector('.search-option-tag[data-option="filename"]');
if (fileNameTag) {
fileNameTag.classList.add('active');
}
}
}
toggleSearchOptionsPanel() {
if (this.searchOptionsPanel) {
const isHidden = this.searchOptionsPanel.classList.contains('hidden');
if (isHidden) {
this.searchOptionsPanel.classList.remove('hidden');
this.searchOptionsToggle.classList.add('active');
} else {
this.closeSearchOptionsPanel();
}
}
}
closeSearchOptionsPanel() {
if (this.searchOptionsPanel) {
this.searchOptionsPanel.classList.add('hidden');
this.searchOptionsToggle.classList.remove('active');
}
}
createClearButton() {
// Create clear button
const clearButton = document.createElement('button');
clearButton.className = 'search-clear';
clearButton.innerHTML = '<i class="fas fa-times"></i>';
clearButton.title = 'Clear search';
// Add click handler
clearButton.addEventListener('click', () => {
this.searchInput.value = '';
this.currentSearchTerm = '';
this.updateClearButtonVisibility();
resetAndReload();
});
// Insert after search input
this.searchInput.parentNode.appendChild(clearButton);
this.clearButton = clearButton;
// Set initial visibility
this.updateClearButtonVisibility();
}
updateClearButtonVisibility() {
if (this.clearButton) {
this.clearButton.classList.toggle('visible', this.searchInput.value.length > 0);
}
}
handleSearch(event) {
if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout);
}
this.searchDebounceTimeout = setTimeout(async () => {
const searchTerm = event.target.value.trim().toLowerCase();
if (searchTerm !== this.currentSearchTerm && !this.isSearching) {
this.currentSearchTerm = searchTerm;
await this.performSearch(searchTerm);
}
}, 250);
}
async performSearch(searchTerm) {
const grid = document.getElementById('loraGrid');
if (!searchTerm) {
state.currentPage = 1;
await resetAndReload();
return;
}
try {
this.isSearching = true;
state.loadingManager.showSimpleLoading('Searching...');
state.currentPage = 1;
state.hasMore = true;
const url = new URL('/api/loras', window.location.origin);
url.searchParams.set('page', '1');
url.searchParams.set('page_size', '20');
url.searchParams.set('sort_by', state.sortBy);
url.searchParams.set('search', searchTerm);
url.searchParams.set('fuzzy', 'true');
// Add search options
url.searchParams.set('search_filename', state.searchOptions.filename.toString());
url.searchParams.set('search_modelname', state.searchOptions.modelname.toString());
url.searchParams.set('search_tags', state.searchOptions.tags.toString());
// Always send folder parameter if there is an active folder
if (state.activeFolder) {
url.searchParams.set('folder', state.activeFolder);
// Add recursive parameter when recursive search is enabled
url.searchParams.set('recursive', state.searchOptions.recursive.toString());
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
if (searchTerm === this.currentSearchTerm) {
grid.innerHTML = '';
if (data.items.length === 0) {
grid.innerHTML = '<div class="no-results">No matching loras found</div>';
state.hasMore = false;
} else {
appendLoraCards(data.items);
state.hasMore = state.currentPage < data.total_pages;
state.currentPage++;
}
}
} catch (error) {
console.error('Search error:', error);
showToast('Search failed', 'error');
} finally {
this.isSearching = false;
state.loadingManager.hide();
}
}
}

View File

@@ -0,0 +1,127 @@
/**
* Utility functions for localStorage with namespacing to avoid conflicts
* with other ComfyUI extensions or the main application
*/
// Namespace prefix for all localStorage keys
const STORAGE_PREFIX = 'lora_manager_';
/**
* Get an item from localStorage with namespace support and fallback to legacy keys
* @param {string} key - The key without prefix
* @param {any} defaultValue - Default value if key doesn't exist
* @returns {any} The stored value or defaultValue
*/
export function getStorageItem(key, defaultValue = null) {
// Try with prefix first
const prefixedValue = localStorage.getItem(STORAGE_PREFIX + key);
if (prefixedValue !== null) {
// If it's a JSON string, parse it
try {
return JSON.parse(prefixedValue);
} catch (e) {
return prefixedValue;
}
}
// Fallback to legacy key (without prefix)
const legacyValue = localStorage.getItem(key);
if (legacyValue !== null) {
// If found in legacy storage, migrate it to prefixed storage
try {
const parsedValue = JSON.parse(legacyValue);
setStorageItem(key, parsedValue);
return parsedValue;
} catch (e) {
setStorageItem(key, legacyValue);
return legacyValue;
}
}
// Return default value if neither prefixed nor legacy key exists
return defaultValue;
}
/**
* Set an item in localStorage with namespace prefix
* @param {string} key - The key without prefix
* @param {any} value - The value to store
*/
export function setStorageItem(key, value) {
const prefixedKey = STORAGE_PREFIX + key;
// Convert objects and arrays to JSON strings
if (typeof value === 'object' && value !== null) {
localStorage.setItem(prefixedKey, JSON.stringify(value));
} else {
localStorage.setItem(prefixedKey, value);
}
}
/**
* Remove an item from localStorage (both prefixed and legacy)
* @param {string} key - The key without prefix
*/
export function removeStorageItem(key) {
localStorage.removeItem(STORAGE_PREFIX + key);
localStorage.removeItem(key); // Also remove legacy key
}
/**
* Migrate all existing localStorage items to use the prefix
* This should be called once during application initialization
*/
export function migrateStorageItems() {
// Check if migration has already been performed
if (localStorage.getItem(STORAGE_PREFIX + 'migration_completed')) {
console.log('Lora Manager: Storage migration already completed');
return;
}
// List of known keys used in the application
const knownKeys = [
'nsfwBlurLevel',
'theme',
'activeFolder',
'folderTagsCollapsed',
'settings',
'loras_filters',
'recipes_filters',
'checkpoints_filters',
'loras_search_prefs',
'recipes_search_prefs',
'checkpoints_search_prefs',
'show_update_notifications',
'last_update_check'
];
// Migrate each known key
knownKeys.forEach(key => {
const prefixedKey = STORAGE_PREFIX + key;
// Only migrate if the prefixed key doesn't already exist
if (localStorage.getItem(prefixedKey) === null) {
const value = localStorage.getItem(key);
if (value !== null) {
try {
// Try to parse as JSON first
const parsedValue = JSON.parse(value);
setStorageItem(key, parsedValue);
} catch (e) {
// If not JSON, store as is
setStorageItem(key, value);
}
// We can optionally remove the old key after migration
localStorage.removeItem(key);
}
}
});
// Mark migration as completed
localStorage.setItem(STORAGE_PREFIX + 'migration_completed', 'true');
console.log('Lora Manager: Storage migration completed');
}

View File

@@ -1,15 +1,59 @@
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
export function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.append(toast);
// Get or create toast container
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'toast-container';
document.body.append(toastContainer);
}
toastContainer.append(toast);
// Calculate vertical position for stacked toasts
const existingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
const toastIndex = existingToasts.indexOf(toast);
const topOffset = 20; // Base offset from top
const spacing = 10; // Space between toasts
// Set position based on existing toasts
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
requestAnimationFrame(() => {
toast.classList.add('show');
setTimeout(() => toast.remove(), 2300);
// Set timeout based on type
let timeout = 2000; // Default (info)
if (type === 'warning' || type === 'error') {
timeout = 5000;
}
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => {
toast.remove();
// Reposition remaining toasts
if (toastContainer) {
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
remainingToasts.forEach((t, index) => {
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
});
// Remove container if empty
if (remainingToasts.length === 0) {
toastContainer.remove();
}
}
});
}, timeout);
});
}
@@ -27,7 +71,7 @@ export function lazyLoadImages() {
}
export function restoreFolderFilter() {
const activeFolder = localStorage.getItem('activeFolder');
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
if (folderTag) {
folderTag.classList.add('active');
@@ -36,13 +80,13 @@ export function restoreFolderFilter() {
}
export function initTheme() {
document.body.dataset.theme = localStorage.getItem('theme') || 'dark';
document.body.dataset.theme = getStorageItem('theme') || 'dark';
}
export function toggleTheme() {
const theme = document.body.dataset.theme === 'light' ? 'dark' : 'light';
document.body.dataset.theme = theme;
localStorage.setItem('theme', theme);
setStorageItem('theme', theme);
}
export function toggleFolder(tag) {
@@ -98,26 +142,101 @@ export function openCivitai(modelName) {
}
}
/**
* Dynamically positions the search options panel and filter panel
* based on the current layout and folder tags container height
*/
export function updatePanelPositions() {
const searchOptionsPanel = document.getElementById('searchOptionsPanel');
const filterPanel = document.getElementById('filterPanel');
if (!searchOptionsPanel && !filterPanel) return;
// Get the header element
const header = document.querySelector('.app-header');
if (!header) return;
// Calculate the position based on the bottom of the header
const headerRect = header.getBoundingClientRect();
const topPosition = headerRect.bottom + 5; // Add 5px padding
// Set the positions
if (searchOptionsPanel) {
searchOptionsPanel.style.top = `${topPosition}px`;
}
if (filterPanel) {
filterPanel.style.top = `${topPosition}px`;
}
// Adjust panel horizontal position based on the search container
const searchContainer = document.querySelector('.header-search');
if (searchContainer) {
const searchRect = searchContainer.getBoundingClientRect();
// Position the search options panel aligned with the search container
if (searchOptionsPanel) {
searchOptionsPanel.style.right = `${window.innerWidth - searchRect.right}px`;
}
// Position the filter panel aligned with the filter button
if (filterPanel) {
const filterButton = document.getElementById('filterButton');
if (filterButton) {
const filterRect = filterButton.getBoundingClientRect();
filterPanel.style.right = `${window.innerWidth - filterRect.right}px`;
}
}
}
}
// Update the toggleFolderTags function
export function toggleFolderTags() {
const folderTags = document.querySelector('.folder-tags');
const btn = document.querySelector('.toggle-folders-btn');
const isCollapsed = folderTags.classList.toggle('collapsed');
const toggleBtn = document.querySelector('.toggle-folders-btn i');
// 更新按钮提示文本
btn.title = isCollapsed ? 'Expand folder tags' : 'Collapse folder tags';
// 保存状态到 localStorage
localStorage.setItem('folderTagsCollapsed', isCollapsed);
if (folderTags) {
folderTags.classList.toggle('collapsed');
if (folderTags.classList.contains('collapsed')) {
// Change icon to indicate folders are hidden
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
setStorageItem('folderTagsCollapsed', 'true');
} else {
// Change icon to indicate folders are visible
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
setStorageItem('folderTagsCollapsed', 'false');
}
// Update panel positions after toggling
// Use a small delay to ensure the DOM has updated
setTimeout(() => {
updatePanelPositions();
}, 50);
}
}
// Add this to your existing initialization code
export function initFolderTagsVisibility() {
const isCollapsed = localStorage.getItem('folderTagsCollapsed') === 'true';
const isCollapsed = getStorageItem('folderTagsCollapsed');
if (isCollapsed) {
const folderTags = document.querySelector('.folder-tags');
const btn = document.querySelector('.toggle-folders-btn');
folderTags.classList.add('collapsed');
btn.title = 'Expand folder tags';
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (folderTags) {
folderTags.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
}
} else {
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
}
}
}
@@ -128,10 +247,13 @@ export function initBackToTop() {
button.title = 'Back to top';
document.body.appendChild(button);
// Get the scrollable container
const scrollContainer = document.querySelector('.page-content');
// Show/hide button based on scroll position
const toggleBackToTop = () => {
const scrollThreshold = window.innerHeight * 0.75;
if (window.scrollY > scrollThreshold) {
const scrollThreshold = window.innerHeight * 0.3;
if (scrollContainer.scrollTop > scrollThreshold) {
button.classList.add('visible');
} else {
button.classList.remove('visible');
@@ -140,14 +262,14 @@ export function initBackToTop() {
// Smooth scroll to top
button.addEventListener('click', () => {
window.scrollTo({
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
});
// Listen for scroll events
window.addEventListener('scroll', toggleBackToTop);
// Listen for scroll events on the scrollable container
scrollContainer.addEventListener('scroll', toggleBackToTop);
// Initial check
toggleBackToTop();

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