Compare commits

...

95 Commits

Author SHA1 Message Date
Will Miao
18cdaabf5e Update release notes and version to v0.8.17, adding new features including duplicate model detection, enhanced URL recipe imports, and improved trigger word control. 2025-06-09 19:07:53 +08:00
Will Miao
787e37b7c6 Add CivitAI re-linking functionality and related UI components. Fixes #216
- Implemented new API endpoints for re-linking models to CivitAI.
- Added context menu options for re-linking in both Lora and Checkpoint context menus.
- Created a modal for user confirmation and input for CivitAI model URL.
- Updated styles for the new modal and context menu items.
- Enhanced error handling and user feedback during the re-linking process.
2025-06-09 17:23:03 +08:00
Will Miao
4e5c8b2dd0 Add help modal functionality and update related UI components 2025-06-09 14:55:18 +08:00
Will Miao
d8ddacde38 Remove 'folder' field from model metadata before saving to file. See #211 2025-06-09 11:26:24 +08:00
Will Miao
bb1e42f0d3 Add restart required icon to example images download location label. See #212 2025-06-08 20:43:10 +08:00
pixelpaws
923669c495 Merge pull request #213 from willmiao/migrate-images
Migrate images
2025-06-08 20:11:37 +08:00
Will Miao
7a4139544c Add method to update model metadata from local example images. Fixes #211 2025-06-08 20:10:36 +08:00
Will Miao
4d6ea0236b Add centralized example images setting and update related UI components 2025-06-08 17:38:46 +08:00
Will Miao
e872a06f22 Refactor MiscRoutes and move example images related api to ExampleImagesRoutes 2025-06-08 14:40:30 +08:00
Will Miao
647bda2160 Add API endpoint and frontend integration for fetching example image files 2025-06-07 22:31:57 +08:00
Will Miao
c1e93d23f3 Merge branch 'migrate-images' of https://github.com/willmiao/ComfyUI-Lora-Manager into migrate-images 2025-06-07 11:32:55 +08:00
Will Miao
c96550cc68 Enhance migration and download processes: add backend path update and prevent duplicate completion toasts 2025-06-07 11:29:53 +08:00
Will Miao
b1015ecdc5 Add migration functionality for example images: implement API endpoint and UI controls 2025-06-07 11:27:25 +08:00
Will Miao
f1b928a037 Add migration functionality for example images: implement API endpoint and UI controls 2025-06-07 09:34:07 +08:00
Will Miao
16c312c90b Fix version description not showing. Fixes #210 2025-06-07 01:29:38 +08:00
Will Miao
110ffd0118 Refactor modal close behavior: ensure consistent handling of closeOnOutsideClick option across multiple modals. 2025-06-06 10:32:18 +08:00
Will Miao
35ad872419 Enhance duplicates management: add help tooltip for duplicate groups and improve responsive styling for banners and groups. 2025-06-05 15:06:53 +08:00
Will Miao
9b943cf2b8 Update custom node icon 2025-06-05 06:48:48 +08:00
Will Miao
9d1b357e64 Enhance cache validation logic: add logging for version and model type mismatches, and relax directory structure checks to improve cache validity. 2025-06-04 20:47:14 +08:00
Will Miao
9fc2fb4d17 Enhance model caching and exclusion functionality: update cache version, add excluded models to cache data, and ensure cache is saved to disk after model exclusion and deletion. 2025-06-04 18:38:45 +08:00
Will Miao
641fa8a3d9 Enhance duplicates mode functionality: add toggle for entering/exiting mode, improve exit button styling, and manage control button states during duplicates mode. 2025-06-04 16:46:57 +08:00
Will Miao
add9269706 Enhance duplicate mode exit logic: hide duplicates banner, clear model grid, and re-enable virtual scrolling. Improve spacer element handling in VirtualScroller by recreating it if not found in the DOM. 2025-06-04 16:05:57 +08:00
Will Miao
1a01c4a344 Refactor trigger words UI handling: improve event listener management, restore original words on cancel, and enhance dropdown update logic. See #147 2025-06-04 15:02:13 +08:00
Will Miao
b4e7feed06 Enhance trained words extraction and display: include class tokens in response and update UI accordingly. See #147 2025-06-04 12:04:38 +08:00
Will Miao
4b96c650eb Enhance example image handling: improve filename extraction and fallback for local images 2025-06-04 11:30:56 +08:00
Will Miao
107aef3785 Enhance SaveImage and TriggerWordToggle: add tooltips for parameters to improve user guidance 2025-06-03 19:40:01 +08:00
Will Miao
b49807824f Fix optimizeExampleImages setting in SettingsManager 2025-06-03 18:10:43 +08:00
Will Miao
e5ef2ef8b5 Add default_active parameter to TriggerWordToggle for controlling default state 2025-06-03 17:45:52 +08:00
Will Miao
88779ed56c Enhance Lora Manager widget: add configurable window size for Shift+Click behavior 2025-06-03 16:25:31 +08:00
Will Miao
8b59fb6adc Refactor ShowcaseView and uiHelpers for improved image/video handling
- Moved getLocalExampleImageUrl function to uiHelpers.js for better modularity.
- Updated ShowcaseView.js to utilize the new structure for local and fallback URLs.
- Enhanced lazy loading functions to support both primary and fallback URLs for images and videos.
- Simplified metadata panel generation in ShowcaseView.js.
- Improved showcase toggle functionality and added initialization for lazy loading and metadata handlers.
2025-06-03 16:06:54 +08:00
Will Miao
7945647b0b Refactor core application and recipe manager: remove lazy loading functionality and clean up imports in uiHelpers. 2025-06-03 15:40:51 +08:00
Will Miao
2d39b84806 Add CivitaiApiMetadataParser and improve recipe parsing logic for Civitai images. Also fixes #197
Additional info: Now prioritizes using the Civitai Images API to fetch image and generation metadata. Even NSFW images can now be imported via URL.
2025-06-03 14:58:43 +08:00
Will Miao
e151a19fcf Implement bulk operations for LoRAs: add send to workflow and bulk delete functionality with modal confirmation. 2025-06-03 07:44:52 +08:00
Will Miao
99d2ba26b9 Add API endpoint for fetching trained words and implement dropdown suggestions in the trigger words editor. See #147 2025-06-02 17:04:33 +08:00
Will Miao
396924f4cc Add badge for duplicate count and update logic in ModelDuplicatesManager and PageControls 2025-06-02 09:42:28 +08:00
Will Miao
7545312229 Add bulk delete endpoint for checkpoints and enhance ModelDuplicatesManager for better handling of model types 2025-06-02 08:54:31 +08:00
Will Miao
26f9779fbf Add bulk delete functionality for loras and implement model duplicates management. See #198
- Introduced a new API endpoint for bulk deleting loras.
- Added ModelDuplicatesManager to handle duplicate models for loras and checkpoints.
- Implemented UI components for displaying duplicates and managing selections.
- Enhanced controls with a button for finding duplicates.
- Updated templates to include a duplicates banner and associated actions.
2025-06-02 08:08:45 +08:00
Will Miao
0bd62eef3a Add endpoints for finding duplicate loras and filename conflicts; implement tracking for duplicates in ModelHashIndex and update ModelScanner to handle new data structures. 2025-05-31 20:50:51 +08:00
Will Miao
e06d15f508 Remove LoraHashIndex class and related functionality to streamline codebase. 2025-05-31 20:25:12 +08:00
Will Miao
aa1ee96bc9 Add versioning and history tracking to usage statistics. Implement backup and conversion for old stats format, enhancing data structure for checkpoints and loras. 2025-05-31 16:38:18 +08:00
Will Miao
355c73512d Enhance modal close behavior by tracking mouse events on the background. Implement logic to close modals only if mouseup occurs on the background after mousedown, improving user experience. 2025-05-31 08:53:20 +08:00
Will Miao
0daf9d92ff Update version to 0.8.16 and enhance release notes with new features, improvements, and bug fixes. 2025-05-30 21:04:24 +08:00
Will Miao
37de26ce25 Enhance Lora code update handling for browser and desktop modes. Implement broadcast support for Lora Loader nodes and improve node ID management in the workflow. 2025-05-30 20:12:38 +08:00
Will Miao
0eaef7e7a0 Refactor extension name for consistency in usage statistics tracking 2025-05-30 17:30:29 +08:00
Will Miao
8063cee3cd Add rename functionality for checkpoint and LoRA files with loading indicators 2025-05-30 16:38:18 +08:00
Will Miao
cbb25b4ac0 Enhance model metadata saving functionality with loading indicators and improved validation. Refactor editing logic for better user experience in both checkpoint and LoRA modals. Fixes #200 2025-05-30 16:30:01 +08:00
Will Miao
c62206a157 Add preprocessing for MessagePack serialization to handle large integers. See #201 2025-05-30 10:55:48 +08:00
Will Miao
09832141d0 Add functionality to open example images folder for models 2025-05-30 09:42:36 +08:00
Will Miao
bf8e121a10 Add functionality to copy LoRA syntax and update event handling for copy action 2025-05-30 09:02:17 +08:00
Will Miao
68568073ec Refactor model caching logic to streamline adding models and ensure disk persistence 2025-05-30 07:34:39 +08:00
Will Miao
ec36524c35 Add Civitai image URL optimization and simplify image processing logic 2025-05-29 22:20:16 +08:00
Will Miao
67acd9fd2c Relax cache validation by removing strict modification time checks, allowing users to refresh the cache as needed. 2025-05-29 20:58:06 +08:00
Will Miao
f7be5c8d25 Change log level to info for cache save operation and ensure cache is saved to disk after updating preview URL 2025-05-29 20:09:58 +08:00
Will Miao
ceacac75e0 Increase minimum width of dropdown menu for improved usability 2025-05-29 15:55:14 +08:00
Will Miao
bae66f94e8 Add full rebuild option to model refresh functionality and enhance dropdown controls 2025-05-29 15:51:45 +08:00
Will Miao
ddf132bd78 Add cache management feature: implement clear cache API and modal confirmation 2025-05-29 14:36:13 +08:00
Will Miao
afb012029f Enhance get_cached_data method: improve cache rebuilding logic and ensure cache is saved after initialization 2025-05-29 08:50:17 +08:00
Will Miao
651e14c8c3 Enhance get_cached_data method: add rebuild_cache option for improved cache management 2025-05-29 08:36:18 +08:00
Will Miao
e7c626eb5f Add MessagePack support for efficient cache serialization and update dependencies 2025-05-28 22:30:06 +08:00
pixelpaws
a0b0d40a19 Update README.md 2025-05-27 22:28:26 +08:00
Will Miao
42e3ab9e27 Update tutorial links in README: replace outdated video links with the latest tutorial 2025-05-27 19:24:22 +08:00
Will Miao
6e5f333364 Enhance model file moving logic: support moving associated files and handle metadata paths 2025-05-27 05:41:39 +08:00
Will Miao
f33a9abe60 Limit Lora hash display to first 10 characters and improve WebP metadata handling 2025-05-22 16:29:12 +08:00
Will Miao
7f1bbdd615 Remove debug print statement for primary sampler ID in MetadataProcessor 2025-05-22 16:01:55 +08:00
Will Miao
d3bf8eaceb Add container padding properties to VirtualScroller and adjust card padding 2025-05-22 15:23:32 +08:00
Will Miao
b9c9d602de Enhance download modals: auto-focus on URL input and auto-select version if only one available 2025-05-22 11:07:52 +08:00
Will Miao
b25fbd6e24 Refactor modal styles: remove model name field and adjust margin for modal content header 2025-05-22 10:02:13 +08:00
Will Miao
6052608a4e Update version to 0.8.15-bugfix in pyproject.toml 2025-05-22 04:42:12 +08:00
Will Miao
a073b82751 Enhance WebP image saving: add EXIF data and workflow metadata support. Fixes #193 2025-05-21 19:17:12 +08:00
Will Miao
8250acdfb5 Add creator information display to Lora and Checkpoint modals. #186 2025-05-21 15:31:23 +08:00
Will Miao
8e1f73a34e Refactor display density settings: replace compact mode with display density option and update related UI components 2025-05-20 19:35:41 +08:00
Will Miao
50704bc882 Enhance error handling and input validation in fetch_and_update_model method 2025-05-20 13:57:22 +08:00
Will Miao
35d34e3513 Revert db0b49c427 Refactor load_metadata to use save_metadata for updating metadata files 2025-05-19 21:46:01 +08:00
Will Miao
ea834f3de6 Revert "Enhance metadata processing in ModelScanner: prevent intermediate writes, restore missing civitai data, and ensure base_model consistency. #185"
This reverts commit 99b36442bb.
2025-05-19 21:39:31 +08:00
Will Miao
11aedde72f Fix save_metadata call to await asynchronous execution in load_metadata function. Fixes #192 2025-05-19 15:01:56 +08:00
Will Miao
488654abc8 Improve card layout responsiveness and scrolling behavior 2025-05-18 07:49:39 +08:00
Will Miao
da1be0dc65 Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-05-17 15:40:23 +08:00
Will Miao
d0c728a339 Enhance node tracing logic and improve prompt handling in metadata processing. See #189 2025-05-17 15:40:05 +08:00
pixelpaws
66c66c4d9b Update README.md 2025-05-16 17:08:23 +08:00
Will Miao
4882721387 Update version to 0.8.15 and add release notes for enhanced features and improvements 2025-05-16 16:13:37 +08:00
Will Miao
06a8850c0c Add more wiki images 2025-05-16 15:54:52 +08:00
Will Miao
370aa06c67 Refactor duplicates banner styles for improved layout and responsiveness 2025-05-16 15:47:08 +08:00
Will Miao
c9fa0564e7 Update images 2025-05-16 11:36:37 +08:00
Will Miao
2ba7a0ceba Add keyboard navigation support and related styles for enhanced user experience 2025-05-15 20:17:57 +08:00
Will Miao
276aedfbb9 Set 'from_civitai' flag to True when updating local metadata with CivitAI data 2025-05-15 16:50:32 +08:00
Will Miao
c193c75674 Fix misleading error message for invalid civitai api key or early access deny 2025-05-15 13:46:46 +08:00
Will Miao
a562ba3746 Fix TriggerWord Toggle not updating when all LoRAs are disabled 2025-05-15 10:30:46 +08:00
Will Miao
2fedd572ff Add header drag functionality for proportional strength adjustment of LoRAs 2025-05-15 10:12:46 +08:00
Will Miao
db0b49c427 Refactor load_metadata to use save_metadata for updating metadata files 2025-05-15 09:49:30 +08:00
Will Miao
03a6f8111c Add functionality to copy and send LoRA/Recipe syntax to workflow
- Implemented copy functionality for LoRA and Recipe syntax in context menus.
- Added options to send LoRA and Recipe to workflow in both append and replace modes.
- Updated HTML templates to include new context menu items for sending actions.
2025-05-15 07:01:50 +08:00
Will Miao
925ad7b3e0 Add user-select: none to prevent text selection on cards and control elements 2025-05-15 05:36:56 +08:00
Will Miao
bf793d5b8b Refactor Lora and Recipe card event handling: replace copy functionality with direct send to ComfyUI workflow, update UI elements, and enhance sendLoraToWorkflow to support recipe syntax. 2025-05-14 23:51:00 +08:00
Will Miao
64a906ca5e Add Lora syntax send to comfyui functionality: implement API endpoint and frontend integration for sending and updating LoRA codes in ComfyUI nodes. 2025-05-14 21:09:36 +08:00
Will Miao
99b36442bb Enhance metadata processing in ModelScanner: prevent intermediate writes, restore missing civitai data, and ensure base_model consistency. #185 2025-05-14 19:16:58 +08:00
Will Miao
3c5164d510 Update screenshot 2025-05-13 22:56:51 +08:00
110 changed files with 9068 additions and 2885 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ settings.json
output/* output/*
py/run_test.py py/run_test.py
.vscode/ .vscode/
cache/

View File

@@ -10,16 +10,43 @@ A comprehensive toolset that streamlines organizing, downloading, and applying L
![Interface Preview](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/screenshot.png) ![Interface Preview](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/screenshot.png)
One-click Integration:
![One-Click Integration](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/one-click-send.jpg)
## 📺 Tutorial: One-Click LoRA Integration ## 📺 Tutorial: One-Click LoRA Integration
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature: 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) [![One-Click LoRA Integration Tutorial](https://img.youtube.com/vi/hvKw31YpE-U/0.jpg)](https://youtu.be/hvKw31YpE-U)
[![LoRA Manager v0.8.10 - Checkpoint Management, Standalone Mode, and New Features!](https://img.youtube.com/vi/VKvTlCB78h4/0.jpg)](https://youtu.be/VKvTlCB78h4)
--- ---
## Release Notes ## Release Notes
### v0.8.17
* **Duplicate Model Detection** - Added "Find Duplicates" functionality for LoRAs and checkpoints using model file hash detection, enabling convenient viewing and batch deletion of duplicate models
* **Enhanced URL Recipe Imports** - Optimized import recipe via URL functionality using CivitAI API calls instead of web scraping, now supporting all rated images (including NSFW) for recipe imports
* **Improved TriggerWord Control** - Enhanced TriggerWord Toggle node with new default_active switch to set the initial state (active/inactive) when trigger words are added
* **Centralized Example Management** - Added "Migrate Existing Example Images" feature to consolidate downloaded example images from model folders into central storage with customizable naming patterns
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
### v0.8.16
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart
* **Customizable Display Density** - Replaced compact mode with adjustable display density settings for personalized layout customization
* **Model Creator Information** - Added creator details to model information panels for better attribution
* **Improved WebP Support** - Enhanced Save Image node with workflow embedding capability for WebP format images
* **Direct Example Access** - Added "Open Example Images Folder" button to card interfaces for convenient browsing of downloaded model examples
* **Enhanced Compatibility** - Full ComfyUI Desktop support for "Send lora or recipe to workflow" functionality
* **Cache Management** - Added settings to clear existing cache files when needed
* **Bug Fixes & Stability** - Various improvements for overall reliability and performance
### v0.8.15
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
* **Improved LoRA Loader Controls** - Added header drag functionality for proportional strength adjustment of all LoRAs simultaneously (including CLIP strengths when expanded)
* **Keyboard Navigation Support** - Implemented Page Up/Down for page scrolling, Home key to jump to top, and End key to jump to bottom for faster browsing through large collections
### v0.8.14 ### v0.8.14
* **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience * **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience
* **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K) * **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K)
@@ -49,71 +76,6 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
* **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module * **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module
* **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections * **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections
### v0.8.9
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
* **Smoother Page Transitions** - Optimized interface switching between pages, eliminating flash issues particularly noticeable in dark theme
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
### v0.8.8
* **Real-time TriggerWord Updates** - Enhanced TriggerWord Toggle node to instantly update when connected Lora Loader or Lora Stacker nodes change, without requiring workflow execution
* **Optimized Metadata Recovery** - Improved utilization of existing .civitai.info files for faster initialization and preservation of metadata from models deleted from CivitAI
* **Migration Acceleration** - Further speed improvements for users transitioning from A1111/Forge environments
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
### v0.8.7
* **Enhanced Context Menu** - Added comprehensive context menu functionality to Recipes and Checkpoints pages for improved workflow
* **Interactive LoRA Strength Control** - Implemented drag functionality in LoRA Loader for intuitive strength adjustment
* **Metadata Collector Overhaul** - Rebuilt metadata collection system with optimized architecture for better performance
* **Improved Save Image Node** - Enhanced metadata capture and image saving performance with the new metadata collector
* **Streamlined Recipe Saving** - Optimized Save Recipe functionality to work independently without requiring Preview Image nodes
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
### v0.8.6 Major Update
* **Checkpoint Management** - Added comprehensive management for model checkpoints including scanning, searching, filtering, and deletion
* **Enhanced Metadata Support** - New capabilities for retrieving and managing checkpoint metadata with improved operations
* **Improved Initial Loading** - Optimized cache initialization with visual progress indicators for better user experience
### v0.8.5
* **Enhanced LoRA & Recipe Connectivity** - Added Recipes tab in LoRA details to see all recipes using a specific LoRA
* **Improved Navigation** - New shortcuts to jump between related LoRAs and Recipes with one-click navigation
* **Video Preview Controls** - Added "Autoplay Videos on Hover" setting to optimize performance and reduce resource usage
* **UI Experience Refinements** - Smoother transitions between related content pages
### 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.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.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.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.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) [View Update History](./update_logs.md)
--- ---
@@ -178,7 +140,7 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
### Option 2: **Portable Standalone Edition** (No ComfyUI required) ### Option 2: **Portable Standalone Edition** (No ComfyUI required)
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.10/lora_manager_portable.7z) 1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.15/lora_manager_portable.7z)
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder 2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key 3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
4. Run run.bat 4. Run run.bat

View File

@@ -7,6 +7,7 @@ from .routes.recipe_routes import RecipeRoutes
from .routes.checkpoints_routes import CheckpointsRoutes from .routes.checkpoints_routes import CheckpointsRoutes
from .routes.update_routes import UpdateRoutes from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes from .routes.misc_routes import MiscRoutes
from .routes.example_images_routes import ExampleImagesRoutes
from .services.service_registry import ServiceRegistry from .services.service_registry import ServiceRegistry
from .services.settings_manager import settings from .services.settings_manager import settings
import logging import logging
@@ -112,6 +113,7 @@ class LoraManager:
RecipeRoutes.setup_routes(app) RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app) # Register miscellaneous routes MiscRoutes.setup_routes(app) # Register miscellaneous routes
ExampleImagesRoutes.setup_routes(app) # Register example images routes
# Schedule service initialization # Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services()) app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -1,7 +1,5 @@
"""Constants used by the metadata collector""" """Constants used by the metadata collector"""
# Metadata collection constants
# Metadata categories # Metadata categories
MODELS = "models" MODELS = "models"
PROMPTS = "prompts" PROMPTS = "prompts"
@@ -9,6 +7,7 @@ SAMPLING = "sampling"
LORAS = "loras" LORAS = "loras"
SIZE = "size" SIZE = "size"
IMAGES = "images" IMAGES = "images"
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
# Complete list of categories to track # Complete list of categories to track
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES] METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES]

View File

@@ -4,33 +4,109 @@ import sys
# Check if running in standalone mode # Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules standalone_mode = 'nodes' not in sys.modules
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IS_SAMPLER
class MetadataProcessor: class MetadataProcessor:
"""Process and format collected metadata""" """Process and format collected metadata"""
@staticmethod @staticmethod
def find_primary_sampler(metadata): def find_primary_sampler(metadata, downstream_id=None):
"""Find the primary KSampler node (with highest denoise value)""" """
Find the primary KSampler node that executed before the given downstream node
Parameters:
- metadata: The workflow metadata
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
"""
# If we have a downstream_id and execution_order, use it to narrow down potential samplers
if downstream_id and "execution_order" in metadata:
execution_order = metadata["execution_order"]
# Find the index of the downstream node in the execution order
if downstream_id in execution_order:
downstream_index = execution_order.index(downstream_id)
# Extract all sampler nodes that executed before the downstream node
candidate_samplers = {}
for i in range(downstream_index):
node_id = execution_order[i]
# Use IS_SAMPLER flag to identify true sampler nodes
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
candidate_samplers[node_id] = metadata[SAMPLING][node_id]
# If we found candidate samplers, apply primary sampler logic to these candidates only
if candidate_samplers:
# Collect potential primary samplers based on different criteria
custom_advanced_samplers = []
advanced_add_noise_samplers = []
high_denoise_samplers = []
max_denoise = -1
high_denoise_id = None
# First, check for SamplerCustomAdvanced among candidates
prompt = metadata.get("current_prompt")
if prompt and prompt.original_prompt:
for node_id in candidate_samplers:
node_info = prompt.original_prompt.get(node_id, {})
if node_info.get("class_type") == "SamplerCustomAdvanced":
custom_advanced_samplers.append(node_id)
# Next, check for KSamplerAdvanced with add_noise="enable" among candidates
for node_id, sampler_info in candidate_samplers.items():
parameters = sampler_info.get("parameters", {})
add_noise = parameters.get("add_noise")
if add_noise == "enable":
advanced_add_noise_samplers.append(node_id)
# Find the sampler with highest denoise value among candidates
for node_id, sampler_info in candidate_samplers.items():
parameters = sampler_info.get("parameters", {})
denoise = parameters.get("denoise")
if denoise is not None and denoise > max_denoise:
max_denoise = denoise
high_denoise_id = node_id
if high_denoise_id:
high_denoise_samplers.append(high_denoise_id)
# Combine all potential primary samplers
potential_samplers = custom_advanced_samplers + advanced_add_noise_samplers + high_denoise_samplers
# Find the most recent potential primary sampler (closest to downstream node)
for i in range(downstream_index - 1, -1, -1):
node_id = execution_order[i]
if node_id in potential_samplers:
return node_id, candidate_samplers[node_id]
# If no potential sampler found from our criteria, return the most recent sampler
if candidate_samplers:
for i in range(downstream_index - 1, -1, -1):
node_id = execution_order[i]
if node_id in candidate_samplers:
return node_id, candidate_samplers[node_id]
# If no downstream_id provided or no suitable sampler found, fall back to original logic
primary_sampler = None primary_sampler = None
primary_sampler_id = None primary_sampler_id = None
max_denoise = -1 # Track the highest denoise value max_denoise = -1
# First, check for SamplerCustomAdvanced # First, check for SamplerCustomAdvanced
prompt = metadata.get("current_prompt") prompt = metadata.get("current_prompt")
if prompt and prompt.original_prompt: if prompt and prompt.original_prompt:
for node_id, node_info in prompt.original_prompt.items(): for node_id, node_info in prompt.original_prompt.items():
if node_info.get("class_type") == "SamplerCustomAdvanced": if node_info.get("class_type") == "SamplerCustomAdvanced":
# Found a SamplerCustomAdvanced node # Check if the node is in SAMPLING and has IS_SAMPLER flag
if node_id in metadata.get(SAMPLING, {}): if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
return node_id, metadata[SAMPLING][node_id] return node_id, metadata[SAMPLING][node_id]
# Next, check for KSamplerAdvanced with add_noise="enable" # Next, check for KSamplerAdvanced with add_noise="enable" using IS_SAMPLER flag
for node_id, sampler_info in metadata.get(SAMPLING, {}).items(): for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
# Skip if not marked as a sampler
if not sampler_info.get(IS_SAMPLER, False):
continue
parameters = sampler_info.get("parameters", {}) parameters = sampler_info.get("parameters", {})
add_noise = parameters.get("add_noise") add_noise = parameters.get("add_noise")
# If add_noise is "enable", this is likely the primary sampler for KSamplerAdvanced
if add_noise == "enable": if add_noise == "enable":
primary_sampler = sampler_info primary_sampler = sampler_info
primary_sampler_id = node_id primary_sampler_id = node_id
@@ -39,10 +115,12 @@ class MetadataProcessor:
# If no specialized sampler found, find the sampler with highest denoise value # If no specialized sampler found, find the sampler with highest denoise value
if primary_sampler is None: if primary_sampler is None:
for node_id, sampler_info in metadata.get(SAMPLING, {}).items(): for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
# Skip if not marked as a sampler
if not sampler_info.get(IS_SAMPLER, False):
continue
parameters = sampler_info.get("parameters", {}) parameters = sampler_info.get("parameters", {})
denoise = parameters.get("denoise") denoise = parameters.get("denoise")
# If denoise exists and is higher than current max, use this sampler
if denoise is not None and denoise > max_denoise: if denoise is not None and denoise > max_denoise:
max_denoise = denoise max_denoise = denoise
primary_sampler = sampler_info primary_sampler = sampler_info
@@ -74,13 +152,18 @@ class MetadataProcessor:
current_node_id = node_id current_node_id = node_id
current_input = input_name current_input = input_name
# If we're just tracing to origin (no target_class), keep track of the last valid node
last_valid_node = None
while current_depth < max_depth: while current_depth < max_depth:
if current_node_id not in prompt.original_prompt: if current_node_id not in prompt.original_prompt:
return None return last_valid_node if not target_class else None
node_inputs = prompt.original_prompt[current_node_id].get("inputs", {}) node_inputs = prompt.original_prompt[current_node_id].get("inputs", {})
if current_input not in node_inputs: if current_input not in node_inputs:
return None # We've reached a node without the specified input - this is our origin node
# if we're not looking for a specific target_class
return current_node_id if not target_class else None
input_value = node_inputs[current_input] input_value = node_inputs[current_input]
# Input connections are formatted as [node_id, output_index] # Input connections are formatted as [node_id, output_index]
@@ -91,9 +174,9 @@ class MetadataProcessor:
if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class: if target_class and prompt.original_prompt[found_node_id].get("class_type") == target_class:
return found_node_id return found_node_id
# If we're not looking for a specific class or haven't found it yet # If we're not looking for a specific class, update the last valid node
if not target_class: if not target_class:
return found_node_id last_valid_node = found_node_id
# Continue tracing through intermediate nodes # Continue tracing through intermediate nodes
current_node_id = found_node_id current_node_id = found_node_id
@@ -101,16 +184,17 @@ class MetadataProcessor:
if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}): if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}):
current_input = "conditioning" current_input = "conditioning"
else: else:
# If there's no "conditioning" input, we can't trace further # If there's no "conditioning" input, return the current node
# if we're not looking for a specific target_class
return found_node_id if not target_class else None return found_node_id if not target_class else None
else: else:
# We've reached a node with no further connections # We've reached a node with no further connections
return None return last_valid_node if not target_class else None
current_depth += 1 current_depth += 1
# If we've reached max depth without finding target_class # If we've reached max depth without finding target_class
return None return last_valid_node if not target_class else None
@staticmethod @staticmethod
def find_primary_checkpoint(metadata): def find_primary_checkpoint(metadata):
@@ -126,8 +210,14 @@ class MetadataProcessor:
return None return None
@staticmethod @staticmethod
def extract_generation_params(metadata): def extract_generation_params(metadata, id=None):
"""Extract generation parameters from metadata using node relationships""" """
Extract generation parameters from metadata using node relationships
Parameters:
- metadata: The workflow metadata
- id: Optional ID of a downstream node to help identify the specific primary sampler
"""
params = { params = {
"prompt": "", "prompt": "",
"negative_prompt": "", "negative_prompt": "",
@@ -147,13 +237,20 @@ class MetadataProcessor:
prompt = metadata.get("current_prompt") prompt = metadata.get("current_prompt")
# Find the primary KSampler node # Find the primary KSampler node
primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata) primary_sampler_id, primary_sampler = MetadataProcessor.find_primary_sampler(metadata, id)
# Directly get checkpoint from metadata instead of tracing # Directly get checkpoint from metadata instead of tracing
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata) checkpoint = MetadataProcessor.find_primary_checkpoint(metadata)
if checkpoint: if checkpoint:
params["checkpoint"] = checkpoint params["checkpoint"] = checkpoint
# Check if guidance parameter exists in any sampling node
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
parameters = sampler_info.get("parameters", {})
if "guidance" in parameters and parameters["guidance"] is not None:
params["guidance"] = parameters["guidance"]
break
if primary_sampler: if primary_sampler:
# Extract sampling parameters # Extract sampling parameters
sampling_params = primary_sampler.get("parameters", {}) sampling_params = primary_sampler.get("parameters", {})
@@ -187,7 +284,7 @@ class MetadataProcessor:
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {}) sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
params["sampler"] = sampler_params.get("sampler_name") params["sampler"] = sampler_params.get("sampler_name")
# 3. Trace guider input for CFGGuider, FluxGuidance and CLIPTextEncode # 3. Trace guider input for CFGGuider and CLIPTextEncode
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5) guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
if guider_node_id and guider_node_id in prompt.original_prompt: if guider_node_id and guider_node_id in prompt.original_prompt:
# Check if the guider node is a CFGGuider # Check if the guider node is a CFGGuider
@@ -207,21 +304,14 @@ class MetadataProcessor:
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}): if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "") params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
else: else:
# Look for FluxGuidance along the guider path positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
flux_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "FluxGuidance", max_depth=5)
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
params["guidance"] = flux_params.get("guidance")
# Find CLIPTextEncode for positive prompt (through conditioning)
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}): if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "") params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
else: else:
# Original tracing for standard samplers # Original tracing for standard samplers
# Trace positive prompt - look specifically for CLIPTextEncode # Trace positive prompt - look specifically for CLIPTextEncode
positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncode", max_depth=10) positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}): if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "") params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
else: else:
@@ -230,20 +320,8 @@ class MetadataProcessor:
if positive_flux_node_id and positive_flux_node_id in metadata.get(PROMPTS, {}): if positive_flux_node_id and positive_flux_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_flux_node_id].get("text", "") params["prompt"] = metadata[PROMPTS][positive_flux_node_id].get("text", "")
# Also extract guidance value if present in the sampling data
if positive_flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][positive_flux_node_id].get("parameters", {})
if "guidance" in flux_params:
params["guidance"] = flux_params.get("guidance")
# Find any FluxGuidance nodes in the positive conditioning path
flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "FluxGuidance", max_depth=5)
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
params["guidance"] = flux_params.get("guidance")
# Trace negative prompt - look specifically for CLIPTextEncode # Trace negative prompt - look specifically for CLIPTextEncode
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", "CLIPTextEncode", max_depth=10) negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}): if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "") params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
@@ -273,13 +351,19 @@ class MetadataProcessor:
return params return params
@staticmethod @staticmethod
def to_dict(metadata): def to_dict(metadata, id=None):
"""Convert extracted metadata to the ComfyUI output.json format""" """
Convert extracted metadata to the ComfyUI output.json format
Parameters:
- metadata: The workflow metadata
- id: Optional ID of a downstream node to help identify the specific primary sampler
"""
if standalone_mode: if standalone_mode:
# Return empty dictionary in standalone mode # Return empty dictionary in standalone mode
return {} return {}
params = MetadataProcessor.extract_generation_params(metadata) params = MetadataProcessor.extract_generation_params(metadata, id)
# Convert all values to strings to match output.json format # Convert all values to strings to match output.json format
for key in params: for key in params:
@@ -289,7 +373,7 @@ class MetadataProcessor:
return params return params
@staticmethod @staticmethod
def to_json(metadata): def to_json(metadata, id=None):
"""Convert metadata to JSON string""" """Convert metadata to JSON string"""
params = MetadataProcessor.to_dict(metadata) params = MetadataProcessor.to_dict(metadata, id)
return json.dumps(params, indent=4) return json.dumps(params, indent=4)

View File

@@ -1,6 +1,6 @@
import os import os
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
class NodeMetadataExtractor: class NodeMetadataExtractor:
@@ -61,7 +61,8 @@ class SamplerExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id] = { metadata[SAMPLING][node_id] = {
"parameters": sampling_params, "parameters": sampling_params,
"node_id": node_id "node_id": node_id,
IS_SAMPLER: True # Add sampler flag
} }
# Extract latent image dimensions if available # Extract latent image dimensions if available
@@ -98,7 +99,8 @@ class KSamplerAdvancedExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id] = { metadata[SAMPLING][node_id] = {
"parameters": sampling_params, "parameters": sampling_params,
"node_id": node_id "node_id": node_id,
IS_SAMPLER: True # Add sampler flag
} }
# Extract latent image dimensions if available # Extract latent image dimensions if available
@@ -269,7 +271,8 @@ class KSamplerSelectExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id] = { metadata[SAMPLING][node_id] = {
"parameters": sampling_params, "parameters": sampling_params,
"node_id": node_id "node_id": node_id,
IS_SAMPLER: False # Mark as non-primary sampler
} }
class BasicSchedulerExtractor(NodeMetadataExtractor): class BasicSchedulerExtractor(NodeMetadataExtractor):
@@ -285,7 +288,8 @@ class BasicSchedulerExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id] = { metadata[SAMPLING][node_id] = {
"parameters": sampling_params, "parameters": sampling_params,
"node_id": node_id "node_id": node_id,
IS_SAMPLER: False # Mark as non-primary sampler
} }
class SamplerCustomAdvancedExtractor(NodeMetadataExtractor): class SamplerCustomAdvancedExtractor(NodeMetadataExtractor):
@@ -303,7 +307,8 @@ class SamplerCustomAdvancedExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id] = { metadata[SAMPLING][node_id] = {
"parameters": sampling_params, "parameters": sampling_params,
"node_id": node_id "node_id": node_id,
IS_SAMPLER: True # Add sampler flag
} }
# Extract latent image dimensions if available # Extract latent image dimensions if available
@@ -338,11 +343,20 @@ class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
clip_l_text = inputs.get("clip_l", "") clip_l_text = inputs.get("clip_l", "")
t5xxl_text = inputs.get("t5xxl", "") t5xxl_text = inputs.get("t5xxl", "")
# Create JSON string with T5 content first, then CLIP-L # If both are empty, use empty string
combined_text = json.dumps({ if not clip_l_text and not t5xxl_text:
"T5": t5xxl_text, combined_text = ""
"CLIP-L": clip_l_text # If one is empty, use the non-empty one
}) elif not clip_l_text:
combined_text = t5xxl_text
elif not t5xxl_text:
combined_text = clip_l_text
# If both have content, use JSON format
else:
combined_text = json.dumps({
"T5": t5xxl_text,
"CLIP-L": clip_l_text
})
metadata[PROMPTS][node_id] = { metadata[PROMPTS][node_id] = {
"text": combined_text, "text": combined_text,
@@ -391,11 +405,13 @@ NODE_EXTRACTORS = {
# Loaders # Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor, "CheckpointLoaderSimple": CheckpointLoaderExtractor,
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor "UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
"LoraLoader": LoraLoaderExtractor, "LoraLoader": LoraLoaderExtractor,
"LoraManagerLoader": LoraLoaderManagerExtractor, "LoraManagerLoader": LoraLoaderManagerExtractor,
# Conditioning # Conditioning
"CLIPTextEncode": CLIPTextEncodeExtractor, "CLIPTextEncode": CLIPTextEncodeExtractor,
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux "CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
# Latent # Latent
"EmptyLatentImage": ImageSizeExtractor, "EmptyLatentImage": ImageSizeExtractor,
# Flux # Flux

View File

@@ -14,20 +14,23 @@ class DebugMetadata:
"required": { "required": {
"images": ("IMAGE",), "images": ("IMAGE",),
}, },
"hidden": {
"id": "UNIQUE_ID",
},
} }
RETURN_TYPES = ("STRING",) RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("metadata_json",) RETURN_NAMES = ("metadata_json",)
FUNCTION = "process_metadata" FUNCTION = "process_metadata"
def process_metadata(self, images): def process_metadata(self, images, id):
try: try:
# Get the current execution context's metadata # Get the current execution context's metadata
from ..metadata_collector import get_metadata from ..metadata_collector import get_metadata
metadata = get_metadata() metadata = get_metadata()
# Use the MetadataProcessor to convert it to JSON string # Use the MetadataProcessor to convert it to JSON string
metadata_json = MetadataProcessor.to_json(metadata) metadata_json = MetadataProcessor.to_json(metadata, id)
return (metadata_json,) return (metadata_json,)
except Exception as e: except Exception as e:

View File

@@ -31,16 +31,36 @@ class SaveImage:
return { return {
"required": { "required": {
"images": ("IMAGE",), "images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "ComfyUI"}), "filename_prefix": ("STRING", {
"file_format": (["png", "jpeg", "webp"],), "default": "ComfyUI",
"tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc."
}),
"file_format": (["png", "jpeg", "webp"], {
"tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality."
}),
}, },
"optional": { "optional": {
"lossless_webp": ("BOOLEAN", {"default": False}), "lossless_webp": ("BOOLEAN", {
"quality": ("INT", {"default": 100, "min": 1, "max": 100}), "default": False,
"embed_workflow": ("BOOLEAN", {"default": False}), "tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss."
"add_counter_to_filename": ("BOOLEAN", {"default": True}), }),
"quality": ("INT", {
"default": 100,
"min": 1,
"max": 100,
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files."
}),
"embed_workflow": ("BOOLEAN", {
"default": False,
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats."
}),
"add_counter_to_filename": ("BOOLEAN", {
"default": True,
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images."
}),
}, },
"hidden": { "hidden": {
"id": "UNIQUE_ID",
"prompt": "PROMPT", "prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO", "extra_pnginfo": "EXTRA_PNGINFO",
}, },
@@ -223,7 +243,7 @@ class SaveImage:
if lora_hashes: if lora_hashes:
lora_hash_parts = [] lora_hash_parts = []
for lora_name, hash_value in lora_hashes.items(): for lora_name, hash_value in lora_hashes.items():
lora_hash_parts.append(f"{lora_name}: {hash_value}") lora_hash_parts.append(f"{lora_name}: {hash_value[:10]}")
if lora_hash_parts: if lora_hash_parts:
params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"") params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"")
@@ -300,14 +320,14 @@ class SaveImage:
return filename return filename
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None, def save_images(self, images, filename_prefix, file_format, id, prompt=None, extra_pnginfo=None,
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True): lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
"""Save images with metadata""" """Save images with metadata"""
results = [] results = []
# Get metadata using the metadata collector # Get metadata using the metadata collector
raw_metadata = get_metadata() raw_metadata = get_metadata()
metadata_dict = MetadataProcessor.to_dict(raw_metadata) metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
# Get or create metadata asynchronously # Get or create metadata asynchronously
metadata = asyncio.run(self.format_metadata(metadata_dict)) metadata = asyncio.run(self.format_metadata(metadata_dict))
@@ -378,14 +398,23 @@ class SaveImage:
print(f"Error adding EXIF data: {e}") print(f"Error adding EXIF data: {e}")
img.save(file_path, format="JPEG", **save_kwargs) img.save(file_path, format="JPEG", **save_kwargs)
elif file_format == "webp": elif file_format == "webp":
# For WebP, also use piexif for metadata try:
if metadata: # For WebP, use piexif for metadata
try: exif_dict = {}
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
exif_bytes = piexif.dump(exif_dict) if metadata:
save_kwargs["exif"] = exif_bytes exif_dict['Exif'] = {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}
except Exception as e:
print(f"Error adding EXIF data: {e}") # Add workflow if needed
if embed_workflow and extra_pnginfo is not None:
workflow_json = json.dumps(extra_pnginfo["workflow"])
exif_dict['0th'] = {piexif.ImageIFD.ImageDescription: "Workflow:" + workflow_json}
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) img.save(file_path, format="WEBP", **save_kwargs)
results.append({ results.append({
@@ -399,7 +428,7 @@ class SaveImage:
return results return results
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None, def process_image(self, images, id, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True): lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
"""Process and save image with metadata""" """Process and save image with metadata"""
# Make sure the output directory exists # Make sure the output directory exists
@@ -416,6 +445,7 @@ class SaveImage:
images, images,
filename_prefix, filename_prefix,
file_format, file_format,
id,
prompt, prompt,
extra_pnginfo, extra_pnginfo,
lossless_webp, lossless_webp,

View File

@@ -16,11 +16,18 @@ class TriggerWordToggle:
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"group_mode": ("BOOLEAN", {"default": True}), "group_mode": ("BOOLEAN", {
"default": True,
"tooltip": "When enabled, treats each group of trigger words as a single toggleable unit."
}),
"default_active": ("BOOLEAN", {
"default": True,
"tooltip": "Sets the default initial state (active or inactive) when trigger words are added."
}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
"hidden": { "hidden": {
"id": "UNIQUE_ID", # 会被 ComfyUI 自动替换为唯一ID "id": "UNIQUE_ID",
}, },
} }
@@ -41,7 +48,7 @@ class TriggerWordToggle:
else: else:
return data return data
def process_trigger_words(self, id, group_mode, **kwargs): def process_trigger_words(self, id, group_mode, default_active, **kwargs):
# Handle both old and new formats for trigger_words # Handle both old and new formats for trigger_words
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words') trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else "" trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""

View File

@@ -7,7 +7,8 @@ from .parsers import (
RecipeFormatParser, RecipeFormatParser,
ComfyMetadataParser, ComfyMetadataParser,
MetaFormatParser, MetaFormatParser,
AutomaticMetadataParser AutomaticMetadataParser,
CivitaiApiMetadataParser
) )
__all__ = [ __all__ = [
@@ -18,5 +19,6 @@ __all__ = [
'RecipeFormatParser', 'RecipeFormatParser',
'ComfyMetadataParser', 'ComfyMetadataParser',
'MetaFormatParser', 'MetaFormatParser',
'AutomaticMetadataParser' 'AutomaticMetadataParser',
'CivitaiApiMetadataParser'
] ]

View File

@@ -5,7 +5,8 @@ from .parsers import (
RecipeFormatParser, RecipeFormatParser,
ComfyMetadataParser, ComfyMetadataParser,
MetaFormatParser, MetaFormatParser,
AutomaticMetadataParser AutomaticMetadataParser,
CivitaiApiMetadataParser
) )
from .base import RecipeMetadataParser from .base import RecipeMetadataParser
@@ -15,29 +16,49 @@ class RecipeParserFactory:
"""Factory for creating recipe metadata parsers""" """Factory for creating recipe metadata parsers"""
@staticmethod @staticmethod
def create_parser(user_comment: str) -> RecipeMetadataParser: def create_parser(metadata) -> RecipeMetadataParser:
""" """
Create appropriate parser based on the user comment content Create appropriate parser based on the metadata content
Args: Args:
user_comment: The EXIF UserComment string from the image metadata: The metadata from the image (dict or str)
Returns: Returns:
Appropriate RecipeMetadataParser implementation Appropriate RecipeMetadataParser implementation
""" """
# Try ComfyMetadataParser first since it requires valid JSON # First, try CivitaiApiMetadataParser for dict input
if isinstance(metadata, dict):
try:
if CivitaiApiMetadataParser().is_metadata_matching(metadata):
return CivitaiApiMetadataParser()
except Exception as e:
logger.debug(f"CivitaiApiMetadataParser check failed: {e}")
pass
# Convert dict to string for other parsers that expect string input
try:
import json
metadata_str = json.dumps(metadata)
except Exception as e:
logger.debug(f"Failed to convert dict to JSON string: {e}")
return None
else:
metadata_str = metadata
# Try ComfyMetadataParser which requires valid JSON
try: try:
if ComfyMetadataParser().is_metadata_matching(user_comment): if ComfyMetadataParser().is_metadata_matching(metadata_str):
return ComfyMetadataParser() return ComfyMetadataParser()
except Exception: except Exception:
# If JSON parsing fails, move on to other parsers # If JSON parsing fails, move on to other parsers
pass pass
if RecipeFormatParser().is_metadata_matching(user_comment): # Check other parsers that expect string input
if RecipeFormatParser().is_metadata_matching(metadata_str):
return RecipeFormatParser() return RecipeFormatParser()
elif AutomaticMetadataParser().is_metadata_matching(user_comment): elif AutomaticMetadataParser().is_metadata_matching(metadata_str):
return AutomaticMetadataParser() return AutomaticMetadataParser()
elif MetaFormatParser().is_metadata_matching(user_comment): elif MetaFormatParser().is_metadata_matching(metadata_str):
return MetaFormatParser() return MetaFormatParser()
else: else:
return None return None

View File

@@ -4,10 +4,12 @@ from .recipe_format import RecipeFormatParser
from .comfy import ComfyMetadataParser from .comfy import ComfyMetadataParser
from .meta_format import MetaFormatParser from .meta_format import MetaFormatParser
from .automatic import AutomaticMetadataParser from .automatic import AutomaticMetadataParser
from .civitai_image import CivitaiApiMetadataParser
__all__ = [ __all__ = [
'RecipeFormatParser', 'RecipeFormatParser',
'ComfyMetadataParser', 'ComfyMetadataParser',
'MetaFormatParser', 'MetaFormatParser',
'AutomaticMetadataParser', 'AutomaticMetadataParser',
'CivitaiApiMetadataParser',
] ]

View File

@@ -0,0 +1,248 @@
"""Parser for Civitai image metadata format."""
import json
import logging
from typing import Dict, Any, Union
from ..base import RecipeMetadataParser
from ..constants import GEN_PARAM_KEYS
logger = logging.getLogger(__name__)
class CivitaiApiMetadataParser(RecipeMetadataParser):
"""Parser for Civitai image metadata format"""
def is_metadata_matching(self, metadata) -> bool:
"""Check if the metadata matches the Civitai image metadata format
Args:
metadata: The metadata from the image (dict)
Returns:
bool: True if this parser can handle the metadata
"""
if not metadata or not isinstance(metadata, dict):
return False
# Check for key markers specific to Civitai image metadata
return any([
"resources" in metadata,
"civitaiResources" in metadata,
"additionalResources" in metadata
])
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
"""Parse metadata from Civitai image format
Args:
metadata: The metadata from the image (dict)
recipe_scanner: Optional recipe scanner service
civitai_client: Optional Civitai API client
Returns:
Dict containing parsed recipe data
"""
try:
# Initialize result structure
result = {
'base_model': None,
'loras': [],
'gen_params': {},
'from_civitai_image': True
}
# Extract prompt and negative prompt
if "prompt" in metadata:
result["gen_params"]["prompt"] = metadata["prompt"]
if "negativePrompt" in metadata:
result["gen_params"]["negative_prompt"] = metadata["negativePrompt"]
# Extract other generation parameters
param_mapping = {
"steps": "steps",
"sampler": "sampler",
"cfgScale": "cfg_scale",
"seed": "seed",
"Size": "size",
"clipSkip": "clip_skip",
}
for civitai_key, our_key in param_mapping.items():
if civitai_key in metadata and our_key in GEN_PARAM_KEYS:
result["gen_params"][our_key] = metadata[civitai_key]
# Extract base model information - directly if available
if "baseModel" in metadata:
result["base_model"] = metadata["baseModel"]
elif "Model hash" in metadata and civitai_client:
model_hash = metadata["Model hash"]
model_info = await civitai_client.get_model_by_hash(model_hash)
if model_info:
result["base_model"] = model_info.get("baseModel", "")
elif "Model" in metadata and isinstance(metadata.get("resources"), list):
# Try to find base model in resources
for resource in metadata.get("resources", []):
if resource.get("type") == "model" and resource.get("name") == metadata.get("Model"):
# This is likely the checkpoint model
if civitai_client and resource.get("hash"):
model_info = await civitai_client.get_model_by_hash(resource.get("hash"))
if model_info:
result["base_model"] = model_info.get("baseModel", "")
base_model_counts = {}
# Process standard resources array
if "resources" in metadata and isinstance(metadata["resources"], list):
for resource in metadata["resources"]:
# Modified to process resources without a type field as potential LoRAs
if resource.get("type", "lora") == "lora":
lora_entry = {
'name': resource.get("name", "Unknown LoRA"),
'type': "lora",
'weight': float(resource.get("weight", 1.0)),
'hash': resource.get("hash", ""),
'existsLocally': False,
'localPath': None,
'file_name': resource.get("name", "Unknown"),
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get info from Civitai if hash is available
if lora_entry['hash'] and civitai_client:
try:
lora_hash = lora_entry['hash']
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
lora_hash
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}")
result["loras"].append(lora_entry)
# Process civitaiResources array
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
for resource in metadata["civitaiResources"]:
# Modified to process resources without a type field as potential LoRAs
if resource.get("type") in ["lora", "lycoris"] or "type" not in resource:
# Initialize lora entry with the same structure as in automatic.py
lora_entry = {
'id': str(resource.get("modelVersionId")),
'modelId': str(resource.get("modelId")) if resource.get("modelId") else None,
'name': resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""),
'type': resource.get("type", "lora"),
'weight': round(float(resource.get("weight", 1.0)), 2),
'existsLocally': False,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get info from Civitai if modelVersionId is available
if resource.get('modelVersionId') and civitai_client:
try:
version_id = str(resource.get('modelVersionId'))
# Use get_model_version_info instead of get_model_version
civitai_info, error = await civitai_client.get_model_version_info(version_id)
if error:
logger.warning(f"Error getting model version info: {error}")
continue
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for model version {resource.get('modelVersionId')}: {e}")
result["loras"].append(lora_entry)
# Process additionalResources array
if "additionalResources" in metadata and isinstance(metadata["additionalResources"], list):
for resource in metadata["additionalResources"]:
# Modified to process resources without a type field as potential LoRAs
if resource.get("type") in ["lora", "lycoris"] or "type" not in resource:
lora_type = resource.get("type", "lora")
name = resource.get("name", "")
# Extract ID from URN format if available
model_id = None
if name and "civitai:" in name:
parts = name.split("@")
if len(parts) > 1:
model_id = parts[1]
lora_entry = {
'name': name,
'type': lora_type,
'weight': float(resource.get("strength", 1.0)),
'hash': "",
'existsLocally': False,
'localPath': None,
'file_name': name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# If we have a model ID and civitai client, try to get more info
if model_id and civitai_client:
try:
# Use get_model_version_info with the model ID
civitai_info, error = await civitai_client.get_model_version_info(model_id)
if error:
logger.warning(f"Error getting model version info: {error}")
else:
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for model ID {model_id}: {e}")
result["loras"].append(lora_entry)
# If base model wasn't found earlier, use the most common one from LoRAs
if not result["base_model"] and base_model_counts:
result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0]
return result
except Exception as e:
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []}

View File

@@ -45,6 +45,7 @@ class ApiRoutes:
app.router.add_post('/api/delete_model', routes.delete_model) app.router.add_post('/api/delete_model', routes.delete_model)
app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint
app.router.add_post('/api/fetch-civitai', routes.fetch_civitai) app.router.add_post('/api/fetch-civitai', routes.fetch_civitai)
app.router.add_post('/api/relink-civitai', routes.relink_civitai) # Add new relink endpoint
app.router.add_post('/api/replace_preview', routes.replace_preview) app.router.add_post('/api/replace_preview', routes.replace_preview)
app.router.add_get('/api/loras', routes.get_loras) app.router.add_get('/api/loras', routes.get_loras)
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai) app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
@@ -80,6 +81,13 @@ class ApiRoutes:
# Add update check routes # Add update check routes
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
# Add new endpoints for finding duplicates
app.router.add_get('/api/loras/find-duplicates', routes.find_duplicate_loras)
app.router.add_get('/api/loras/find-filename-conflicts', routes.find_filename_conflicts)
# Add new endpoint for bulk deleting loras
app.router.add_post('/api/loras/bulk-delete', routes.bulk_delete_loras)
async def delete_model(self, request: web.Request) -> web.Response: async def delete_model(self, request: web.Request) -> web.Response:
"""Handle model deletion request""" """Handle model deletion request"""
if self.scanner is None: if self.scanner is None:
@@ -107,7 +115,10 @@ class ApiRoutes:
async def scan_loras(self, request: web.Request) -> web.Response: async def scan_loras(self, request: web.Request) -> web.Response:
"""Force a rescan of LoRA files""" """Force a rescan of LoRA files"""
try: try:
await self.scanner.get_cached_data(force_refresh=True) # Get full_rebuild parameter from query string, default to false
full_rebuild = request.query.get('full_rebuild', 'false').lower() == 'true'
await self.scanner.get_cached_data(force_refresh=True, rebuild_cache=full_rebuild)
return web.json_response({"status": "success", "message": "LoRA scan completed"}) return web.json_response({"status": "success", "message": "LoRA scan completed"})
except Exception as e: except Exception as e:
logger.error(f"Error in scan_loras: {e}", exc_info=True) logger.error(f"Error in scan_loras: {e}", exc_info=True)
@@ -512,7 +523,7 @@ class ApiRoutes:
logger.warning(f"Early access download failed: {error_message}") logger.warning(f"Early access download failed: {error_message}")
return web.Response( return web.Response(
status=401, # Use 401 status code to match Civitai's response status=401, # Use 401 status code to match Civitai's response
text=f"Early Access Restriction: {error_message}" text=error_message
) )
return web.Response(status=500, text=error_message) return web.Response(status=500, text=error_message)
@@ -1166,3 +1177,118 @@ class ApiRoutes:
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
async def find_duplicate_loras(self, request: web.Request) -> web.Response:
"""Find loras with duplicate SHA256 hashes"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
# Get duplicate hashes from hash index
duplicates = self.scanner._hash_index.get_duplicate_hashes()
# Format the response
result = []
cache = await self.scanner.get_cached_data()
for sha256, paths in duplicates.items():
group = {
"hash": sha256,
"models": []
}
# Find matching models for each duplicate path
for path in paths:
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
if model:
group["models"].append(self._format_lora_response(model))
# Add the primary model too
primary_path = self.scanner._hash_index.get_path(sha256)
if primary_path and primary_path not in paths:
primary_model = next((m for m in cache.raw_data if m['file_path'] == primary_path), None)
if primary_model:
group["models"].insert(0, self._format_lora_response(primary_model))
if group["models"]: # Only include if we found models
result.append(group)
return web.json_response({
"success": True,
"duplicates": result,
"count": len(result)
})
except Exception as e:
logger.error(f"Error finding duplicate loras: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
"""Find loras with conflicting filenames"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
# Get duplicate filenames from hash index
duplicates = self.scanner._hash_index.get_duplicate_filenames()
# Format the response
result = []
cache = await self.scanner.get_cached_data()
for filename, paths in duplicates.items():
group = {
"filename": filename,
"models": []
}
# Find matching models for each path
for path in paths:
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
if model:
group["models"].append(self._format_lora_response(model))
# Find the model from the main index too
hash_val = self.scanner._hash_index.get_hash_by_filename(filename)
if hash_val:
main_path = self.scanner._hash_index.get_path(hash_val)
if main_path and main_path not in paths:
main_model = next((m for m in cache.raw_data if m['file_path'] == main_path), None)
if main_model:
group["models"].insert(0, self._format_lora_response(main_model))
if group["models"]: # Only include if we found models
result.append(group)
return web.json_response({
"success": True,
"conflicts": result,
"count": len(result)
})
except Exception as e:
logger.error(f"Error finding filename conflicts: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
async def bulk_delete_loras(self, request: web.Request) -> web.Response:
"""Handle bulk deletion of lora models"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_bulk_delete_models(request, self.scanner)
except Exception as e:
logger.error(f"Error in bulk delete loras: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def relink_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID for LoRAs"""
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)

View File

@@ -51,6 +51,7 @@ class CheckpointsRoutes:
app.router.add_post('/api/checkpoints/delete', self.delete_model) app.router.add_post('/api/checkpoints/delete', self.delete_model)
app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint
app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai) app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai)
app.router.add_post('/api/checkpoints/relink-civitai', self.relink_civitai) # Add new relink endpoint
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview) app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
app.router.add_post('/api/checkpoints/download', self.download_checkpoint) app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route
@@ -58,6 +59,13 @@ class CheckpointsRoutes:
# Add new WebSocket endpoint for checkpoint progress # Add new WebSocket endpoint for checkpoint progress
app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection) app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection)
# Add new routes for finding duplicates and filename conflicts
app.router.add_get('/api/checkpoints/find-duplicates', self.find_duplicate_checkpoints)
app.router.add_get('/api/checkpoints/find-filename-conflicts', self.find_filename_conflicts)
# Add new endpoint for bulk deleting checkpoints
app.router.add_post('/api/checkpoints/bulk-delete', self.bulk_delete_checkpoints)
async def get_checkpoints(self, request): async def get_checkpoints(self, request):
"""Get paginated checkpoint data""" """Get paginated checkpoint data"""
try: try:
@@ -420,7 +428,10 @@ class CheckpointsRoutes:
async def scan_checkpoints(self, request): async def scan_checkpoints(self, request):
"""Force a rescan of checkpoint files""" """Force a rescan of checkpoint files"""
try: try:
await self.scanner.get_cached_data(force_refresh=True) # Get the full_rebuild parameter and convert to bool, default to False
full_rebuild = request.query.get('full_rebuild', 'false').lower() == 'true'
await self.scanner.get_cached_data(force_refresh=True, rebuild_cache=full_rebuild)
return web.json_response({"status": "success", "message": "Checkpoint scan completed"}) return web.json_response({"status": "success", "message": "Checkpoint scan completed"})
except Exception as e: except Exception as e:
logger.error(f"Error in scan_checkpoints: {e}", exc_info=True) logger.error(f"Error in scan_checkpoints: {e}", exc_info=True)
@@ -692,3 +703,116 @@ class CheckpointsRoutes:
except Exception as e: except Exception as e:
logger.error(f"Error fetching checkpoint model versions: {e}") logger.error(f"Error fetching checkpoint model versions: {e}")
return web.Response(status=500, text=str(e)) return web.Response(status=500, text=str(e))
async def find_duplicate_checkpoints(self, request: web.Request) -> web.Response:
"""Find checkpoints with duplicate SHA256 hashes"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_checkpoint_scanner()
# Get duplicate hashes from hash index
duplicates = self.scanner._hash_index.get_duplicate_hashes()
# Format the response
result = []
cache = await self.scanner.get_cached_data()
for sha256, paths in duplicates.items():
group = {
"hash": sha256,
"models": []
}
# Find matching models for each path
for path in paths:
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
if model:
group["models"].append(self._format_checkpoint_response(model))
# Add the primary model too
primary_path = self.scanner._hash_index.get_path(sha256)
if primary_path and primary_path not in paths:
primary_model = next((m for m in cache.raw_data if m['file_path'] == primary_path), None)
if primary_model:
group["models"].insert(0, self._format_checkpoint_response(primary_model))
if group["models"]:
result.append(group)
return web.json_response({
"success": True,
"duplicates": result,
"count": len(result)
})
except Exception as e:
logger.error(f"Error finding duplicate checkpoints: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
"""Find checkpoints with conflicting filenames"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_checkpoint_scanner()
# Get duplicate filenames from hash index
duplicates = self.scanner._hash_index.get_duplicate_filenames()
# Format the response
result = []
cache = await self.scanner.get_cached_data()
for filename, paths in duplicates.items():
group = {
"filename": filename,
"models": []
}
# Find matching models for each path
for path in paths:
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
if model:
group["models"].append(self._format_checkpoint_response(model))
# Find the model from the main index too
hash_val = self.scanner._hash_index.get_hash_by_filename(filename)
if hash_val:
main_path = self.scanner._hash_index.get_path(hash_val)
if main_path and main_path not in paths:
main_model = next((m for m in cache.raw_data if m['file_path'] == main_path), None)
if main_model:
group["models"].insert(0, self._format_checkpoint_response(main_model))
if group["models"]:
result.append(group)
return web.json_response({
"success": True,
"conflicts": result,
"count": len(result)
})
except Exception as e:
logger.error(f"Error finding filename conflicts: {e}", exc_info=True)
return web.json_response({
"success": False,
"error": str(e)
}, status=500)
async def bulk_delete_checkpoints(self, request: web.Request) -> web.Response:
"""Handle bulk deletion of checkpoint models"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_checkpoint_scanner()
return await ModelRouteUtils.handle_bulk_delete_models(request, self.scanner)
except Exception as e:
logger.error(f"Error in bulk delete checkpoints: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def relink_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID for checkpoints"""
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,13 @@
import logging import logging
import os import os
import asyncio from server import PromptServer # type: ignore
import json
import time
import aiohttp
from aiohttp import web from aiohttp import web
from ..services.settings_manager import settings from ..services.settings_manager import settings
from ..utils.usage_stats import UsageStats from ..utils.usage_stats import UsageStats
from ..services.service_registry import ServiceRegistry from ..utils.lora_metadata import extract_trained_words
from ..utils.exif_utils import ExifUtils from ..config import config
from ..utils.constants import EXAMPLE_IMAGE_WIDTH, SUPPORTED_MEDIA_EXTENSIONS from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..services.civitai_client import CivitaiClient import re
from ..utils.routes_common import ModelRouteUtils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,15 +35,70 @@ class MiscRoutes:
"""Register miscellaneous routes""" """Register miscellaneous routes"""
app.router.add_post('/api/settings', MiscRoutes.update_settings) app.router.add_post('/api/settings', MiscRoutes.update_settings)
# Add new route for clearing cache
app.router.add_post('/api/clear-cache', MiscRoutes.clear_cache)
# Usage stats routes # Usage stats routes
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats) app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats) app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats)
# Example images download routes # Lora code update endpoint
app.router.add_post('/api/download-example-images', MiscRoutes.download_example_images) app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
app.router.add_get('/api/example-images-status', MiscRoutes.get_example_images_status)
app.router.add_post('/api/pause-example-images', MiscRoutes.pause_example_images) # Add new route for getting trained words
app.router.add_post('/api/resume-example-images', MiscRoutes.resume_example_images) app.router.add_get('/api/trained-words', MiscRoutes.get_trained_words)
# Add new route for getting model example files
app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
@staticmethod
async def clear_cache(request):
"""Clear all cache files from the cache folder"""
try:
# Get the cache folder path (relative to project directory)
project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
cache_folder = os.path.join(project_dir, 'cache')
# Check if cache folder exists
if not os.path.exists(cache_folder):
logger.info("Cache folder does not exist, nothing to clear")
return web.json_response({'success': True, 'message': 'No cache folder found'})
# Get list of cache files before deleting for reporting
cache_files = [f for f in os.listdir(cache_folder) if os.path.isfile(os.path.join(cache_folder, f))]
deleted_files = []
# Delete each .msgpack file in the cache folder
for filename in cache_files:
if filename.endswith('.msgpack'):
file_path = os.path.join(cache_folder, filename)
try:
os.remove(file_path)
deleted_files.append(filename)
logger.info(f"Deleted cache file: {filename}")
except Exception as e:
logger.error(f"Failed to delete {filename}: {e}")
return web.json_response({
'success': False,
'error': f"Failed to delete {filename}: {str(e)}"
}, status=500)
# If we want to completely remove the cache folder too (optional,
# but we'll keep the folder structure in place here)
# shutil.rmtree(cache_folder)
return web.json_response({
'success': True,
'message': f"Successfully cleared {len(deleted_files)} cache files",
'deleted_files': deleted_files
})
except Exception as e:
logger.error(f"Error clearing cache files: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod @staticmethod
async def update_settings(request): async def update_settings(request):
@@ -121,10 +172,14 @@ class MiscRoutes:
usage_stats = UsageStats() usage_stats = UsageStats()
stats = await usage_stats.get_stats() stats = await usage_stats.get_stats()
return web.json_response({ # Add version information to help clients handle format changes
stats_response = {
'success': True, 'success': True,
'data': stats 'data': stats,
}) 'format_version': 2 # Indicate this is the new format with history
}
return web.json_response(stats_response)
except Exception as e: except Exception as e:
logger.error(f"Failed to get usage stats: {e}", exc_info=True) logger.error(f"Failed to get usage stats: {e}", exc_info=True)
@@ -134,634 +189,217 @@ class MiscRoutes:
}, status=500) }, status=500)
@staticmethod @staticmethod
async def download_example_images(request): async def update_lora_code(request):
""" """
Download example images for models from Civitai Update Lora code in ComfyUI nodes
Expects a JSON body with: Expects a JSON body with:
{ {
"output_dir": "path/to/output", # Base directory to save example images "node_ids": [123, 456], # Optional - List of node IDs to update (for browser mode)
"optimize": true, # Whether to optimize images (default: true) "lora_code": "<lora:modelname:1.0>", # The Lora code to send
"model_types": ["lora", "checkpoint"], # Model types to process (default: both) "mode": "append" # or "replace" - whether to append or replace existing code
"delay": 1.0 # Delay between downloads to avoid rate limiting (default: 1.0)
} }
""" """
global download_task, is_downloading, download_progress
if is_downloading:
# Create a copy for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': False,
'error': 'Download already in progress',
'status': response_progress
}, status=400)
try: try:
# Parse the request body # Parse the request body
data = await request.json() data = await request.json()
output_dir = data.get('output_dir') node_ids = data.get('node_ids')
optimize = data.get('optimize', True) lora_code = data.get('lora_code', '')
model_types = data.get('model_types', ['lora', 'checkpoint']) mode = data.get('mode', 'append')
delay = float(data.get('delay', 0.1)) # Default to 0.1 seconds
if not output_dir: if not lora_code:
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': 'Missing output_dir parameter' 'error': 'Missing lora_code parameter'
}, status=400) }, status=400)
# Create the output directory results = []
os.makedirs(output_dir, exist_ok=True)
# Initialize progress tracking # Desktop mode: no specific node_ids provided
download_progress['total'] = 0 if node_ids is None:
download_progress['completed'] = 0
download_progress['current_model'] = ''
download_progress['status'] = 'running'
download_progress['errors'] = []
download_progress['last_error'] = None
download_progress['start_time'] = time.time()
download_progress['end_time'] = None
# Get the processed models list from a file if it exists
progress_file = os.path.join(output_dir, '.download_progress.json')
if os.path.exists(progress_file):
try: try:
with open(progress_file, 'r', encoding='utf-8') as f: # Send broadcast message with id=-1 to all Lora Loader nodes
saved_progress = json.load(f) PromptServer.instance.send_sync("lora_code_update", {
download_progress['processed_models'] = set(saved_progress.get('processed_models', [])) "id": -1,
logger.info(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed") "lora_code": lora_code,
"mode": mode
})
results.append({
'node_id': 'broadcast',
'success': True
})
except Exception as e: except Exception as e:
logger.error(f"Failed to load progress file: {e}") logger.error(f"Error broadcasting lora code: {e}")
download_progress['processed_models'] = set() results.append({
'node_id': 'broadcast',
'success': False,
'error': str(e)
})
else: else:
download_progress['processed_models'] = set() # Browser mode: send to specific nodes
for node_id in node_ids:
# Start the download task try:
is_downloading = True # Send the message to the frontend
download_task = asyncio.create_task( PromptServer.instance.send_sync("lora_code_update", {
MiscRoutes._download_all_example_images( "id": node_id,
output_dir, "lora_code": lora_code,
optimize, "mode": mode
model_types, })
delay results.append({
) 'node_id': node_id,
) 'success': True
})
# Create a copy for JSON serialization except Exception as e:
response_progress = download_progress.copy() logger.error(f"Error sending lora code to node {node_id}: {e}")
response_progress['processed_models'] = list(download_progress['processed_models']) results.append({
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) 'node_id': node_id,
'success': False,
'error': str(e)
})
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'message': 'Download started', 'results': results
'status': response_progress
}) })
except Exception as e: except Exception as e:
logger.error(f"Failed to start example images download: {e}", exc_info=True) logger.error(f"Failed to update lora code: {e}", exc_info=True)
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
@staticmethod @staticmethod
async def get_example_images_status(request): async def get_trained_words(request):
"""Get the current status of example images download""" """
global download_progress Get trained words from a safetensors file, sorted by frequency
# Create a copy of the progress dict with the set converted to a list for JSON serialization Expects a query parameter:
response_progress = download_progress.copy() file_path: Path to the safetensors file
response_progress['processed_models'] = list(download_progress['processed_models']) """
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) try:
# Get file path from query parameters
file_path = request.query.get('file_path')
return web.json_response({ if not file_path:
'success': True, return web.json_response({
'is_downloading': is_downloading, 'success': False,
'status': response_progress 'error': 'Missing file_path parameter'
}) }, status=400)
@staticmethod # Check if file exists and is a safetensors file
async def pause_example_images(request): if not os.path.exists(file_path):
"""Pause the example images download""" return web.json_response({
global download_progress 'success': False,
'error': f"File not found: {file_path}"
}, status=404)
if not is_downloading: if not file_path.lower().endswith('.safetensors'):
return web.json_response({
'success': False,
'error': 'File is not a safetensors file'
}, status=400)
# Extract trained words and class_tokens
trained_words, class_tokens = await extract_trained_words(file_path)
# Return result with both trained words and class tokens
return web.json_response({
'success': True,
'trained_words': trained_words,
'class_tokens': class_tokens
})
except Exception as e:
logger.error(f"Failed to get trained words: {e}", exc_info=True)
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': 'No download in progress' 'error': str(e)
}, status=400) }, status=500)
download_progress['status'] = 'paused'
return web.json_response({
'success': True,
'message': 'Download paused'
})
@staticmethod @staticmethod
async def resume_example_images(request): async def get_model_example_files(request):
"""Resume the example images download""" """
global download_progress Get list of example image files for a specific model based on file path
if not is_downloading: Expects:
return web.json_response({ - file_path in query parameters
'success': False,
'error': 'No download in progress'
}, status=400)
if download_progress['status'] == 'paused': Returns:
download_progress['status'] = 'running' - List of image files with their paths as static URLs
"""
try:
# Get the model file path from query parameters
file_path = request.query.get('file_path')
if not file_path:
return web.json_response({
'success': False,
'error': 'Missing file_path parameter'
}, status=400)
# Extract directory and base filename
model_dir = os.path.dirname(file_path)
model_filename = os.path.basename(file_path)
model_name = os.path.splitext(model_filename)[0]
# Check if the directory exists
if not os.path.exists(model_dir):
return web.json_response({
'success': False,
'error': 'Model directory not found',
'files': []
}, status=404)
# Look for files matching the pattern modelname.example.<index>.<ext>
files = []
pattern = f"{model_name}.example."
for file in os.listdir(model_dir):
file_lower = file.lower()
if file_lower.startswith(pattern.lower()):
file_full_path = os.path.join(model_dir, file)
if os.path.isfile(file_full_path):
# Check if the file is a supported media file
file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
# Extract the index from the filename
try:
# Extract the part after '.example.' and before file extension
index_part = file[len(pattern):].split('.')[0]
# Try to parse it as an integer
index = int(index_part)
except (ValueError, IndexError):
# If we can't parse the index, use infinity to sort at the end
index = float('inf')
# Convert file path to static URL
static_url = config.get_preview_static_url(file_full_path)
files.append({
'name': file,
'path': static_url,
'extension': file_ext,
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'],
'index': index
})
# Sort files by their index for consistent ordering
files.sort(key=lambda x: x['index'])
# Remove the index field as it's only used for sorting
for file in files:
file.pop('index', None)
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'message': 'Download resumed' 'files': files
}) })
else:
except Exception as e:
logger.error(f"Failed to get model example files: {e}", exc_info=True)
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': f"Download is in '{download_progress['status']}' state, cannot resume" 'error': str(e)
}, status=400) }, status=500)
@staticmethod
async def _refresh_model_metadata(model_hash, model_name, scanner_type, scanner):
"""Refresh model metadata from CivitAI
Args:
model_hash: SHA256 hash of the model
model_name: Name of the model (for logging)
scanner_type: Type of scanner ('lora' or 'checkpoint')
scanner: Scanner instance for this model type
Returns:
bool: True if metadata was successfully refreshed, False otherwise
"""
global download_progress
try:
# Find the model in the scanner cache
cache = await scanner.get_cached_data()
model_data = None
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
break
if not model_data:
logger.warning(f"Model {model_name} with hash {model_hash} not found in cache")
return False
file_path = model_data.get('file_path')
if not file_path:
logger.warning(f"Model {model_name} has no file path")
return False
# Track that we're refreshing this model
download_progress['refreshed_models'].add(model_hash)
# Use ModelRouteUtils to refresh the metadata
async def update_cache_func(old_path, new_path, metadata):
return await scanner.update_single_model_cache(old_path, new_path, metadata)
success = await ModelRouteUtils.fetch_and_update_model(
model_hash,
file_path,
model_data,
update_cache_func
)
if success:
logger.info(f"Successfully refreshed metadata for {model_name}")
return True
else:
logger.warning(f"Failed to refresh metadata for {model_name}")
return False
except Exception as e:
error_msg = f"Error refreshing metadata for {model_name}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False
@staticmethod
async def _process_model_images(model_hash, model_name, model_images, model_dir, optimize, independent_session, delay):
"""Process and download images for a single model
Args:
model_hash: SHA256 hash of the model
model_name: Name of the model
model_images: List of image objects from CivitAI
model_dir: Directory to save images to
optimize: Whether to optimize images
independent_session: aiohttp session for downloads
delay: Delay between downloads
Returns:
bool: True if all images were processed successfully, False otherwise
"""
global download_progress
model_success = True
for i, image in enumerate(model_images, 1):
image_url = image.get('url')
if not image_url:
continue
# Get image filename from URL
image_filename = os.path.basename(image_url.split('?')[0])
image_ext = os.path.splitext(image_filename)[1].lower()
# Handle both images and videos
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug(f"Skipping unsupported file type: {image_filename}")
continue
save_filename = f"image_{i}{image_ext}"
# Check if already downloaded
save_path = os.path.join(model_dir, save_filename)
if os.path.exists(save_path):
logger.debug(f"File already exists: {save_path}")
continue
# Download the file
try:
logger.debug(f"Downloading {save_filename} for {model_name}")
# Direct download using the independent session
async with independent_session.get(image_url, timeout=60) as response:
if response.status == 200:
if is_image and optimize:
# For images, optimize if requested
image_data = await response.read()
optimized_data, ext = ExifUtils.optimize_image(
image_data,
target_width=EXAMPLE_IMAGE_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# Update save filename if format changed
if ext == '.webp':
save_filename = os.path.splitext(save_filename)[0] + '.webp'
save_path = os.path.join(model_dir, save_filename)
# Save the optimized image
with open(save_path, 'wb') as f:
f.write(optimized_data)
else:
# For videos or unoptimized images, save directly
with open(save_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
if chunk:
f.write(chunk)
elif response.status == 404:
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed due to 404
# Return early to trigger metadata refresh attempt
return False, True # (success, is_stale_metadata)
else:
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
logger.warning(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
# Add a delay between downloads for remote files only
await asyncio.sleep(delay)
except Exception as e:
error_msg = f"Error downloading file {image_url}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
return model_success, False # (success, is_stale_metadata)
@staticmethod
async def _process_local_example_images(model_file_path, model_file_name, model_name, model_dir, optimize):
"""Process local example images for a model
Args:
model_file_path: Path to the model file
model_file_name: Filename of the model
model_name: Name of the model
model_dir: Directory to save processed images to
optimize: Whether to optimize images
Returns:
bool: True if local images were processed successfully, False otherwise
"""
global download_progress
try:
model_dir_path = os.path.dirname(model_file_path)
local_images = []
# Look for files with pattern: filename.example.*.ext
if model_file_name:
example_prefix = f"{model_file_name}.example."
if os.path.exists(model_dir_path):
for file in os.listdir(model_dir_path):
file_lower = file.lower()
if file_lower.startswith(example_prefix.lower()):
file_ext = os.path.splitext(file_lower)[1]
is_supported = (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'])
if is_supported:
local_images.append(os.path.join(model_dir_path, file))
# Process local images if found
if local_images:
logger.info(f"Found {len(local_images)} local example images for {model_name}")
for i, local_image_path in enumerate(local_images, 1):
local_ext = os.path.splitext(local_image_path)[1].lower()
save_filename = f"image_{i}{local_ext}"
save_path = os.path.join(model_dir, save_filename)
# Skip if already exists in output directory
if os.path.exists(save_path):
logger.debug(f"File already exists in output: {save_path}")
continue
# Handle image processing based on file type and optimize setting
is_image = local_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
if is_image and optimize:
# Optimize the image
with open(local_image_path, 'rb') as img_file:
image_data = img_file.read()
optimized_data, ext = ExifUtils.optimize_image(
image_data,
target_width=EXAMPLE_IMAGE_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# Update save filename if format changed
if ext == '.webp':
save_filename = os.path.splitext(save_filename)[0] + '.webp'
save_path = os.path.join(model_dir, save_filename)
# Save the optimized image
with open(save_path, 'wb') as f:
f.write(optimized_data)
else:
# For videos or unoptimized images, copy directly
with open(local_image_path, 'rb') as src_file:
with open(save_path, 'wb') as dst_file:
dst_file.write(src_file.read())
return True
return False
except Exception as e:
error_msg = f"Error processing local examples for {model_name}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False
@staticmethod
async def _download_all_example_images(output_dir, optimize, model_types, delay):
"""Download example images for all models
Args:
output_dir: Base directory to save example images
optimize: Whether to optimize images
model_types: List of model types to process
delay: Delay between downloads to avoid rate limiting
"""
global is_downloading, download_progress
# Create an independent session for downloading example images
# This avoids interference with the CivitAI client's session
connector = aiohttp.TCPConnector(
ssl=True,
limit=3,
force_close=False,
enable_cleanup_closed=True
)
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=60)
# Create a dedicated session just for this download task
independent_session = aiohttp.ClientSession(
connector=connector,
trust_env=True,
timeout=timeout
)
try:
# Get the scanners
scanners = []
if 'lora' in model_types:
lora_scanner = await ServiceRegistry.get_lora_scanner()
scanners.append(('lora', lora_scanner))
if 'checkpoint' in model_types:
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
scanners.append(('checkpoint', checkpoint_scanner))
# Get all models from all scanners
all_models = []
for scanner_type, scanner in scanners:
cache = await scanner.get_cached_data()
if cache and cache.raw_data:
for model in cache.raw_data:
# Only process models with images and a valid sha256
if model.get('civitai') and model.get('civitai', {}).get('images') and model.get('sha256'):
all_models.append((scanner_type, model, scanner))
# Update total count
download_progress['total'] = len(all_models)
logger.info(f"Found {download_progress['total']} models with example images")
# Process each model
for scanner_type, model, scanner in all_models:
# Check if download is paused
while download_progress['status'] == 'paused':
await asyncio.sleep(1)
# Check if download should continue
if download_progress['status'] != 'running':
logger.info(f"Download stopped: {download_progress['status']}")
break
model_hash = model.get('sha256', '').lower()
model_name = model.get('model_name', 'Unknown')
model_file_path = model.get('file_path', '')
model_file_name = model.get('file_name', '')
try:
# Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already processed
if model_hash in download_progress['processed_models']:
logger.debug(f"Skipping already processed model: {model_name}")
download_progress['completed'] += 1
continue
# Create model directory
model_dir = os.path.join(output_dir, model_hash)
os.makedirs(model_dir, exist_ok=True)
# Process images for this model
images = model.get('civitai', {}).get('images', [])
if not images:
logger.debug(f"No images found for model: {model_name}")
download_progress['processed_models'].add(model_hash)
download_progress['completed'] += 1
continue
# First check if we have local example images for this model
local_images_processed = False
if model_file_path:
local_images_processed = await MiscRoutes._process_local_example_images(
model_file_path,
model_file_name,
model_name,
model_dir,
optimize
)
if local_images_processed:
# Mark as successfully processed if all local images were processed
download_progress['processed_models'].add(model_hash)
logger.info(f"Successfully processed local examples for {model_name}")
# If we didn't process local images, download from remote
if not local_images_processed:
# Try to download images
model_success, is_stale_metadata = await MiscRoutes._process_model_images(
model_hash,
model_name,
images,
model_dir,
optimize,
independent_session,
delay
)
# If metadata is stale (404 error), try to refresh it and download again
if is_stale_metadata and model_hash not in download_progress['refreshed_models']:
logger.info(f"Metadata seems stale for {model_name}, attempting to refresh...")
# Refresh metadata from CivitAI
refresh_success = await MiscRoutes._refresh_model_metadata(
model_hash,
model_name,
scanner_type,
scanner
)
if refresh_success:
# Get updated model data
updated_cache = await scanner.get_cached_data()
updated_model = None
for item in updated_cache.raw_data:
if item.get('sha256') == model_hash:
updated_model = item
break
if updated_model and updated_model.get('civitai', {}).get('images'):
# Try downloading with updated metadata
logger.info(f"Retrying download with refreshed metadata for {model_name}")
updated_images = updated_model.get('civitai', {}).get('images', [])
# Retry download with new images
model_success, _ = await MiscRoutes._process_model_images(
model_hash,
model_name,
updated_images,
model_dir,
optimize,
independent_session,
delay
)
# Only mark model as processed if all images downloaded successfully
if model_success:
download_progress['processed_models'].add(model_hash)
else:
logger.warning(f"Model {model_name} had download errors, will not mark as completed")
# Save progress to file periodically
if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1:
progress_file = os.path.join(output_dir, '.download_progress.json')
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time()
}, f, indent=2)
except Exception as e:
error_msg = f"Error processing model {model.get('model_name')}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
# Update progress
download_progress['completed'] += 1
# Mark as completed
download_progress['status'] = 'completed'
download_progress['end_time'] = time.time()
logger.info(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
except Exception as e:
error_msg = f"Error during example images download: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
download_progress['status'] = 'error'
download_progress['end_time'] = time.time()
finally:
# Close the independent session
try:
await independent_session.close()
except Exception as e:
logger.error(f"Error closing download session: {e}")
# Save final progress to file
try:
progress_file = os.path.join(output_dir, '.download_progress.json')
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time(),
'status': download_progress['status']
}, f, indent=2)
except Exception as e:
logger.error(f"Failed to save progress file: {e}")
# Set download status to not downloading
is_downloading = False

View File

@@ -254,6 +254,7 @@ class RecipeRoutes:
content_type = request.headers.get('Content-Type', '') content_type = request.headers.get('Content-Type', '')
is_url_mode = False is_url_mode = False
metadata = None # Initialize metadata variable
if 'multipart/form-data' in content_type: if 'multipart/form-data' in content_type:
# Handle image upload # Handle image upload
@@ -287,17 +288,63 @@ class RecipeRoutes:
"loras": [] "loras": []
}, status=400) }, status=400)
# Download image from URL # Check if this is a Civitai image URL
temp_path = download_civitai_image(url) import re
civitai_image_match = re.match(r'https://civitai\.com/images/(\d+)', url)
if not temp_path: if civitai_image_match:
return web.json_response({ # Extract image ID and fetch image info using get_image_info
"error": "Failed to download image from URL", image_id = civitai_image_match.group(1)
"loras": [] image_info = await self.civitai_client.get_image_info(image_id)
}, status=400)
# Extract metadata from the image using ExifUtils if not image_info:
metadata = ExifUtils.extract_image_metadata(temp_path) return web.json_response({
"error": "Failed to fetch image information from Civitai",
"loras": []
}, status=400)
# Get image URL from response
image_url = image_info.get('url')
if not image_url:
return web.json_response({
"error": "No image URL found in Civitai response",
"loras": []
}, status=400)
# Download image directly from URL
session = await self.civitai_client.session
# Create a temporary file to save the downloaded image
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
temp_path = temp_file.name
async with session.get(image_url) as response:
if response.status != 200:
return web.json_response({
"error": f"Failed to download image from URL: HTTP {response.status}",
"loras": []
}, status=400)
with open(temp_path, 'wb') as f:
f.write(await response.read())
# Use meta field from image_info as metadata
if 'meta' in image_info:
metadata = image_info['meta']
else:
# Not a Civitai image URL, use the original download method
temp_path = download_civitai_image(url)
if not temp_path:
return web.json_response({
"error": "Failed to download image from URL",
"loras": []
}, status=400)
# If metadata wasn't obtained from Civitai API, extract it from the image
if metadata is None:
# Extract metadata from the image using ExifUtils
metadata = ExifUtils.extract_image_metadata(temp_path)
# If no metadata found, return a more specific error # If no metadata found, return a more specific error
if not metadata: if not metadata:

View File

@@ -346,3 +346,34 @@ class CivitaiClient:
except Exception as e: except Exception as e:
logger.error(f"Error getting hash from Civitai: {e}") logger.error(f"Error getting hash from Civitai: {e}")
return None return None
async def get_image_info(self, image_id: str) -> Optional[Dict]:
"""Fetch image information from Civitai API
Args:
image_id: The Civitai image ID
Returns:
Optional[Dict]: The image data or None if not found
"""
try:
session = await self._ensure_fresh_session()
headers = self._get_request_headers()
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
logger.debug(f"Fetching image info for ID: {image_id}")
async with session.get(url, headers=headers) as response:
if response.status == 200:
data = await response.json()
if data and "items" in data and len(data["items"]) > 0:
logger.debug(f"Successfully fetched image info for ID: {image_id}")
return data["items"][0]
logger.warning(f"No image found with ID: {image_id}")
return None
logger.error(f"Failed to fetch image info for ID: {image_id} (status {response.status})")
return None
except Exception as e:
error_msg = f"Error fetching image info: {e}"
logger.error(error_msg)
return None

View File

@@ -2,8 +2,7 @@ import logging
import os import os
import json import json
import asyncio import asyncio
from typing import Optional, Dict, Any from typing import Dict
from .civitai_client import CivitaiClient
from ..utils.models import LoraMetadata, CheckpointMetadata from ..utils.models import LoraMetadata, CheckpointMetadata
from ..utils.constants import CARD_PREVIEW_WIDTH from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -281,17 +280,11 @@ class DownloadManager:
scanner = await self._get_lora_scanner() scanner = await self._get_lora_scanner()
logger.info(f"Updating lora cache for {save_path}") logger.info(f"Updating lora cache for {save_path}")
cache = await scanner.get_cached_data() # Convert metadata to dictionary
metadata_dict = metadata.to_dict() metadata_dict = metadata.to_dict()
metadata_dict['folder'] = relative_path
cache.raw_data.append(metadata_dict)
await cache.resort()
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 model entry # Add model to cache and save to disk in a single operation
scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path']) await scanner.add_model_to_cache(metadata_dict, relative_path)
# Report 100% completion # Report 100% completion
if progress_callback: if progress_callback:

View File

@@ -1,54 +0,0 @@
from typing import Dict, Optional
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class LoraHashIndex:
"""Index for mapping LoRA file hashes to their file paths"""
def __init__(self):
self._hash_to_path: Dict[str, str] = {}
def add_entry(self, sha256: str, file_path: str) -> None:
"""Add or update a hash -> path mapping"""
if not sha256 or not file_path:
return
# 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"""
if sha256:
self._hash_to_path.pop(sha256.lower(), None)
def remove_by_path(self, file_path: str) -> None:
"""Remove entry by file path"""
for sha256, path in list(self._hash_to_path.items()):
if path == file_path:
del self._hash_to_path[sha256]
break
def get_path(self, sha256: str) -> Optional[str]:
"""Get file path for a given hash"""
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"""
for sha256, path in self._hash_to_path.items():
if path == file_path:
return sha256
return None
def has_hash(self, sha256: str) -> bool:
"""Check if hash exists in index"""
if not sha256:
return False
return sha256.lower() in self._hash_to_path
def clear(self) -> None:
"""Clear all entries"""
self._hash_to_path.clear()

View File

@@ -1,12 +1,15 @@
from typing import Dict, Optional, Set from typing import Dict, Optional, Set, List
import os import os
class ModelHashIndex: class ModelHashIndex:
"""Index for looking up models by hash or path""" """Index for looking up models by hash or filename"""
def __init__(self): def __init__(self):
self._hash_to_path: Dict[str, str] = {} self._hash_to_path: Dict[str, str] = {}
self._filename_to_hash: Dict[str, str] = {} # Changed from path_to_hash to filename_to_hash self._filename_to_hash: Dict[str, str] = {}
# New data structures for tracking duplicates
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
def add_entry(self, sha256: str, file_path: str) -> None: def add_entry(self, sha256: str, file_path: str) -> None:
"""Add or update hash index entry""" """Add or update hash index entry"""
@@ -19,6 +22,26 @@ class ModelHashIndex:
# Extract filename without extension # Extract filename without extension
filename = self._get_filename_from_path(file_path) filename = self._get_filename_from_path(file_path)
# Track duplicates by hash
if sha256 in self._hash_to_path:
old_path = self._hash_to_path[sha256]
if old_path != file_path: # Only record if it's actually a different path
if sha256 not in self._duplicate_hashes:
self._duplicate_hashes[sha256] = [old_path]
if file_path not in self._duplicate_hashes.get(sha256, []):
self._duplicate_hashes.setdefault(sha256, []).append(file_path)
# Track duplicates by filename
if filename in self._filename_to_hash:
old_hash = self._filename_to_hash[filename]
if old_hash != sha256: # Different models with the same name
old_path = self._hash_to_path.get(old_hash)
if old_path:
if filename not in self._duplicate_filenames:
self._duplicate_filenames[filename] = [old_path]
if file_path not in self._duplicate_filenames.get(filename, []):
self._duplicate_filenames.setdefault(filename, []).append(file_path)
# Remove old path mapping if hash exists # Remove old path mapping if hash exists
if sha256 in self._hash_to_path: if sha256 in self._hash_to_path:
old_path = self._hash_to_path[sha256] old_path = self._hash_to_path[sha256]
@@ -43,21 +66,123 @@ class ModelHashIndex:
def remove_by_path(self, file_path: str) -> None: def remove_by_path(self, file_path: str) -> None:
"""Remove entry by file path""" """Remove entry by file path"""
filename = self._get_filename_from_path(file_path) filename = self._get_filename_from_path(file_path)
if filename in self._filename_to_hash: hash_val = None
hash_val = self._filename_to_hash[filename]
if hash_val in self._hash_to_path: # Find the hash for this file path
for h, p in self._hash_to_path.items():
if p == file_path:
hash_val = h
break
# If we didn't find a hash, nothing to do
if not hash_val:
return
# Update duplicates tracking for hash
if hash_val in self._duplicate_hashes:
# Remove the current path from duplicates
self._duplicate_hashes[hash_val] = [p for p in self._duplicate_hashes[hash_val] if p != file_path]
# Update or remove hash mapping based on remaining duplicates
if len(self._duplicate_hashes[hash_val]) > 0:
# Replace with one of the remaining paths
new_path = self._duplicate_hashes[hash_val][0]
new_filename = self._get_filename_from_path(new_path)
# Update hash-to-path mapping
self._hash_to_path[hash_val] = new_path
# IMPORTANT: Update filename-to-hash mapping for consistency
# Remove old filename mapping if it points to this hash
if filename in self._filename_to_hash and self._filename_to_hash[filename] == hash_val:
del self._filename_to_hash[filename]
# Add new filename mapping
self._filename_to_hash[new_filename] = hash_val
# If only one duplicate left, remove from duplicates tracking
if len(self._duplicate_hashes[hash_val]) == 1:
del self._duplicate_hashes[hash_val]
else:
# No duplicates left, remove hash entry completely
del self._duplicate_hashes[hash_val]
del self._hash_to_path[hash_val] del self._hash_to_path[hash_val]
del self._filename_to_hash[filename]
# Remove corresponding filename entry if it points to this hash
if filename in self._filename_to_hash and self._filename_to_hash[filename] == hash_val:
del self._filename_to_hash[filename]
else:
# No duplicates, simply remove the hash entry
del self._hash_to_path[hash_val]
# Remove corresponding filename entry if it points to this hash
if filename in self._filename_to_hash and self._filename_to_hash[filename] == hash_val:
del self._filename_to_hash[filename]
# Update duplicates tracking for filename
if filename in self._duplicate_filenames:
# Remove the current path from duplicates
self._duplicate_filenames[filename] = [p for p in self._duplicate_filenames[filename] if p != file_path]
# Update or remove filename mapping based on remaining duplicates
if len(self._duplicate_filenames[filename]) > 0:
# Get the hash for the first remaining duplicate path
first_dup_path = self._duplicate_filenames[filename][0]
first_dup_hash = None
for h, p in self._hash_to_path.items():
if p == first_dup_path:
first_dup_hash = h
break
# Update the filename to hash mapping if we found a hash
if first_dup_hash:
self._filename_to_hash[filename] = first_dup_hash
# If only one duplicate left, remove from duplicates tracking
if len(self._duplicate_filenames[filename]) == 1:
del self._duplicate_filenames[filename]
else:
# No duplicates left, remove filename entry completely
del self._duplicate_filenames[filename]
if filename in self._filename_to_hash:
del self._filename_to_hash[filename]
def remove_by_hash(self, sha256: str) -> None: def remove_by_hash(self, sha256: str) -> None:
"""Remove entry by hash""" """Remove entry by hash"""
sha256 = sha256.lower() sha256 = sha256.lower()
if sha256 in self._hash_to_path: if sha256 not in self._hash_to_path:
path = self._hash_to_path[sha256] return
filename = self._get_filename_from_path(path)
if filename in self._filename_to_hash: # Get the path and filename
del self._filename_to_hash[filename] path = self._hash_to_path[sha256]
del self._hash_to_path[sha256] filename = self._get_filename_from_path(path)
# Get all paths for this hash (including duplicates)
paths_to_remove = [path]
if sha256 in self._duplicate_hashes:
paths_to_remove.extend(self._duplicate_hashes[sha256])
del self._duplicate_hashes[sha256]
# Remove hash-to-path mapping
del self._hash_to_path[sha256]
# Update filename-to-hash and duplicate filenames for all paths
for path_to_remove in paths_to_remove:
fname = self._get_filename_from_path(path_to_remove)
# If this filename maps to the hash we're removing, remove it
if fname in self._filename_to_hash and self._filename_to_hash[fname] == sha256:
del self._filename_to_hash[fname]
# Update duplicate filenames tracking
if fname in self._duplicate_filenames:
self._duplicate_filenames[fname] = [p for p in self._duplicate_filenames[fname] if p != path_to_remove]
if not self._duplicate_filenames[fname]:
del self._duplicate_filenames[fname]
elif len(self._duplicate_filenames[fname]) == 1:
# If only one entry remains, it's no longer a duplicate
del self._duplicate_filenames[fname]
def has_hash(self, sha256: str) -> bool: def has_hash(self, sha256: str) -> bool:
"""Check if hash exists in index""" """Check if hash exists in index"""
@@ -82,6 +207,8 @@ class ModelHashIndex:
"""Clear all entries""" """Clear all entries"""
self._hash_to_path.clear() self._hash_to_path.clear()
self._filename_to_hash.clear() self._filename_to_hash.clear()
self._duplicate_hashes.clear()
self._duplicate_filenames.clear()
def get_all_hashes(self) -> Set[str]: def get_all_hashes(self) -> Set[str]:
"""Get all hashes in the index""" """Get all hashes in the index"""
@@ -91,6 +218,14 @@ class ModelHashIndex:
"""Get all filenames in the index""" """Get all filenames in the index"""
return set(self._filename_to_hash.keys()) return set(self._filename_to_hash.keys())
def get_duplicate_hashes(self) -> Dict[str, List[str]]:
"""Get dictionary of duplicate hashes and their paths"""
return self._duplicate_hashes
def get_duplicate_filenames(self) -> Dict[str, List[str]]:
"""Get dictionary of duplicate filenames and their paths"""
return self._duplicate_filenames
def __len__(self) -> int: def __len__(self) -> int:
"""Get number of entries""" """Get number of entries"""
return len(self._hash_to_path) return len(self._hash_to_path)

View File

@@ -5,6 +5,7 @@ import asyncio
import time import time
import shutil import shutil
from typing import List, Dict, Optional, Type, Set from typing import List, Dict, Optional, Type, Set
import msgpack # Add MessagePack import for efficient serialization
from ..utils.models import BaseModelMetadata from ..utils.models import BaseModelMetadata
from ..config import config from ..config import config
@@ -17,6 +18,13 @@ from .websocket_manager import ws_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Define cache version to handle future format changes
# Version history:
# 1 - Initial version
# 2 - Added duplicate_filenames and duplicate_hashes tracking
# 3 - Added _excluded_models list to cache
CACHE_VERSION = 3
class ModelScanner: class ModelScanner:
"""Base service for scanning and managing model files""" """Base service for scanning and managing model files"""
@@ -39,6 +47,7 @@ class ModelScanner:
self._tags_count = {} # Dictionary to store tag counts self._tags_count = {} # Dictionary to store tag counts
self._is_initializing = False # Flag to track initialization state self._is_initializing = False # Flag to track initialization state
self._excluded_models = [] # List to track excluded models self._excluded_models = [] # List to track excluded models
self._dirs_last_modified = {} # Track directory modification times
# Register this service # Register this service
asyncio.create_task(self._register_service()) asyncio.create_task(self._register_service())
@@ -48,6 +57,183 @@ class ModelScanner:
service_name = f"{self.model_type}_scanner" service_name = f"{self.model_type}_scanner"
await ServiceRegistry.register_service(service_name, self) await ServiceRegistry.register_service(service_name, self)
def _get_cache_file_path(self) -> Optional[str]:
"""Get the path to the cache file"""
# Get the directory where this module is located
current_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
# Create a cache directory within the project if it doesn't exist
cache_dir = os.path.join(current_dir, "cache")
os.makedirs(cache_dir, exist_ok=True)
# Create filename based on model type
cache_filename = f"lm_{self.model_type}_cache.msgpack"
return os.path.join(cache_dir, cache_filename)
def _prepare_for_msgpack(self, data):
"""Preprocess data to accommodate MessagePack serialization limitations
Converts integers exceeding safe range to strings
Args:
data: Any type of data structure
Returns:
Preprocessed data structure with large integers converted to strings
"""
if isinstance(data, dict):
return {k: self._prepare_for_msgpack(v) for k, v in data.items()}
elif isinstance(data, list):
return [self._prepare_for_msgpack(item) for item in data]
elif isinstance(data, int) and (data > 9007199254740991 or data < -9007199254740991):
# Convert integers exceeding JavaScript's safe integer range (2^53-1) to strings
return str(data)
else:
return data
async def _save_cache_to_disk(self) -> bool:
"""Save cache data to disk using MessagePack"""
if self._cache is None or not self._cache.raw_data:
logger.debug(f"No {self.model_type} cache data to save")
return False
cache_path = self._get_cache_file_path()
if not cache_path:
logger.warning(f"Cannot determine {self.model_type} cache file location")
return False
try:
# Create cache data structure
cache_data = {
"version": CACHE_VERSION,
"timestamp": time.time(),
"model_type": self.model_type,
"raw_data": self._cache.raw_data,
"hash_index": {
"hash_to_path": self._hash_index._hash_to_path,
"filename_to_hash": self._hash_index._filename_to_hash, # Fix: changed from path_to_hash to filename_to_hash
"duplicate_hashes": self._hash_index._duplicate_hashes,
"duplicate_filenames": self._hash_index._duplicate_filenames
},
"tags_count": self._tags_count,
"dirs_last_modified": self._get_dirs_last_modified(),
"excluded_models": self._excluded_models # Add excluded_models to cache data
}
# Preprocess data to handle large integers
processed_cache_data = self._prepare_for_msgpack(cache_data)
# Write to temporary file first (atomic operation)
temp_path = f"{cache_path}.tmp"
with open(temp_path, 'wb') as f:
msgpack.pack(processed_cache_data, f)
# Replace the old file with the new one
if os.path.exists(cache_path):
os.replace(temp_path, cache_path)
else:
os.rename(temp_path, cache_path)
logger.info(f"Saved {self.model_type} cache with {len(self._cache.raw_data)} models to {cache_path}")
logger.debug(f"Hash index stats - hash_to_path: {len(self._hash_index._hash_to_path)}, filename_to_hash: {len(self._hash_index._filename_to_hash)}, duplicate_hashes: {len(self._hash_index._duplicate_hashes)}, duplicate_filenames: {len(self._hash_index._duplicate_filenames)}")
return True
except Exception as e:
logger.error(f"Error saving {self.model_type} cache to disk: {e}")
# Try to clean up temp file if it exists
if 'temp_path' in locals() and os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
return False
def _get_dirs_last_modified(self) -> Dict[str, float]:
"""Get last modified time for all model directories"""
dirs_info = {}
for root in self.get_model_roots():
if os.path.exists(root):
dirs_info[root] = os.path.getmtime(root)
# Also check immediate subdirectories for changes
try:
with os.scandir(root) as it:
for entry in it:
if entry.is_dir(follow_symlinks=True):
dirs_info[entry.path] = entry.stat().st_mtime
except Exception as e:
logger.error(f"Error getting directory info for {root}: {e}")
return dirs_info
def _is_cache_valid(self, cache_data: Dict) -> bool:
"""Validate if the loaded cache is still valid"""
if not cache_data or cache_data.get("version") != CACHE_VERSION:
logger.info(f"Cache invalid - version mismatch. Got: {cache_data.get('version')}, Expected: {CACHE_VERSION}")
return False
if cache_data.get("model_type") != self.model_type:
logger.info(f"Cache invalid - model type mismatch. Got: {cache_data.get('model_type')}, Expected: {self.model_type}")
return False
# Check if directories have changed
# stored_dirs = cache_data.get("dirs_last_modified", {})
# current_dirs = self._get_dirs_last_modified()
# If directory structure has changed, cache is invalid
# if set(stored_dirs.keys()) != set(current_dirs.keys()):
# logger.info(f"Cache invalid - directory structure changed. Stored: {set(stored_dirs.keys())}, Current: {set(current_dirs.keys())}")
# return False
# Remove the modification time check to make cache validation less strict
# This allows the cache to be valid even when files have changed
# Users can explicitly refresh the cache when needed
return True
async def _load_cache_from_disk(self) -> bool:
"""Load cache data from disk using MessagePack"""
start_time = time.time()
cache_path = self._get_cache_file_path()
if not cache_path or not os.path.exists(cache_path):
return False
try:
with open(cache_path, 'rb') as f:
cache_data = msgpack.unpack(f)
# Validate cache data
if not self._is_cache_valid(cache_data):
logger.info(f"{self.model_type.capitalize()} cache file found but invalid or outdated")
return False
# Load data into memory
self._cache = ModelCache(
raw_data=cache_data["raw_data"],
sorted_by_name=[],
sorted_by_date=[],
folders=[]
)
# Load hash index
hash_index_data = cache_data.get("hash_index", {})
self._hash_index._hash_to_path = hash_index_data.get("hash_to_path", {})
self._hash_index._filename_to_hash = hash_index_data.get("filename_to_hash", {}) # Fix: changed from path_to_hash to filename_to_hash
self._hash_index._duplicate_hashes = hash_index_data.get("duplicate_hashes", {})
self._hash_index._duplicate_filenames = hash_index_data.get("duplicate_filenames", {})
# Load tags count
self._tags_count = cache_data.get("tags_count", {})
# Load excluded models
self._excluded_models = cache_data.get("excluded_models", [])
# Resort the cache
await self._cache.resort()
logger.info(f"Loaded {self.model_type} cache from disk with {len(self._cache.raw_data)} models in {time.time() - start_time:.2f} seconds")
return True
except Exception as e:
logger.error(f"Error loading {self.model_type} cache from disk: {e}")
return False
async def initialize_in_background(self) -> None: async def initialize_in_background(self) -> None:
"""Initialize cache in background using thread pool""" """Initialize cache in background using thread pool"""
try: try:
@@ -66,7 +252,31 @@ class ModelScanner:
# Determine the page type based on model type # Determine the page type based on model type
page_type = 'loras' if self.model_type == 'lora' else 'checkpoints' page_type = 'loras' if self.model_type == 'lora' else 'checkpoints'
# First, count all model files to track progress # First, try to load from cache
await ws_manager.broadcast_init_progress({
'stage': 'loading_cache',
'progress': 0,
'details': f"Loading {self.model_type} cache...",
'scanner_type': self.model_type,
'pageType': page_type
})
cache_loaded = await self._load_cache_from_disk()
if cache_loaded:
# Cache loaded successfully, broadcast complete message
await ws_manager.broadcast_init_progress({
'stage': 'finalizing',
'progress': 100,
'status': 'complete',
'details': f"Loaded {len(self._cache.raw_data)} {self.model_type} files from cache.",
'scanner_type': self.model_type,
'pageType': page_type
})
self._is_initializing = False
return
# If cache loading failed, proceed with full scan
await ws_manager.broadcast_init_progress({ await ws_manager.broadcast_init_progress({
'stage': 'scan_folders', 'stage': 'scan_folders',
'progress': 0, 'progress': 0,
@@ -111,6 +321,9 @@ class ModelScanner:
logger.info(f"{self.model_type.capitalize()} cache initialized in {time.time() - start_time:.2f} seconds. Found {len(self._cache.raw_data)} models") logger.info(f"{self.model_type.capitalize()} cache initialized in {time.time() - start_time:.2f} seconds. Found {len(self._cache.raw_data)} models")
# Save the cache to disk after initialization
await self._save_cache_to_disk()
# Send completion message # Send completion message
await asyncio.sleep(0.5) # Small delay to ensure final progress message is sent await asyncio.sleep(0.5) # Small delay to ensure final progress message is sent
await ws_manager.broadcast_init_progress({ await ws_manager.broadcast_init_progress({
@@ -280,8 +493,13 @@ class ModelScanner:
# Clean up the event loop # Clean up the event loop
loop.close() loop.close()
async def get_cached_data(self, force_refresh: bool = False) -> ModelCache: async def get_cached_data(self, force_refresh: bool = False, rebuild_cache: bool = False) -> ModelCache:
"""Get cached model data, refresh if needed""" """Get cached model data, refresh if needed
Args:
force_refresh: Whether to refresh the cache
rebuild_cache: Whether to completely rebuild the cache by reloading from disk first
"""
# If cache is not initialized, return an empty cache # If cache is not initialized, return an empty cache
# Actual initialization should be done via initialize_in_background # Actual initialization should be done via initialize_in_background
if self._cache is None and not force_refresh: if self._cache is None and not force_refresh:
@@ -293,10 +511,25 @@ class ModelScanner:
) )
# If force refresh is requested, initialize the cache directly # If force refresh is requested, initialize the cache directly
if (force_refresh): if force_refresh:
# If rebuild_cache is True, try to reload from disk before reconciliation
if rebuild_cache:
logger.info(f"{self.model_type.capitalize()} Scanner: Attempting to rebuild cache from disk...")
cache_loaded = await self._load_cache_from_disk()
if cache_loaded:
logger.info(f"{self.model_type.capitalize()} Scanner: Successfully reloaded cache from disk")
else:
logger.info(f"{self.model_type.capitalize()} Scanner: Could not reload cache from disk, proceeding with complete rebuild")
# If loading from disk failed, do a complete rebuild and save to disk
await self._initialize_cache()
await self._save_cache_to_disk()
return self._cache
if self._cache is None: if self._cache is None:
# For initial creation, do a full initialization # For initial creation, do a full initialization
await self._initialize_cache() await self._initialize_cache()
# Save the newly built cache
await self._save_cache_to_disk()
else: else:
# For subsequent refreshes, use fast reconciliation # For subsequent refreshes, use fast reconciliation
await self._reconcile_cache() await self._reconcile_cache()
@@ -484,6 +717,9 @@ class ModelScanner:
# Resort cache # Resort cache
await self._cache.resort() await self._cache.resort()
# Save updated cache to disk
await self._save_cache_to_disk()
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.") logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
except Exception as e: except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True) logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
@@ -699,6 +935,44 @@ class ModelScanner:
except Exception as e: except Exception as e:
logger.error(f"Error processing {file_path}: {e}") logger.error(f"Error processing {file_path}: {e}")
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
"""Add a model to the cache and save to disk
Args:
metadata_dict: The model metadata dictionary
folder: The relative folder path for the model
Returns:
bool: True if successful, False otherwise
"""
try:
if self._cache is None:
await self.get_cached_data()
# Update folder in metadata
metadata_dict['folder'] = folder
# Add to cache
self._cache.raw_data.append(metadata_dict)
# Resort cache data
await self._cache.resort()
# Update folders list
all_folders = set(self._cache.folders)
all_folders.add(folder)
self._cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# Update the hash index
self._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
# Save to disk
await self._save_cache_to_disk()
return True
except Exception as e:
logger.error(f"Error adding model to cache: {e}")
return False
async def move_model(self, source_path: str, target_path: str) -> bool: async def move_model(self, source_path: str, target_path: str) -> bool:
"""Move a model and its associated files to a new location""" """Move a model and its associated files to a new location"""
try: try:
@@ -743,25 +1017,40 @@ class ModelScanner:
shutil.move(real_source, real_target) shutil.move(real_source, real_target)
source_metadata = os.path.join(source_dir, f"{base_name}.metadata.json") # Move all associated files with the same base name
source_metadata = None
moved_metadata_path = None
# Find all files with the same base name in the source directory
files_to_move = []
try:
for file in os.listdir(source_dir):
if file.startswith(base_name + ".") and file != os.path.basename(source_path):
source_file_path = os.path.join(source_dir, file)
# Store metadata file path for special handling
if file == f"{base_name}.metadata.json":
source_metadata = source_file_path
moved_metadata_path = os.path.join(target_path, file)
else:
files_to_move.append((source_file_path, os.path.join(target_path, file)))
except Exception as e:
logger.error(f"Error listing files in {source_dir}: {e}")
# Move all associated files
metadata = None metadata = None
if os.path.exists(source_metadata): for source_file, target_file_path in files_to_move:
target_metadata = os.path.join(target_path, f"{base_name}.metadata.json") try:
shutil.move(source_metadata, target_metadata) shutil.move(source_file, target_file_path)
metadata = await self._update_metadata_paths(target_metadata, target_file) except Exception as e:
logger.error(f"Error moving associated file {source_file}: {e}")
# Move civitai.info file if exists # Handle metadata file specially to update paths
source_civitai = os.path.join(source_dir, f"{base_name}.civitai.info") if source_metadata and os.path.exists(source_metadata):
if os.path.exists(source_civitai): try:
target_civitai = os.path.join(target_path, f"{base_name}.civitai.info") shutil.move(source_metadata, moved_metadata_path)
shutil.move(source_civitai, target_civitai) metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
except Exception as e:
for ext in PREVIEW_EXTENSIONS: logger.error(f"Error moving metadata file: {e}")
source_preview = os.path.join(source_dir, f"{base_name}{ext}")
if os.path.exists(source_preview):
target_preview = os.path.join(target_path, f"{base_name}{ext}")
shutil.move(source_preview, target_preview)
break
await self.update_single_model_cache(source_path, target_file, metadata) await self.update_single_model_cache(source_path, target_file, metadata)
@@ -839,6 +1128,9 @@ class ModelScanner:
await cache.resort() await cache.resort()
# Save the updated cache
await self._save_cache_to_disk()
return True return True
def has_hash(self, sha256: str) -> bool: def has_hash(self, sha256: str) -> bool:
@@ -931,4 +1223,171 @@ class ModelScanner:
if self._cache is None: if self._cache is None:
return False return False
return await self._cache.update_preview_url(file_path, preview_url) updated = await self._cache.update_preview_url(file_path, preview_url)
if updated:
# Save updated cache to disk
await self._save_cache_to_disk()
return updated
async def bulk_delete_models(self, file_paths: List[str]) -> Dict:
"""Delete multiple models and update cache in a batch operation
Args:
file_paths: List of file paths to delete
Returns:
Dict containing results of the operation
"""
try:
if not file_paths:
return {
'success': False,
'error': 'No file paths provided for deletion',
'results': []
}
# Get the file monitor
file_monitor = getattr(self, 'file_monitor', None)
# Keep track of success and failures
results = []
total_deleted = 0
cache_updated = False
# Get cache data
cache = await self.get_cached_data()
# Track deleted models to update cache once
deleted_models = []
for file_path in file_paths:
try:
target_dir = os.path.dirname(file_path)
file_name = os.path.splitext(os.path.basename(file_path))[0]
# Delete all associated files for the model
from ..utils.routes_common import ModelRouteUtils
deleted_files = await ModelRouteUtils.delete_model_files(
target_dir,
file_name,
file_monitor
)
if deleted_files:
deleted_models.append(file_path)
results.append({
'file_path': file_path,
'success': True,
'deleted_files': deleted_files
})
total_deleted += 1
else:
results.append({
'file_path': file_path,
'success': False,
'error': 'No files deleted'
})
except Exception as e:
logger.error(f"Error deleting file {file_path}: {e}")
results.append({
'file_path': file_path,
'success': False,
'error': str(e)
})
# Batch update cache if any models were deleted
if deleted_models:
# Update the cache in a batch operation
cache_updated = await self._batch_update_cache_for_deleted_models(deleted_models)
return {
'success': True,
'total_deleted': total_deleted,
'total_attempted': len(file_paths),
'cache_updated': cache_updated,
'results': results
}
except Exception as e:
logger.error(f"Error in bulk delete: {e}", exc_info=True)
return {
'success': False,
'error': str(e),
'results': []
}
async def _batch_update_cache_for_deleted_models(self, file_paths: List[str]) -> bool:
"""Update cache after multiple models have been deleted
Args:
file_paths: List of file paths that were deleted
Returns:
bool: True if cache was updated and saved successfully
"""
if not file_paths or self._cache is None:
return False
try:
# Get all models that need to be removed from cache
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
if not models_to_remove:
return False
# Update tag counts
for model in models_to_remove:
for tag in model.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
# Update hash index
for model in models_to_remove:
file_path = model['file_path']
if hasattr(self, '_hash_index') and self._hash_index:
# Get the hash and filename before removal for duplicate checking
file_name = os.path.splitext(os.path.basename(file_path))[0]
hash_val = model.get('sha256', '').lower()
# Remove from hash index
self._hash_index.remove_by_path(file_path)
# Check and clean up duplicates
self._cleanup_duplicates_after_removal(hash_val, file_name)
# Update cache data
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in file_paths]
# Resort cache
await self._cache.resort()
# Save updated cache to disk
await self._save_cache_to_disk()
return True
except Exception as e:
logger.error(f"Error updating cache after bulk delete: {e}", exc_info=True)
return False
def _cleanup_duplicates_after_removal(self, hash_val: str, file_name: str) -> None:
"""Clean up duplicate entries in hash index after removing a model
Args:
hash_val: SHA256 hash of the removed model
file_name: File name of the removed model without extension
"""
if not hash_val or not file_name or not hasattr(self, '_hash_index'):
return
# Clean up hash duplicates if only 0 or 1 entries remain
if hash_val in self._hash_index._duplicate_hashes:
if len(self._hash_index._duplicate_hashes[hash_val]) <= 1:
del self._hash_index._duplicate_hashes[hash_val]
# Clean up filename duplicates if only 0 or 1 entries remain
if file_name in self._hash_index._duplicate_filenames:
if len(self._hash_index._duplicate_filenames[file_name]) <= 1:
del self._hash_index._duplicate_filenames[file_name]

View File

@@ -1,8 +1,9 @@
from safetensors import safe_open from safetensors import safe_open
from typing import Dict from typing import Dict, List, Tuple
from .model_utils import determine_base_model from .model_utils import determine_base_model
import os import os
import logging import logging
import json
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -81,3 +82,52 @@ async def extract_checkpoint_metadata(file_path: str) -> dict:
logger.error(f"Error extracting checkpoint metadata for {file_path}: {e}") logger.error(f"Error extracting checkpoint metadata for {file_path}: {e}")
# Return default values # Return default values
return {'base_model': 'Unknown', 'model_type': 'checkpoint'} return {'base_model': 'Unknown', 'model_type': 'checkpoint'}
async def extract_trained_words(file_path: str) -> Tuple[List[Tuple[str, int]], str]:
"""Extract trained words from a safetensors file and sort by frequency
Args:
file_path: Path to the safetensors file
Returns:
Tuple of:
- List of (word, frequency) tuples sorted by frequency (highest first)
- class_tokens value (or None if not found)
"""
class_tokens = None
try:
with safe_open(file_path, framework="pt", device="cpu") as f:
metadata = f.metadata()
# Extract class_tokens from ss_datasets if present
if metadata and "ss_datasets" in metadata:
try:
datasets_data = json.loads(metadata["ss_datasets"])
# Look for class_tokens in the first subset
if datasets_data and isinstance(datasets_data, list) and datasets_data[0].get("subsets"):
subsets = datasets_data[0].get("subsets", [])
if subsets and isinstance(subsets, list) and len(subsets) > 0:
class_tokens = subsets[0].get("class_tokens")
except Exception as e:
logger.error(f"Error parsing ss_datasets for class_tokens: {str(e)}")
# Extract tag frequency as before
if metadata and "ss_tag_frequency" in metadata:
# Parse the JSON string into a dictionary
tag_data = json.loads(metadata["ss_tag_frequency"])
# The structure may have an outer key (like "image_dir" or "img")
# We need to get the inner dictionary with the actual word frequencies
if tag_data:
# Get the first key (usually "image_dir" or "img")
first_key = list(tag_data.keys())[0]
words_dict = tag_data[first_key]
# Sort words by frequency (highest first)
sorted_words = sorted(words_dict.items(), key=lambda x: x[1], reverse=True)
return sorted_words, class_tokens
except Exception as e:
logger.error(f"Error extracting trained words from {file_path}: {str(e)}")
return [], class_tokens

View File

@@ -40,6 +40,7 @@ class ModelRouteUtils:
civitai_metadata: Dict, client: CivitaiClient) -> None: civitai_metadata: Dict, client: CivitaiClient) -> None:
"""Update local metadata with CivitAI data""" """Update local metadata with CivitAI data"""
local_metadata['civitai'] = civitai_metadata local_metadata['civitai'] = civitai_metadata
local_metadata['from_civitai'] = True
# Update model name if available # Update model name if available
if 'model' in civitai_metadata: if 'model' in civitai_metadata:
@@ -50,7 +51,7 @@ class ModelRouteUtils:
model_id = civitai_metadata['modelId'] model_id = civitai_metadata['modelId']
if model_id: if model_id:
model_metadata, _ = await client.get_model_metadata(str(model_id)) model_metadata, _ = await client.get_model_metadata(str(model_id))
if model_metadata: if (model_metadata):
local_metadata['modelDescription'] = model_metadata.get('description', '') local_metadata['modelDescription'] = model_metadata.get('description', '')
local_metadata['tags'] = model_metadata.get('tags', []) local_metadata['tags'] = model_metadata.get('tags', [])
local_metadata['civitai']['creator'] = model_metadata['creator'] local_metadata['civitai']['creator'] = model_metadata['creator']
@@ -61,7 +62,7 @@ class ModelRouteUtils:
# Update preview if needed # Update preview if needed
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']): if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
first_preview = next((img for img in civitai_metadata.get('images', [])), None) first_preview = next((img for img in civitai_metadata.get('images', [])), None)
if first_preview: if (first_preview):
# Determine if content is video or image # Determine if content is video or image
is_video = first_preview['type'] == 'video' is_video = first_preview['type'] == 'video'
@@ -143,6 +144,11 @@ class ModelRouteUtils:
""" """
client = CivitaiClient() client = CivitaiClient()
try: try:
# Validate input parameters
if not isinstance(model_data, dict):
logger.error(f"Invalid model_data type: {type(model_data)}")
return False
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
# Check if model metadata exists # Check if model metadata exists
@@ -166,21 +172,25 @@ class ModelRouteUtils:
client client
) )
# Update cache object directly # Update cache object directly using safe .get() method
model_data.update({ update_dict = {
'model_name': local_metadata.get('model_name'), 'model_name': local_metadata.get('model_name'),
'preview_url': local_metadata.get('preview_url'), 'preview_url': local_metadata.get('preview_url'),
'from_civitai': True, 'from_civitai': True,
'civitai': civitai_metadata 'civitai': civitai_metadata
}) }
model_data.update(update_dict)
# Update cache using the provided function # Update cache using the provided function
await update_cache_func(file_path, file_path, local_metadata) await update_cache_func(file_path, file_path, local_metadata)
return True return True
except KeyError as e:
logger.error(f"Error fetching CivitAI data - Missing key: {e} in model_data={model_data}")
return False
except Exception as e: except Exception as e:
logger.error(f"Error fetching CivitAI data: {e}") logger.error(f"Error fetching CivitAI data: {str(e)}", exc_info=True) # Include stack trace
return False return False
finally: finally:
await client.close() await client.close()
@@ -194,7 +204,7 @@ class ModelRouteUtils:
fields = [ fields = [
"id", "modelId", "name", "createdAt", "updatedAt", "id", "modelId", "name", "createdAt", "updatedAt",
"publishedAt", "trainedWords", "baseModel", "description", "publishedAt", "trainedWords", "baseModel", "description",
"model", "images" "model", "images", "creator"
] ]
return {k: data[k] for k in fields if k in data} return {k: data[k] for k in fields if k in data}
@@ -294,6 +304,8 @@ class ModelRouteUtils:
if hasattr(scanner, '_hash_index') and scanner._hash_index: if hasattr(scanner, '_hash_index') and scanner._hash_index:
scanner._hash_index.remove_by_path(file_path) scanner._hash_index.remove_by_path(file_path)
await scanner._save_cache_to_disk()
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'deleted_files': deleted_files 'deleted_files': deleted_files
@@ -475,6 +487,8 @@ class ModelRouteUtils:
# Add to excluded models list # Add to excluded models list
scanner._excluded_models.append(file_path) scanner._excluded_models.append(file_path)
await scanner._save_cache_to_disk()
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'message': f"Model {os.path.basename(file_path)} excluded" 'message': f"Model {os.path.basename(file_path)} excluded"
@@ -561,3 +575,106 @@ class ModelRouteUtils:
logger.error(f"Error downloading {model_type}: {error_message}") logger.error(f"Error downloading {model_type}: {error_message}")
return web.Response(status=500, text=error_message) return web.Response(status=500, text=error_message)
@staticmethod
async def handle_bulk_delete_models(request: web.Request, scanner) -> web.Response:
"""Handle bulk deletion of models
Args:
request: The aiohttp request
scanner: The model scanner instance with cache management methods
Returns:
web.Response: The HTTP response
"""
try:
data = await request.json()
file_paths = data.get('file_paths', [])
if not file_paths:
return web.json_response({
'success': False,
'error': 'No file paths provided for deletion'
}, status=400)
# Use the scanner's bulk delete method to handle all cache and file operations
result = await scanner.bulk_delete_models(file_paths)
return web.json_response({
'success': result.get('success', False),
'total_deleted': result.get('total_deleted', 0),
'total_attempted': result.get('total_attempted', len(file_paths)),
'results': result.get('results', [])
})
except Exception as e:
logger.error(f"Error in bulk delete: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def handle_relink_civitai(request: web.Request, scanner) -> web.Response:
"""Handle CivitAI metadata re-linking request by model version ID
Args:
request: The aiohttp request
scanner: The model scanner instance with cache management methods
Returns:
web.Response: The HTTP response
"""
try:
data = await request.json()
file_path = data.get('file_path')
model_version_id = data.get('model_version_id')
if not file_path or not model_version_id:
return web.json_response({"success": False, "error": "Both file_path and model_version_id are required"}, status=400)
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
# Check if model metadata exists
local_metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
# Create a client for fetching from Civitai
client = await CivitaiClient.get_instance()
try:
# Fetch metadata by model version ID
civitai_metadata, error = await client.get_model_version_info(model_version_id)
if not civitai_metadata:
error_msg = error or "Model version not found on CivitAI"
return web.json_response({"success": False, "error": error_msg}, status=404)
# Find the primary model file to get the correct SHA256 hash
primary_model_file = None
for file in civitai_metadata.get('files', []):
if file.get('primary', False) and file.get('type') == 'Model':
primary_model_file = file
break
if not primary_model_file or not primary_model_file.get('hashes', {}).get('SHA256'):
return web.json_response({"success": False, "error": "No SHA256 hash found in model metadata"}, status=404)
# Update the SHA256 hash in local metadata (convert to lowercase)
local_metadata['sha256'] = primary_model_file['hashes']['SHA256'].lower()
# Update metadata with CivitAI information
await ModelRouteUtils.update_model_metadata(metadata_path, local_metadata, civitai_metadata, client)
# Update the cache
await scanner.update_single_model_cache(file_path, file_path, local_metadata)
return web.json_response({
"success": True,
"message": f"Model successfully re-linked to Civitai version {model_version_id}",
"hash": local_metadata['sha256']
})
finally:
await client.close()
except Exception as e:
logger.error(f"Error re-linking to CivitAI: {e}", exc_info=True)
return web.json_response({"success": False, "error": str(e)}, status=500)

View File

@@ -4,6 +4,8 @@ import sys
import time import time
import asyncio import asyncio
import logging import logging
import datetime
import shutil
from typing import Dict, Set from typing import Dict, Set
from ..config import config from ..config import config
@@ -26,6 +28,7 @@ class UsageStats:
# Default stats file name # Default stats file name
STATS_FILENAME = "lora_manager_stats.json" STATS_FILENAME = "lora_manager_stats.json"
BACKUP_SUFFIX = ".backup"
def __new__(cls): def __new__(cls):
if cls._instance is None: if cls._instance is None:
@@ -39,8 +42,8 @@ class UsageStats:
# Initialize stats storage # Initialize stats storage
self.stats = { self.stats = {
"checkpoints": {}, # sha256 -> count "checkpoints": {}, # sha256 -> { total: count, history: { date: count } }
"loras": {}, # sha256 -> count "loras": {}, # sha256 -> { total: count, history: { date: count } }
"total_executions": 0, "total_executions": 0,
"last_save_time": 0 "last_save_time": 0
} }
@@ -70,6 +73,68 @@ class UsageStats:
# Use the first lora root # Use the first lora root
return os.path.join(config.loras_roots[0], self.STATS_FILENAME) return os.path.join(config.loras_roots[0], self.STATS_FILENAME)
def _backup_old_stats(self):
"""Backup the old stats file before conversion"""
if os.path.exists(self._stats_file_path):
backup_path = f"{self._stats_file_path}{self.BACKUP_SUFFIX}"
try:
shutil.copy2(self._stats_file_path, backup_path)
logger.info(f"Backed up old stats file to {backup_path}")
return True
except Exception as e:
logger.error(f"Failed to backup stats file: {e}")
return False
def _convert_old_format(self, old_stats):
"""Convert old stats format to new format with history"""
new_stats = {
"checkpoints": {},
"loras": {},
"total_executions": old_stats.get("total_executions", 0),
"last_save_time": old_stats.get("last_save_time", time.time())
}
# Get today's date in YYYY-MM-DD format
today = datetime.datetime.now().strftime("%Y-%m-%d")
# Convert checkpoint stats
if "checkpoints" in old_stats and isinstance(old_stats["checkpoints"], dict):
for hash_id, count in old_stats["checkpoints"].items():
new_stats["checkpoints"][hash_id] = {
"total": count,
"history": {
today: count
}
}
# Convert lora stats
if "loras" in old_stats and isinstance(old_stats["loras"], dict):
for hash_id, count in old_stats["loras"].items():
new_stats["loras"][hash_id] = {
"total": count,
"history": {
today: count
}
}
logger.info("Successfully converted stats from old format to new format with history")
return new_stats
def _is_old_format(self, stats):
"""Check if the stats are in the old format (direct count values)"""
# Check if any lora or checkpoint entry is a direct number instead of an object
if "loras" in stats and isinstance(stats["loras"], dict):
for hash_id, data in stats["loras"].items():
if isinstance(data, (int, float)):
return True
if "checkpoints" in stats and isinstance(stats["checkpoints"], dict):
for hash_id, data in stats["checkpoints"].items():
if isinstance(data, (int, float)):
return True
return False
def _load_stats(self): def _load_stats(self):
"""Load existing statistics from file""" """Load existing statistics from file"""
try: try:
@@ -77,17 +142,26 @@ class UsageStats:
with open(self._stats_file_path, 'r', encoding='utf-8') as f: with open(self._stats_file_path, 'r', encoding='utf-8') as f:
loaded_stats = json.load(f) loaded_stats = json.load(f)
# Update our stats with loaded data # Check if old format and needs conversion
if isinstance(loaded_stats, dict): if self._is_old_format(loaded_stats):
# Update individual sections to maintain structure logger.info("Detected old stats format, performing conversion")
if "checkpoints" in loaded_stats and isinstance(loaded_stats["checkpoints"], dict): self._backup_old_stats()
self.stats["checkpoints"] = loaded_stats["checkpoints"] self.stats = self._convert_old_format(loaded_stats)
else:
# Update our stats with loaded data (already in new format)
if isinstance(loaded_stats, dict):
# Update individual sections to maintain structure
if "checkpoints" in loaded_stats and isinstance(loaded_stats["checkpoints"], dict):
self.stats["checkpoints"] = loaded_stats["checkpoints"]
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict): if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
self.stats["loras"] = loaded_stats["loras"] self.stats["loras"] = loaded_stats["loras"]
if "total_executions" in loaded_stats: if "total_executions" in loaded_stats:
self.stats["total_executions"] = loaded_stats["total_executions"] self.stats["total_executions"] = loaded_stats["total_executions"]
if "last_save_time" in loaded_stats:
self.stats["last_save_time"] = loaded_stats["last_save_time"]
logger.info(f"Loaded usage statistics from {self._stats_file_path}") logger.info(f"Loaded usage statistics from {self._stats_file_path}")
except Exception as e: except Exception as e:
@@ -174,15 +248,18 @@ class UsageStats:
# Increment total executions count # Increment total executions count
self.stats["total_executions"] += 1 self.stats["total_executions"] += 1
# Get today's date in YYYY-MM-DD format
today = datetime.datetime.now().strftime("%Y-%m-%d")
# Process checkpoints # Process checkpoints
if MODELS in metadata and isinstance(metadata[MODELS], dict): if MODELS in metadata and isinstance(metadata[MODELS], dict):
await self._process_checkpoints(metadata[MODELS]) await self._process_checkpoints(metadata[MODELS], today)
# Process loras # Process loras
if LORAS in metadata and isinstance(metadata[LORAS], dict): if LORAS in metadata and isinstance(metadata[LORAS], dict):
await self._process_loras(metadata[LORAS]) await self._process_loras(metadata[LORAS], today)
async def _process_checkpoints(self, models_data): async def _process_checkpoints(self, models_data, today_date):
"""Process checkpoint models from metadata""" """Process checkpoint models from metadata"""
try: try:
# Get checkpoint scanner service # Get checkpoint scanner service
@@ -208,12 +285,24 @@ class UsageStats:
# Get hash for this checkpoint # Get hash for this checkpoint
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename) model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
if model_hash: if model_hash:
# Update stats for this checkpoint # Update stats for this checkpoint with date tracking
self.stats["checkpoints"][model_hash] = self.stats["checkpoints"].get(model_hash, 0) + 1 if model_hash not in self.stats["checkpoints"]:
self.stats["checkpoints"][model_hash] = {
"total": 0,
"history": {}
}
# Increment total count
self.stats["checkpoints"][model_hash]["total"] += 1
# Increment today's count
if today_date not in self.stats["checkpoints"][model_hash]["history"]:
self.stats["checkpoints"][model_hash]["history"][today_date] = 0
self.stats["checkpoints"][model_hash]["history"][today_date] += 1
except Exception as e: except Exception as e:
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True) logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
async def _process_loras(self, loras_data): async def _process_loras(self, loras_data, today_date):
"""Process LoRA models from metadata""" """Process LoRA models from metadata"""
try: try:
# Get LoRA scanner service # Get LoRA scanner service
@@ -239,8 +328,20 @@ class UsageStats:
# Get hash for this LoRA # Get hash for this LoRA
lora_hash = lora_scanner.get_hash_by_filename(lora_name) lora_hash = lora_scanner.get_hash_by_filename(lora_name)
if lora_hash: if lora_hash:
# Update stats for this LoRA # Update stats for this LoRA with date tracking
self.stats["loras"][lora_hash] = self.stats["loras"].get(lora_hash, 0) + 1 if lora_hash not in self.stats["loras"]:
self.stats["loras"][lora_hash] = {
"total": 0,
"history": {}
}
# Increment total count
self.stats["loras"][lora_hash]["total"] += 1
# Increment today's count
if today_date not in self.stats["loras"][lora_hash]["history"]:
self.stats["loras"][lora_hash]["history"][today_date] = 0
self.stats["loras"][lora_hash]["history"][today_date] += 1
except Exception as e: except Exception as e:
logger.error(f"Error processing LoRA usage: {e}", exc_info=True) logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
@@ -251,9 +352,11 @@ class UsageStats:
async def get_model_usage_count(self, model_type, sha256): async def get_model_usage_count(self, model_type, sha256):
"""Get usage count for a specific model by hash""" """Get usage count for a specific model by hash"""
if model_type == "checkpoint": if model_type == "checkpoint":
return self.stats["checkpoints"].get(sha256, 0) if sha256 in self.stats["checkpoints"]:
return self.stats["checkpoints"][sha256]["total"]
elif model_type == "lora": elif model_type == "lora":
return self.stats["loras"].get(sha256, 0) if sha256 in self.stats["loras"]:
return self.stats["loras"][sha256]["total"]
return 0 return 0
async def process_execution(self, prompt_id): async def process_execution(self, prompt_id):

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" 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." description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.8.14" version = "0.8.17"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
@@ -14,7 +14,8 @@ dependencies = [
"olefile", # for getting rid of warning message "olefile", # for getting rid of warning message
"requests", "requests",
"toml", "toml",
"natsort" "natsort",
"msgpack"
] ]
[project.urls] [project.urls]
@@ -24,4 +25,4 @@ Repository = "https://github.com/willmiao/ComfyUI-Lora-Manager"
[tool.comfy] [tool.comfy]
PublisherId = "willmiao" PublisherId = "willmiao"
DisplayName = "ComfyUI-Lora-Manager" DisplayName = "ComfyUI-Lora-Manager"
Icon = "" Icon = "https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/android-chrome-512x512.png?raw=true"

View File

@@ -11,3 +11,4 @@ toml
numpy numpy
torch torch
natsort natsort
msgpack

View File

@@ -295,6 +295,7 @@ class StandaloneLoraManager(LoraManager):
from py.routes.checkpoints_routes import CheckpointsRoutes from py.routes.checkpoints_routes import CheckpointsRoutes
from py.routes.update_routes import UpdateRoutes from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes from py.routes.misc_routes import MiscRoutes
from py.routes.example_images_routes import ExampleImagesRoutes
lora_routes = LoraRoutes() lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes() checkpoints_routes = CheckpointsRoutes()
@@ -306,6 +307,7 @@ class StandaloneLoraManager(LoraManager):
RecipeRoutes.setup_routes(app) RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app) MiscRoutes.setup_routes(app)
ExampleImagesRoutes.setup_routes(app)
# Schedule service initialization # Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services()) app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -32,13 +32,21 @@ html, body {
--card-bg: #ffffff; --card-bg: #ffffff;
--border-color: #e0e0e0; --border-color: #e0e0e0;
/* Color System */ /* Color Components */
--lora-accent: oklch(68% 0.28 256); --lora-accent-l: 68%;
--lora-accent-c: 0.28;
--lora-accent-h: 256;
--lora-warning-l: 75%;
--lora-warning-c: 0.25;
--lora-warning-h: 80;
/* Composed Colors */
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
--lora-surface: oklch(100% 0 0 / 0.98); --lora-surface: oklch(100% 0 0 / 0.98);
--lora-border: oklch(90% 0.02 256 / 0.15); --lora-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(95% 0.02 256); --lora-text: oklch(95% 0.02 256);
--lora-error: oklch(75% 0.32 29); --lora-error: oklch(75% 0.32 29);
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */ --lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Modified to be used with oklch() */
/* Spacing Scale */ /* Spacing Scale */
--space-1: calc(8px * 1); --space-1: calc(8px * 1);

View File

@@ -60,6 +60,18 @@
border-color: var(--lora-accent); border-color: var(--lora-accent);
} }
/* Danger button style - updated to use proper theme variables */
.bulk-operations-actions button.danger-btn {
background: oklch(70% 0.2 29); /* Light red background that works in both themes */
color: oklch(98% 0.01 0); /* Almost white text for good contrast */
border-color: var(--lora-error);
}
.bulk-operations-actions button.danger-btn:hover {
background: var(--lora-error);
color: oklch(100% 0 0); /* Pure white text on hover for maximum contrast */
}
/* Style for selected cards */ /* Style for selected cards */
.lora-card.selected { .lora-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent); box-shadow: 0 0 0 2px var(--lora-accent);
@@ -262,83 +274,6 @@
background: var(--lora-accent); background: var(--lora-accent);
} }
/* NSFW Level Selector */
.nsfw-level-selector {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: var(--z-modal);
width: 300px;
display: none;
}
.nsfw-level-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.nsfw-level-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.close-nsfw-selector {
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
}
.close-nsfw-selector:hover {
background: var(--border-color);
}
.current-level {
margin-bottom: 12px;
padding: 8px;
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.nsfw-level-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nsfw-level-btn {
flex: 1 0 calc(33% - 8px);
padding: 8px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.nsfw-level-btn:hover {
background: var(--lora-border);
}
.nsfw-level-btn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Mobile optimizations */ /* Mobile optimizations */
@media (max-width: 768px) { @media (max-width: 768px) {
.selected-thumbnails-strip { .selected-thumbnails-strip {

View File

@@ -7,9 +7,11 @@
margin-top: var(--space-2); margin-top: var(--space-2);
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */ padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */ padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
width: 100%; /* Ensure it takes full width of container */
max-width: 1400px; /* Base container width */ max-width: 1400px; /* Base container width */
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
box-sizing: border-box; /* Include padding in width calculation */
} }
.lora-card { .lora-card {
@@ -84,23 +86,39 @@
min-height: 0; /* Fix for potential flexbox sizing issue in Firefox */ min-height: 0; /* Fix for potential flexbox sizing issue in Firefox */
} }
/* Smaller text for medium density */
.medium-density .model-name {
font-size: 0.95em;
max-height: 2.6em;
}
.medium-density .base-model-label {
font-size: 0.85em;
max-width: 120px;
}
.medium-density .card-actions i {
font-size: 0.98em;
padding: 4px;
}
/* Smaller text for compact mode */ /* Smaller text for compact mode */
.compact-mode .model-name { .compact-density .model-name {
font-size: 0.9em; font-size: 0.9em;
max-height: 2.4em; max-height: 2.4em;
} }
.compact-mode .base-model-label { .compact-density .base-model-label {
font-size: 0.8em; font-size: 0.8em;
max-width: 110px; max-width: 110px;
} }
.compact-mode .card-actions i { .compact-density .card-actions i {
font-size: 0.95em; font-size: 0.95em;
padding: 3px; padding: 3px;
} }
.compact-mode .model-info { .compact-density .model-info {
padding-bottom: 2px; padding-bottom: 2px;
} }
@@ -359,6 +377,25 @@
font-size: 0.85em; font-size: 0.85em;
} }
/* Prevent text selection on cards and interactive elements */
.lora-card,
.lora-card *,
.card-actions,
.card-actions i,
.toggle-blur-btn,
.show-content-btn,
.card-preview img,
.card-preview video,
.card-footer,
.card-header,
.model-name,
.base-model-label {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Recipe specific elements - migrated from recipe-card.css */ /* Recipe specific elements - migrated from recipe-card.css */
.recipe-indicator { .recipe-indicator {
position: absolute; position: absolute;
@@ -429,10 +466,12 @@
display: block; display: block;
position: relative; position: relative;
margin: 0 auto; margin: 0 auto;
padding: 6px 0; /* Add top/bottom padding equivalent to card padding */ padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
height: auto; height: auto;
width: 100%; width: 100%;
max-width: 1400px; /* Keep the max-width from original grid */ max-width: 1400px; /* Keep the max-width from original grid */
box-sizing: border-box; /* Include padding in width calculation */
overflow-x: hidden; /* Prevent horizontal overflow */
} }
/* For larger screens, allow more space for the cards */ /* For larger screens, allow more space for the cards */

View File

@@ -2,30 +2,46 @@
/* Duplicates banner */ /* Duplicates banner */
.duplicates-banner { .duplicates-banner {
position: sticky; position: sticky; /* Keep the sticky position */
top: 48px; /* Match header height */ top: var(--space-1);
left: 0;
width: 100%; width: 100%;
background-color: var(--card-bg); background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); /* Use accent color with low opacity */
color: var(--text-color); color: var(--text-color);
border-bottom: 1px solid var(--border-color); border-top: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); /* Add top border with accent color */
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
z-index: var(--z-overlay); z-index: var(--z-overlay);
padding: 12px 16px; padding: 12px 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */
transition: all 0.3s ease; transition: all 0.3s ease;
margin-bottom: 20px;
} }
.duplicates-banner .banner-content { .duplicates-banner .banner-content {
position: relative;
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 0 16px;
}
/* Responsive container for larger screens - match container in layout.css */
@media (min-width: 2000px) {
.duplicates-banner .banner-content {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.duplicates-banner .banner-content {
max-width: 2400px;
}
} }
.duplicates-banner i.fa-exclamation-triangle { .duplicates-banner i.fa-exclamation-triangle {
font-size: 18px; font-size: 18px;
color: oklch(var(--lora-warning)); color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
} }
.duplicates-banner .banner-actions { .duplicates-banner .banner-actions {
@@ -35,6 +51,29 @@
align-items: center; align-items: center;
} }
/* Improved exit button in banner */
.duplicates-banner button.btn-exit-mode {
min-width: 120px;
background-color: var(--card-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: var(--border-radius-xs);
font-size: 0.85em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
}
.duplicates-banner button.btn-exit-mode:hover {
background-color: var(--bg-color);
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
transform: translateY(-1px);
}
.duplicates-banner button { .duplicates-banner button {
min-width: 100px; min-width: 100px;
display: flex; display: flex;
@@ -53,7 +92,7 @@
} }
.duplicates-banner button:hover { .duplicates-banner button:hover {
border-color: var(--lora-accent); border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
background: var(--bg-color); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
@@ -78,23 +117,42 @@
/* Duplicate groups */ /* Duplicate groups */
.duplicate-group { .duplicate-group {
position: relative; position: relative;
border: 2px solid oklch(var(--lora-warning)); border: 2px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
padding: 16px; padding: 16px;
margin-bottom: 24px; margin-bottom: 24px;
background: var(--card-bg); background: var(--card-bg);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); /* Add subtle shadow to groups */
/* Add responsive width settings to match banner */
max-width: 1400px;
margin-left: auto;
margin-right: auto;
}
/* Add responsive container adjustments for duplicate groups - match container in banner */
@media (min-width: 2000px) {
.duplicate-group {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.duplicate-group {
max-width: 2400px;
}
} }
.duplicate-group-header { .duplicate-group-header {
background-color: var(--bg-color); background-color: var(--bg-color);
color: var(--text-color); color: var(--text-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: 8px 16px; padding: 10px 16px; /* Slightly increased padding */
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
margin-bottom: 16px; margin-bottom: 16px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-left: 4px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Add accent border on the left */
} }
.duplicate-group-header span:last-child { .duplicate-group-header span:last-child {
@@ -122,7 +180,7 @@
} }
.duplicate-group-header button:hover { .duplicate-group-header button:hover {
border-color: var(--lora-accent); border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
background: var(--bg-color); background: var(--bg-color);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
@@ -177,7 +235,7 @@
} }
.group-toggle-btn:hover { .group-toggle-btn:hover {
border-color: var(--lora-accent); border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08); box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
} }
@@ -189,16 +247,16 @@
} }
.lora-card.duplicate:hover { .lora-card.duplicate:hover {
border-color: var(--lora-accent); border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
} }
.lora-card.duplicate.latest { .lora-card.duplicate.latest {
border-style: solid; border-style: solid;
border-color: oklch(var(--lora-warning)); border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
} }
.lora-card.duplicate-selected { .lora-card.duplicate-selected {
border: 2px solid oklch(var(--lora-accent)); border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
} }
@@ -218,7 +276,7 @@
position: absolute; position: absolute;
top: 10px; top: 10px;
left: 10px; left: 10px;
background: oklch(var(--lora-accent)); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
color: white; color: white;
font-size: 12px; font-size: 12px;
padding: 2px 6px; padding: 2px 6px;
@@ -226,6 +284,128 @@
z-index: 5; z-index: 5;
} }
/* Model tooltip for duplicates mode */
.model-tooltip {
position: absolute;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
padding: 10px;
z-index: 1000;
max-width: 350px;
min-width: 250px;
color: var(--text-color);
font-size: 0.9em;
pointer-events: none; /* Don't block mouse events */
}
.model-tooltip .tooltip-header {
font-weight: bold;
font-size: 1.1em;
margin-bottom: 8px;
padding-bottom: 5px;
border-bottom: 1px solid var(--border-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-tooltip .tooltip-info div {
margin-bottom: 4px;
display: flex;
flex-wrap: wrap;
}
.model-tooltip .tooltip-info div strong {
margin-right: 5px;
min-width: 70px;
}
/* Badge Styles */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px; /* Reduced from 20px */
height: 16px; /* Reduced from 20px */
border-radius: 8px; /* Adjusted for smaller size */
background-color: var(--lora-error);
color: white;
font-size: 10px; /* Smaller font size */
font-weight: bold;
padding: 0 4px; /* Reduced padding */
position: absolute;
top: -8px; /* Moved closer to button */
right: -8px; /* Moved closer to button */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); /* Softer shadow */
transition: transform 0.2s ease, opacity 0.2s ease;
}
.badge:empty {
display: none;
}
/* Make the pulse animation more subtle */
.badge.pulse {
animation: badge-pulse 2s infinite; /* Slower animation */
}
@keyframes badge-pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1); /* Less expansion */
}
100% {
transform: scale(1);
}
}
/* Help icon styling */
.help-icon {
color: var(--text-color);
opacity: 0.7;
cursor: help;
font-size: 16px;
margin-left: 8px;
transition: all 0.2s ease;
}
.help-icon:hover {
opacity: 1;
color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
}
/* Help tooltip */
.help-tooltip {
display: none;
position: absolute;
max-width: 400px;
background: var(--card-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: var(--z-overlay);
font-size: 0.9em;
margin-top: 10px;
text-align: left;
pointer-events: none;
}
.help-tooltip:after {
content: "";
position: absolute;
top: -8px;
left: 10px; /* Position the arrow near the left instead of center */
border-width: 0 8px 8px 8px;
border-style: solid;
border-color: transparent transparent var(--card-bg) transparent;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.duplicates-banner .banner-content { .duplicates-banner .banner-content {
@@ -256,4 +436,50 @@
margin-left: 0; margin-left: 0;
flex: 1; flex: 1;
} }
.help-tooltip {
max-width: calc(100% - 40px);
}
/* Remove the fixed positioning adjustments for mobile since we're now using dynamic positioning */
.help-tooltip:after {
left: 10px;
}
}
/* In dark mode, add additional distinction */
html[data-theme="dark"] .duplicates-banner {
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); /* Stronger shadow in dark mode */
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
}
html[data-theme="dark"] .duplicate-group {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); /* Stronger shadow in dark mode */
}
html[data-theme="dark"] .help-tooltip {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Styles for disabled controls during duplicates mode */
.disabled-during-duplicates {
opacity: 0.5 !important;
pointer-events: none !important;
cursor: not-allowed !important;
user-select: none !important;
filter: grayscale(50%) !important;
}
/* Make the active duplicates button more prominent */
#findDuplicatesBtn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
position: relative;
z-index: 5;
}
#findDuplicatesBtn.active:hover {
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
} }

View File

@@ -136,6 +136,30 @@
opacity: 0; opacity: 0;
} }
/* Badge styling */
.update-badge {
position: absolute;
top: -3px;
right: -3px;
width: 8px;
height: 8px;
background-color: var(--lora-error);
border-radius: 50%;
border: 2px solid var(--card-bg);
transition: all 0.2s ease;
pointer-events: none;
opacity: 0;
}
.update-badge.visible {
opacity: 1;
}
.update-badge.hidden,
.update-badge:not(.visible) {
opacity: 0;
}
/* Mobile adjustments */ /* Mobile adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.app-title { .app-title {

View File

@@ -0,0 +1,96 @@
/* Keyboard navigation indicator and help */
.keyboard-nav-hint {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: help;
transition: all 0.2s ease;
margin-left: 8px;
}
.keyboard-nav-hint:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
}
.keyboard-nav-hint i {
font-size: 14px;
}
/* Tooltip styling */
.tooltip {
position: relative;
}
.tooltip .tooltiptext {
visibility: hidden;
width: 240px;
background-color: var(--lora-surface);
color: var(--text-color);
text-align: center;
border-radius: var(--border-radius-xs);
padding: 8px;
position: absolute;
z-index: 9999; /* 确保在卡片上方显示 */
left: 120%; /* 将tooltip显示在图标右侧 */
top: 50%; /* 垂直居中 */
transform: translateY(-50%); /* 垂直居中 */
opacity: 0;
transition: opacity 0.3s;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
border: 1px solid var(--lora-border);
font-size: 0.85em;
line-height: 1.4;
}
.tooltip .tooltiptext::after {
content: "";
position: absolute;
top: 50%; /* 箭头垂直居中 */
right: 100%; /* 箭头在左侧 */
margin-top: -5px;
border-width: 5px;
border-style: solid;
border-color: transparent var(--lora-border) transparent transparent; /* 箭头指向左侧 */
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}
/* Keyboard shortcuts table */
.keyboard-shortcuts {
width: 100%;
border-collapse: collapse;
margin-top: 5px;
}
.keyboard-shortcuts td {
padding: 4px;
text-align: left;
}
.keyboard-shortcuts td:first-child {
font-weight: bold;
width: 40%;
}
.key {
display: inline-block;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 1px 5px;
font-size: 0.8em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}

View File

@@ -132,7 +132,7 @@
} }
.scroll-indicator:hover { .scroll-indicator:hover {
background: oklch(var(--lora-accent) / 0.1); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: translateY(-1px); transform: translateY(-1px);
} }
@@ -241,7 +241,7 @@
/* Keep the hover effect using accent color */ /* Keep the hover effect using accent color */
.trigger-word-tag:hover { .trigger-word-tag:hover {
background: oklch(var(--lora-accent) / 0.1); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent); border-color: var(--lora-accent);
} }
@@ -301,7 +301,7 @@
} }
.trigger-words-edit-controls button:hover { .trigger-words-edit-controls button:hover {
background: oklch(var(--lora-accent) / 0.1); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent); border-color: var(--lora-accent);
} }
@@ -324,6 +324,7 @@
margin-top: var(--space-2); margin-top: var(--space-2);
display: flex; display: flex;
gap: var(--space-1); gap: var(--space-1);
position: relative; /* Added for dropdown positioning */
} }
.new-trigger-word-input { .new-trigger-word-input {
@@ -346,7 +347,7 @@
padding: 4px 8px; padding: 4px 8px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--bg-color); background: var (--bg-color);
color: var(--text-color); color: var(--text-color);
font-size: 0.85em; font-size: 0.85em;
cursor: pointer; cursor: pointer;
@@ -371,6 +372,146 @@
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
/* Trained Words Loading Indicator */
.trained-words-loading {
display: flex;
align-items: center;
justify-content: center;
margin: var(--space-1) 0;
color: var(--text-color);
opacity: 0.7;
font-size: 0.9em;
gap: 8px;
}
.trained-words-loading i {
color: var(--lora-accent);
}
/* Trained Words Dropdown Styles */
.trained-words-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
margin-top: 4px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.trained-words-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--card-bg);
border-bottom: 1px solid var(--border-color);
}
.trained-words-header span {
font-size: 0.9em;
font-weight: 500;
color: var(--text-color);
}
.trained-words-header small {
font-size: 0.8em;
opacity: 0.7;
}
.trained-words-container {
max-height: 200px;
overflow-y: auto;
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.trained-word-item {
display: inline-flex;
align-items: center;
justify-content: space-between;
padding: 5px 10px;
cursor: pointer;
transition: all 0.2s ease;
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
max-width: 150px;
}
.trained-word-item:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
}
.trained-word-item.already-added {
opacity: 0.7;
cursor: default;
}
.trained-word-item.already-added:hover {
background: var(--lora-surface);
border-color: var(--lora-border);
}
.trained-word-text {
color: var(--lora-accent);
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: 4px;
max-width: 100px;
}
.trained-word-meta {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.trained-word-freq {
color: var (--text-color);
font-size: 0.75em;
background: rgba(0, 0, 0, 0.05);
border-radius: 10px;
min-width: 20px;
padding: 1px 5px;
text-align: center;
line-height: 1.2;
}
[data-theme="dark"] .trained-word-freq {
background: rgba(255, 255, 255, 0.05);
}
.added-indicator {
color: var(--lora-accent);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75em;
}
.no-trained-words {
padding: 16px 12px;
text-align: center;
color: var(--text-color);
opacity: 0.7;
font-style: italic;
font-size: 0.9em;
}
/* Editable Fields */ /* Editable Fields */
.editable-field { .editable-field {
position: relative; position: relative;
@@ -515,7 +656,7 @@
} }
.preset-tag:hover { .preset-tag:hover {
background: oklch(var(--lora-accent) / 0.1); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent); border-color: var(--lora-accent);
} }
@@ -549,10 +690,6 @@
position: relative; position: relative;
} }
.file-name-wrapper:hover {
background: oklch(var(--lora-accent) / 0.1);
}
.file-name-content { .file-name-content {
padding: 2px 4px; padding: 2px 4px;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
@@ -673,11 +810,6 @@
opacity: 0.9; opacity: 0.9;
} }
/* Model name field styles - complete replacement */
.model-name-field {
display: none;
}
/* New Model Name Header Styles */ /* New Model Name Header Styles */
.model-name-header { .model-name-header {
display: flex; display: flex;
@@ -754,7 +886,7 @@
.tab-btn:hover { .tab-btn:hover {
opacity: 1; opacity: 1;
background: oklch(var(--lora-accent) / 0.05); background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
} }
.tab-btn.active { .tab-btn.active {
@@ -936,7 +1068,7 @@
.model-description-content pre { .model-description-content pre {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: var(--space-1); padding: var (--space-1);
white-space: pre-wrap; white-space: pre-wrap;
margin: 1em 0; margin: 1em 0;
overflow-x: auto; overflow-x: auto;
@@ -1324,3 +1456,88 @@
.recipes-error i { .recipes-error i {
color: var(--lora-error); color: var(--lora-error);
} }
/* Creator Information Styles */
.creator-info {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: var(--space-1);
padding: 6px 10px;
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
max-width: fit-content;
}
[data-theme="dark"] .creator-info {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.creator-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
}
.creator-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.creator-placeholder {
background: var(--lora-accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.creator-username {
font-size: 0.9em;
font-weight: 500;
color: var(--text-color);
}
/* Optional: add hover effect for creator info */
.creator-info:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
}
/* Class tokens styling */
.class-tokens-container {
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.class-token-item {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1) !important;
border: 1px solid var(--lora-accent) !important;
}
.token-badge {
background: var(--lora-accent);
color: white;
font-size: 0.7em;
padding: 2px 5px;
border-radius: 8px;
white-space: nowrap;
}
.dropdown-separator {
height: 1px;
background: var(--lora-border);
margin: 5px 10px;
}

View File

@@ -40,3 +40,80 @@
width: 16px; width: 16px;
text-align: center; text-align: center;
} }
/* NSFW Level Selector */
.nsfw-level-selector {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 16px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
z-index: var(--z-modal);
width: 300px;
display: none;
}
.nsfw-level-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.nsfw-level-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.close-nsfw-selector {
background: transparent;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
}
.close-nsfw-selector:hover {
background: var(--border-color);
}
.current-level {
margin-bottom: 12px;
padding: 8px;
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.nsfw-level-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.nsfw-level-btn {
flex: 1 0 calc(33% - 8px);
padding: 8px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
}
.nsfw-level-btn:hover {
background: var(--lora-border);
}
.nsfw-level-btn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}

View File

@@ -110,7 +110,7 @@ body.modal-open {
margin-top: var(--space-3); margin-top: var(--space-3);
} }
.cancel-btn, .delete-btn, .exclude-btn { .cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
padding: 8px var(--space-2); padding: 8px var(--space-2);
border-radius: 6px; border-radius: 6px;
border: none; border: none;
@@ -131,7 +131,7 @@ body.modal-open {
} }
/* Style for exclude button - different from delete button */ /* Style for exclude button - different from delete button */
.exclude-btn { .exclude-btn, .confirm-btn {
background: var(--lora-accent, #4f46e5); background: var(--lora-accent, #4f46e5);
color: white; color: white;
} }
@@ -144,14 +144,14 @@ body.modal-open {
opacity: 0.9; opacity: 0.9;
} }
.exclude-btn:hover { .exclude-btn:hover, .confirm-btn:hover {
opacity: 0.9; opacity: 0.9;
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%); background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
} }
.modal-content h2 { .modal-content h2 {
color: var(--text-color); color: var(--text-color);
margin-bottom: var(--space-2); margin-bottom: var(--space-1);
font-size: 1.5em; font-size: 1.5em;
} }
@@ -306,6 +306,18 @@ body.modal-open {
width: 100%; /* Full width */ width: 100%; /* Full width */
} }
/* Migrate control styling */
.migrate-control {
display: flex;
align-items: center;
gap: 8px;
}
.migrate-control input {
flex: 1;
min-width: 0;
}
/* 统一各个 section 的样式 */ /* 统一各个 section 的样式 */
.support-section, .support-section,
.changelog-section, .changelog-section,
@@ -363,6 +375,12 @@ body.modal-open {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
/* Add disabled style for setting items */
.setting-item[data-requires-centralized="true"].disabled {
opacity: 0.6;
pointer-events: none;
}
/* Control row with label and input together */ /* Control row with label and input together */
.setting-row { .setting-row {
display: flex; display: flex;
@@ -526,7 +544,7 @@ input:checked + .toggle-slider:before {
gap: 8px; gap: 8px;
padding: 8px 16px; padding: 8px 16px;
background-color: var(--card-bg); background-color: var(--card-bg);
color: var(--text-color); color: var (--text-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
cursor: pointer; cursor: pointer;
@@ -554,6 +572,13 @@ input:checked + .toggle-slider:before {
pointer-events: none; pointer-events: none;
} }
.restart-required-icon {
color: var(--lora-warning);
margin-left: 5px;
font-size: 0.85em;
vertical-align: text-bottom;
}
/* Dark theme specific button adjustments */ /* Dark theme specific button adjustments */
[data-theme="dark"] .primary-btn:hover { [data-theme="dark"] .primary-btn:hover {
background-color: oklch(from var(--lora-accent) l c h / 75%); background-color: oklch(from var(--lora-accent) l c h / 75%);
@@ -683,3 +708,268 @@ input:checked + .toggle-slider:before {
[data-theme="dark"] .warning-text { [data-theme="dark"] .warning-text {
color: var(--lora-warning, #f39c12); color: var(--lora-warning, #f39c12);
} }
/* Add styles for density description list */
.density-description {
margin: 8px 0;
padding-left: 20px;
font-size: 0.9em;
}
.density-description li {
margin-bottom: 4px;
}
/* Help Modal styles */
.help-modal {
max-width: 850px;
}
.help-header {
display: flex;
align-items: center;
margin-bottom: var(--space-2);
}
.modal-help-icon {
font-size: 24px;
color: var(--lora-accent);
margin-right: var(--space-2);
vertical-align: text-bottom;
}
/* Tab navigation styles */
.help-tabs {
display: flex;
border-bottom: 1px solid var(--lora-border);
margin-bottom: var(--space-2);
gap: 8px;
}
.tab-btn {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color);
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
opacity: 0.7;
}
.tab-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
opacity: 0.9;
}
.tab-btn.active {
color: var(--lora-accent);
border-bottom: 2px solid var(--lora-accent);
opacity: 1;
}
/* Tab content styles */
.help-content {
padding: var(--space-1) 0;
overflow-y: auto;
}
.tab-pane {
display: none;
}
.tab-pane.active {
display: block;
}
/* Video embed styles */
.video-embed {
position: relative;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
height: 0;
overflow: hidden;
max-width: 100%;
margin-bottom: var(--space-2);
border-radius: var(--border-radius-sm);
}
.video-embed iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.video-embed.small {
max-width: 100%;
margin-bottom: var(--space-1);
}
.help-text {
margin: var(--space-2) 0;
}
.help-text ul {
padding-left: 20px;
margin-top: 8px;
}
.help-text li {
margin-bottom: 8px;
}
/* Documentation link styles */
.docs-section {
margin-bottom: var(--space-3);
}
.docs-section h4 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: var(--space-1);
}
.docs-links {
list-style-type: none;
padding-left: var(--space-3);
}
.docs-links li {
margin-bottom: var(--space-1);
position: relative;
}
.docs-links li:before {
content: "•";
position: absolute;
left: -15px;
color: var(--lora-accent);
}
.docs-links a {
color: var(--lora-accent);
text-decoration: none;
transition: color 0.2s;
}
.docs-links a:hover {
text-decoration: underline;
}
/* Update video list styles */
.video-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.video-item {
display: flex;
flex-direction: column;
}
.video-info {
padding: var(--space-1);
}
.video-info h4 {
margin-bottom: var(--space-1);
}
.video-info p {
font-size: 0.9em;
opacity: 0.8;
}
/* Dark theme adjustments */
[data-theme="dark"] .tab-btn:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* Update date badge styles */
.update-date-badge {
display: inline-flex;
align-items: center;
font-size: 0.75em;
font-weight: 500;
background-color: var(--lora-accent);
color: var(--lora-text);
padding: 4px 8px;
border-radius: 12px;
margin-left: 10px;
vertical-align: middle;
animation: fadeIn 0.5s ease-in-out;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.update-date-badge i {
margin-right: 5px;
font-size: 0.9em;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
/* Dark theme adjustments */
[data-theme="dark"] .update-date-badge {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Re-link to Civitai Modal styles */
.warning-box {
background-color: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.5);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
margin-bottom: var(--space-3);
}
.warning-box i {
color: var(--lora-warning);
margin-right: var(--space-1);
}
.warning-box ul {
padding-left: 20px;
margin: var(--space-1) 0;
}
.warning-box li {
margin-bottom: 4px;
}
.input-group {
display: flex;
flex-direction: column;
margin-bottom: var(--space-2);
}
.input-group label {
margin-bottom: var(--space-1);
font-weight: 500;
}
.input-group input {
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
}
.input-error {
color: var(--lora-error);
font-size: 0.9em;
min-height: 20px;
margin-top: 4px;
}
[data-theme="dark"] .warning-box {
background-color: rgba(255, 193, 7, 0.05);
border-color: rgba(255, 193, 7, 0.3);
}

View File

@@ -1,10 +1,10 @@
.page-content { .page-content {
height: calc(100vh - 48px); /* Full height minus header */ height: calc(100vh - 48px); /* Full height minus header */
margin-top: 48px; /* Push down below header */ margin-top: 48px; /* Push down below header */
overflow-y: auto; /* Enable scrolling here */
width: 100%; width: 100%;
position: relative; position: relative;
overflow-y: scroll; overflow-x: hidden; /* Prevent horizontal scrolling */
overflow-y: auto; /* Enable vertical scrolling */
} }
.container { .container {
@@ -35,6 +35,13 @@
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.controls-right {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto; /* Push to the right */
}
.actions { .actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -306,6 +313,86 @@
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
} }
/* Prevent text selection in control and header areas */
.tag,
.control-group button,
.control-group select,
.toggle-folders-btn,
.bulk-operations-panel,
.app-header,
.header-branding,
.app-title,
.main-nav,
.nav-item,
.header-actions button,
.header-controls,
.toggle-folders-container button {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* Dropdown Button Styling */
.dropdown-group {
position: relative;
display: flex;
}
.dropdown-main {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 1px solid rgba(0, 0, 0, 0.1);
}
.dropdown-toggle {
width: 24px !important;
min-width: unset !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding: 0 !important;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
min-width: 230px;
padding: 5px 0;
margin: 2px 0 0;
font-size: 0.85em;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
}
.dropdown-group.active .dropdown-menu {
display: block;
}
.dropdown-item {
display: block;
padding: 6px 15px;
clear: both;
font-weight: 400;
color: var(--text-color);
cursor: pointer;
transition: background-color 0.2s ease;
}
.dropdown-item:hover {
background-color: oklch(var(--lora-accent) / 0.1);
}
.dropdown-item i {
margin-right: 8px;
width: 16px;
text-align: center;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.actions { .actions {
flex-wrap: wrap; flex-wrap: wrap;
@@ -318,11 +405,14 @@
width: 100%; width: 100%;
} }
.controls-right {
width: 100%;
justify-content: flex-end;
margin-top: 8px;
}
.toggle-folders-container { .toggle-folders-container {
margin-left: 0; margin-left: 0;
width: 100%;
display: flex;
justify-content: flex-end;
} }
.folder-tags-container { .folder-tags-container {
@@ -348,4 +438,9 @@
.back-to-top { .back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */ bottom: 60px; /* Give some extra space from bottom on mobile */
} }
.dropdown-menu {
left: auto;
right: 0; /* Align to right on mobile */
}
} }

View File

@@ -23,6 +23,7 @@
@import 'components/progress-panel.css'; @import 'components/progress-panel.css';
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */ @import 'components/alphabet-bar.css'; /* Add alphabet bar component */
@import 'components/duplicates.css'; /* Add duplicates component */ @import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
.initialization-notice { .initialization-notice {
display: flex; display: flex;

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -434,6 +434,8 @@ export function replaceModelPreview(filePath, modelType = 'lora') {
// Delete a model (generic) // Delete a model (generic)
export async function deleteModel(filePath, modelType = 'lora') { export async function deleteModel(filePath, modelType = 'lora') {
try { try {
state.loadingManager.showSimpleLoading(`Deleting ${modelType}...`);
const endpoint = modelType === 'checkpoint' const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/delete' ? '/api/checkpoints/delete'
: '/api/delete_model'; : '/api/delete_model';
@@ -475,6 +477,8 @@ export async function deleteModel(filePath, modelType = 'lora') {
console.error(`Error deleting ${modelType}:`, error); console.error(`Error deleting ${modelType}:`, error);
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error'); showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
return false; return false;
} finally {
state.loadingManager.hide();
} }
} }
@@ -499,13 +503,18 @@ export async function refreshModels(options = {}) {
const { const {
modelType = 'lora', modelType = 'lora',
scanEndpoint = '/api/loras/scan', scanEndpoint = '/api/loras/scan',
resetAndReloadFunction resetAndReloadFunction,
fullRebuild = false // New parameter with default value false
} = options; } = options;
try { try {
state.loadingManager.showSimpleLoading(`Refreshing ${modelType}s...`); state.loadingManager.showSimpleLoading(`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${modelType}s...`);
const response = await fetch(scanEndpoint); // Add fullRebuild parameter to the request
const url = new URL(scanEndpoint, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`); throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`);
@@ -515,10 +524,10 @@ export async function refreshModels(options = {}) {
await resetAndReloadFunction(true); // update folders await resetAndReloadFunction(true); // update folders
} }
showToast(`Refresh complete`, 'success'); showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
} catch (error) { } catch (error) {
console.error(`Refresh failed:`, error); console.error(`Refresh failed:`, error);
showToast(`Failed to refresh ${modelType}s`, 'error'); showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${modelType}s`, 'error');
} finally { } finally {
state.loadingManager.hide(); state.loadingManager.hide();
state.loadingManager.restoreProgressBar(); state.loadingManager.restoreProgressBar();
@@ -657,6 +666,8 @@ export async function refreshSingleModelMetadata(filePath, modelType = 'lora') {
// Generic function to exclude a model // Generic function to exclude a model
export async function excludeModel(filePath, modelType = 'lora') { export async function excludeModel(filePath, modelType = 'lora') {
try { try {
state.loadingManager.showSimpleLoading(`Excluding ${modelType}...`);
const endpoint = modelType === 'checkpoint' const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/exclude' ? '/api/checkpoints/exclude'
: '/api/loras/exclude'; : '/api/loras/exclude';
@@ -698,6 +709,8 @@ export async function excludeModel(filePath, modelType = 'lora') {
console.error(`Error excluding ${modelType}:`, error); console.error(`Error excluding ${modelType}:`, error);
showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error'); showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error');
return false; return false;
} finally {
state.loadingManager.hide();
} }
} }

View File

@@ -76,11 +76,12 @@ export async function resetAndReload(updateFolders = false) {
} }
// Refresh checkpoints // Refresh checkpoints
export async function refreshCheckpoints() { export async function refreshCheckpoints(fullRebuild = false) {
return baseRefreshModels({ return baseRefreshModels({
modelType: 'checkpoint', modelType: 'checkpoint',
scanEndpoint: '/api/checkpoints/scan', scanEndpoint: '/api/checkpoints/scan',
resetAndReloadFunction: resetAndReload resetAndReloadFunction: resetAndReload,
fullRebuild: fullRebuild
}); });
} }
@@ -119,22 +120,30 @@ export async function refreshSingleCheckpointMetadata(filePath) {
* @returns {Promise} - Promise that resolves with the server response * @returns {Promise} - Promise that resolves with the server response
*/ */
export async function saveModelMetadata(filePath, data) { export async function saveModelMetadata(filePath, data) {
const response = await fetch('/api/checkpoints/save-metadata', { try {
method: 'POST', // Show loading indicator
headers: { state.loadingManager.showSimpleLoading('Saving metadata...');
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) { const response = await fetch('/api/checkpoints/save-metadata', {
throw new Error('Failed to save metadata'); method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return response.json();
} finally {
// Always hide the loading indicator when done
state.loadingManager.hide();
} }
return response.json();
} }
/** /**
@@ -145,3 +154,39 @@ export async function saveModelMetadata(filePath, data) {
export function excludeCheckpoint(filePath) { export function excludeCheckpoint(filePath) {
return baseExcludeModel(filePath, 'checkpoint'); return baseExcludeModel(filePath, 'checkpoint');
} }
/**
* Rename a checkpoint file
* @param {string} filePath - Current file path
* @param {string} newFileName - New file name (without path)
* @returns {Promise<Object>} - Promise that resolves with the server response
*/
export async function renameCheckpointFile(filePath, newFileName) {
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming checkpoint file...');
const response = await fetch('/api/rename_checkpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
new_file_name: newFileName
})
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error renaming checkpoint file:', error);
throw error;
} finally {
// Hide loading indicator
state.loadingManager.hide();
}
}

View File

@@ -21,22 +21,30 @@ import { state, getCurrentPageState } from '../state/index.js';
* @returns {Promise} Promise of the save operation * @returns {Promise} Promise of the save operation
*/ */
export async function saveModelMetadata(filePath, data) { export async function saveModelMetadata(filePath, data) {
const response = await fetch('/api/loras/save-metadata', { try {
method: 'POST', // Show loading indicator
headers: { state.loadingManager.showSimpleLoading('Saving metadata...');
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) { const response = await fetch('/api/loras/save-metadata', {
throw new Error('Failed to save metadata'); method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return response.json();
} finally {
// Always hide the loading indicator when done
state.loadingManager.hide();
} }
return response.json();
} }
/** /**
@@ -143,11 +151,12 @@ export async function resetAndReload(updateFolders = false) {
} }
} }
export async function refreshLoras() { export async function refreshLoras(fullRebuild = false) {
return baseRefreshModels({ return baseRefreshModels({
modelType: 'lora', modelType: 'lora',
scanEndpoint: '/api/loras/scan', scanEndpoint: '/api/loras/scan',
resetAndReloadFunction: resetAndReload resetAndReloadFunction: resetAndReload,
fullRebuild: fullRebuild
}); });
} }
@@ -173,3 +182,39 @@ export async function fetchModelDescription(modelId, filePath) {
throw error; throw error;
} }
} }
/**
* Rename a LoRA file
* @param {string} filePath - Current file path
* @param {string} newFileName - New file name (without path)
* @returns {Promise<Object>} - Promise that resolves with the server response
*/
export async function renameLoraFile(filePath, newFileName) {
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming LoRA 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
})
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error renaming LoRA file:', error);
throw error;
} finally {
// Hide loading indicator
state.loadingManager.hide();
}
}

View File

@@ -4,6 +4,7 @@ import { createPageControls } from './components/controls/index.js';
import { loadMoreCheckpoints } from './api/checkpointApi.js'; import { loadMoreCheckpoints } from './api/checkpointApi.js';
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js'; import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
import { CheckpointContextMenu } from './components/ContextMenu/index.js'; import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
// Initialize the Checkpoints page // Initialize the Checkpoints page
class CheckpointsPageManager { class CheckpointsPageManager {
@@ -14,6 +15,9 @@ class CheckpointsPageManager {
// Initialize checkpoint download manager // Initialize checkpoint download manager
window.checkpointDownloadManager = new CheckpointDownloadManager(); window.checkpointDownloadManager = new CheckpointDownloadManager();
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this, 'checkpoints');
// Expose only necessary functions to global scope // Expose only necessary functions to global scope
this._exposeRequiredGlobalFunctions(); this._exposeRequiredGlobalFunctions();
} }
@@ -29,6 +33,9 @@ class CheckpointsPageManager {
window.checkpointManager = { window.checkpointManager = {
loadCheckpoints: (reset) => loadMoreCheckpoints(reset) loadCheckpoints: (reset) => loadMoreCheckpoints(reset)
}; };
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
} }
async initialize() { async initialize() {

View File

@@ -1,4 +1,4 @@
import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, openExampleImagesFolder } from '../utils/uiHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { showCheckpointModal } from './checkpointModal/index.js'; import { showCheckpointModal } from './checkpointModal/index.js';
import { NSFW_LEVELS } from '../utils/constants.js'; import { NSFW_LEVELS } from '../utils/constants.js';
@@ -115,8 +115,8 @@ export function createCheckpointCard(checkpoint) {
<span class="model-name">${checkpoint.model_name}</span> <span class="model-name">${checkpoint.model_name}</span>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<i class="fas fa-image" <i class="fas fa-folder-open"
title="Replace Preview Image"> title="Open Example Images Folder">
</i> </i>
</div> </div>
</div> </div>
@@ -272,6 +272,12 @@ export function createCheckpointCard(checkpoint) {
replaceCheckpointPreview(checkpoint.file_path); replaceCheckpointPreview(checkpoint.file_path);
}); });
// Open example images folder button click event
card.querySelector('.fa-folder-open')?.addEventListener('click', e => {
e.stopPropagation();
openExampleImagesFolder(checkpoint.sha256);
});
// Add autoplayOnHover handlers for video elements if needed // Add autoplayOnHover handlers for video elements if needed
const videoElement = card.querySelector('video'); const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) { if (videoElement && autoplayOnHover) {

View File

@@ -1,372 +0,0 @@
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() {
this.menu = document.getElementById('loraContextMenu');
this.currentCard = null;
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.init();
}
init() {
document.addEventListener('click', () => this.hideMenu());
document.addEventListener('contextmenu', (e) => {
const card = e.target.closest('.lora-card');
if (!card) {
this.hideMenu();
return;
}
e.preventDefault();
this.showMenu(e.clientX, e.clientY, card);
});
this.menu.addEventListener('click', (e) => {
const menuItem = e.target.closest('.context-menu-item');
if (!menuItem || !this.currentCard) return;
const action = menuItem.dataset.action;
if (!action) return;
switch(action) {
case 'detail':
// Trigger the main card click which shows the modal
this.currentCard.click();
break;
case 'civitai':
// Only trigger if the card is from civitai
if (this.currentCard.dataset.from_civitai === 'true') {
if (this.currentCard.dataset.meta === '{}') {
showToast('Please fetch metadata from CivitAI first', 'info');
} else {
this.currentCard.querySelector('.fa-globe')?.click();
}
} else {
showToast('No CivitAI information available', 'info');
}
break;
case 'copyname':
this.currentCard.querySelector('.fa-copy')?.click();
break;
case 'preview':
this.currentCard.querySelector('.fa-image')?.click();
break;
case 'delete':
this.currentCard.querySelector('.fa-trash')?.click();
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'refresh-metadata':
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
break;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
}
this.hideMenu();
});
// Initialize NSFW Level Selector events
this.initNSFWSelector();
}
initNSFWSelector() {
// Close button
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
closeBtn.addEventListener('click', () => {
this.nsfwSelector.style.display = 'none';
});
// Level buttons
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
levelButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const level = parseInt(btn.dataset.level);
const filePath = this.nsfwSelector.dataset.cardPath;
if (!filePath) return;
try {
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
// Update card data
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
let metaData = {};
try {
metaData = JSON.parse(card.dataset.meta || '{}');
} catch (err) {
console.error('Error parsing metadata:', err);
}
metaData.preview_nsfw_level = level;
card.dataset.meta = JSON.stringify(metaData);
card.dataset.nsfwLevel = level.toString();
// Apply blur effect immediately
this.updateCardBlurEffect(card, level);
}
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
this.nsfwSelector.style.display = 'none';
} catch (error) {
showToast(`Failed to set content rating: ${error.message}`, 'error');
}
});
});
// Close when clicking outside
document.addEventListener('click', (e) => {
if (this.nsfwSelector.style.display === 'block' &&
!this.nsfwSelector.contains(e.target) &&
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
this.nsfwSelector.style.display = 'none';
}
});
}
async saveModelMetadata(filePath, data) {
const response = await fetch('/api/loras/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return await response.json();
}
updateCardBlurEffect(card, level) {
// Get user settings for blur threshold
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
// Get card preview container
const previewContainer = card.querySelector('.card-preview');
if (!previewContainer) return;
// Get preview media element
const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video');
if (!previewMedia) return;
// Check if blur should be applied
if (level >= blurThreshold) {
// Add blur class to the preview container
previewContainer.classList.add('blurred');
// Get or create the NSFW overlay
let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay');
if (!nsfwOverlay) {
// Create new overlay
nsfwOverlay = document.createElement('div');
nsfwOverlay.className = 'nsfw-overlay';
// Create and configure the warning content
const warningContent = document.createElement('div');
warningContent.className = 'nsfw-warning';
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// Add warning text and show button
warningContent.innerHTML = `
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
`;
// Add click event to the show button
const showBtn = warningContent.querySelector('.show-content-btn');
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
previewContainer.classList.remove('blurred');
nsfwOverlay.style.display = 'none';
// Update toggle button icon if it exists
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
});
nsfwOverlay.appendChild(warningContent);
previewContainer.appendChild(nsfwOverlay);
} else {
// Update existing overlay
const warningText = nsfwOverlay.querySelector('p');
if (warningText) {
let nsfwText = "Mature Content";
if (level >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (level >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (level >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
warningText.textContent = nsfwText;
}
nsfwOverlay.style.display = 'flex';
}
// Get or create the toggle button in the header
const cardHeader = previewContainer.querySelector('.card-header');
if (cardHeader) {
let toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
if (!toggleBtn) {
toggleBtn = document.createElement('button');
toggleBtn.className = 'toggle-blur-btn';
toggleBtn.title = 'Toggle blur';
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
// Add click event to toggle button
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isBlurred = previewContainer.classList.toggle('blurred');
const icon = toggleBtn.querySelector('i');
// Update icon and overlay visibility
if (isBlurred) {
icon.className = 'fas fa-eye';
nsfwOverlay.style.display = 'flex';
} else {
icon.className = 'fas fa-eye-slash';
nsfwOverlay.style.display = 'none';
}
});
// Add to the beginning of header
cardHeader.insertBefore(toggleBtn, cardHeader.firstChild);
// Update base model label class
const baseModelLabel = cardHeader.querySelector('.base-model-label');
if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) {
baseModelLabel.classList.add('with-toggle');
}
} else {
// Update existing toggle button
toggleBtn.querySelector('i').className = 'fas fa-eye';
}
}
} else {
// Remove blur
previewContainer.classList.remove('blurred');
// Hide overlay if it exists
const overlay = previewContainer.querySelector('.nsfw-overlay');
if (overlay) overlay.style.display = 'none';
// Update or remove toggle button
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
// We'll leave the button but update the icon
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
}
}
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');
// Get current NSFW level
let currentLevel = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
currentLevel = metaData.preview_nsfw_level || 0;
// Update if we have no recorded level but have a dataset attribute
if (!currentLevel && card.dataset.nsfwLevel) {
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
}
} catch (err) {
console.error('Error parsing metadata:', err);
}
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
// Position the selector
if (x && y) {
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const selectorRect = selector.getBoundingClientRect();
// Center the selector if no coordinates provided
let finalX = (viewportWidth - selectorRect.width) / 2;
let finalY = (viewportHeight - selectorRect.height) / 2;
selector.style.left = `${finalX}px`;
selector.style.top = `${finalY}px`;
}
// Highlight current level button
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
if (parseInt(btn.dataset.level) === currentLevel) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// Store reference to current card
selector.dataset.cardPath = card.dataset.filepath;
// Show selector
selector.style.display = 'block';
}
showMenu(x, y, card) {
this.currentCard = card;
this.menu.style.display = 'block';
// 获取菜单尺寸
const menuRect = this.menu.getBoundingClientRect();
// 获取视口尺寸
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
// 计算最终位置 - 使用 clientX/Y不需要考虑滚动偏移
let finalX = x;
let finalY = y;
// 确保菜单不会超出右侧边界
if (x + menuRect.width > viewportWidth) {
finalX = x - menuRect.width;
}
// 确保菜单不会超出底部边界
if (y + menuRect.height > viewportHeight) {
finalY = y - menuRect.height;
}
// 直接设置位置,因为 position: fixed 是相对于视口定位的
this.menu.style.left = `${finalX}px`;
this.menu.style.top = `${finalY}px`;
}
hideMenu() {
this.menu.style.display = 'none';
this.currentCard = null;
}
}
// For backward compatibility, re-export the LoraContextMenu class
// export { LoraContextMenu } from './ContextMenu/LoraContextMenu.js';

View File

@@ -1,9 +1,12 @@
import { BaseContextMenu } from './BaseContextMenu.js'; import { BaseContextMenu } from './BaseContextMenu.js';
import { refreshSingleCheckpointMetadata, saveModelMetadata } from '../../api/checkpointApi.js'; import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview } from '../../api/checkpointApi.js';
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js'; import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js'; import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js'; import { showExcludeModal } from '../../utils/modalUtils.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
import { resetAndReload } from '../../api/checkpointApi.js';
export class CheckpointContextMenu extends BaseContextMenu { export class CheckpointContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -23,10 +26,12 @@ export class CheckpointContextMenu extends BaseContextMenu {
this.currentCard.click(); this.currentCard.click();
break; break;
case 'preview': case 'preview':
// Replace checkpoint preview // Open example images folder instead of replacing preview
if (this.currentCard.querySelector('.fa-image')) { openExampleImagesFolder(this.currentCard.dataset.sha256);
this.currentCard.querySelector('.fa-image').click(); break;
} case 'replace-preview':
// Add new action for replacing preview images
replaceCheckpointPreview(this.currentCard.dataset.filepath);
break; break;
case 'civitai': case 'civitai':
// Open civitai page // Open civitai page
@@ -54,6 +59,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Refresh metadata from CivitAI // Refresh metadata from CivitAI
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath); refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath);
break; break;
case 'relink-civitai':
// Handle re-link to Civitai
this.showRelinkCivitaiModal();
break;
case 'set-nsfw': case 'set-nsfw':
// Set NSFW level // Set NSFW level
this.showNSFWLevelSelector(null, null, this.currentCard); this.showNSFWLevelSelector(null, null, this.currentCard);
@@ -317,4 +326,87 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Show selector // Show selector
selector.style.display = 'block'; selector.style.display = 'block';
} }
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
// Set up confirm button handler
const confirmBtn = document.getElementById('confirmRelinkBtn');
const urlInput = document.getElementById('civitaiModelUrl');
const errorDiv = document.getElementById('civitaiModelUrlError');
// Remove previous event listener if exists
if (this._boundRelinkHandler) {
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
}
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const modelVersionId = this.extractModelVersionId(url);
if (!modelVersionId) {
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const response = await fetch('/api/checkpoints/relink-civitai', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_version_id: modelVersionId
})
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
// Reload the current view to show updated data
await resetAndReload();
} else {
throw new Error(data.error || 'Failed to re-link model');
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
};
// Set new event listener
confirmBtn.addEventListener('click', this._boundRelinkHandler);
// Clear previous input
urlInput.value = '';
errorDiv.textContent = '';
// Show modal
modalManager.showModal('relinkCivitaiModal');
}
extractModelVersionId(url) {
try {
const parsedUrl = new URL(url);
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return modelVersionId;
} catch (e) {
return null;
}
}
} }

View File

@@ -1,9 +1,11 @@
import { BaseContextMenu } from './BaseContextMenu.js'; import { BaseContextMenu } from './BaseContextMenu.js';
import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.js'; import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview, resetAndReload } from '../../api/loraApi.js';
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js'; import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js'; import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js'; import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
export class LoraContextMenu extends BaseContextMenu { export class LoraContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -35,13 +37,28 @@ export class LoraContextMenu extends BaseContextMenu {
} }
break; break;
case 'copyname': case 'copyname':
this.currentCard.querySelector('.fa-copy')?.click(); // Generate and copy LoRA syntax
this.copyLoraSyntax();
break;
case 'sendappend':
// Send LoRA to workflow (append mode)
this.sendLoraToWorkflow(false);
break;
case 'sendreplace':
// Send LoRA to workflow (replace mode)
this.sendLoraToWorkflow(true);
break; break;
case 'preview': case 'preview':
this.currentCard.querySelector('.fa-image')?.click(); // Open example images folder instead of showing preview image dialog
openExampleImagesFolder(this.currentCard.dataset.sha256);
break;
case 'replace-preview':
// Add a new action for replacing preview images
replacePreview(this.currentCard.dataset.filepath);
break; break;
case 'delete': case 'delete':
this.currentCard.querySelector('.fa-trash')?.click(); // Call showDeleteModal directly instead of clicking the trash button
showDeleteModal(this.currentCard.dataset.filepath);
break; break;
case 'move': case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath); moveManager.showMoveModal(this.currentCard.dataset.filepath);
@@ -49,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata': case 'refresh-metadata':
refreshSingleLoraMetadata(this.currentCard.dataset.filepath); refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
break; break;
case 'relink-civitai':
this.showRelinkCivitaiModal();
break;
case 'set-nsfw': case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard); this.showNSFWLevelSelector(null, null, this.currentCard);
break; break;
@@ -58,6 +78,110 @@ export class LoraContextMenu extends BaseContextMenu {
} }
} }
// New method to handle copy syntax functionality
copyLoraSyntax() {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
}
// New method to handle send to workflow functionality
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
// New method to handle re-link to Civitai
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
// Set up confirm button handler
const confirmBtn = document.getElementById('confirmRelinkBtn');
const urlInput = document.getElementById('civitaiModelUrl');
const errorDiv = document.getElementById('civitaiModelUrlError');
// Remove previous event listener if exists
if (this._boundRelinkHandler) {
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
}
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const modelVersionId = this.extractModelVersionId(url);
if (!modelVersionId) {
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const response = await fetch('/api/relink-civitai', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_version_id: modelVersionId
})
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('Model successfully re-linked to Civitai', 'success');
// Reload the current view to show updated data
await resetAndReload();
} else {
throw new Error(data.error || 'Failed to re-link model');
}
} catch (error) {
console.error('Error re-linking model:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
state.loadingManager.hide();
}
};
// Set new event listener
confirmBtn.addEventListener('click', this._boundRelinkHandler);
// Clear previous input
urlInput.value = '';
errorDiv.textContent = '';
// Show modal
modalManager.showModal('relinkCivitaiModal');
}
extractModelVersionId(url) {
try {
const parsedUrl = new URL(url);
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return modelVersionId;
} catch (e) {
return null;
}
}
// NSFW Selector methods from the original context menu // NSFW Selector methods from the original context menu
initNSFWSelector() { initNSFWSelector() {
// Close button // Close button

View File

@@ -1,5 +1,5 @@
import { BaseContextMenu } from './BaseContextMenu.js'; import { BaseContextMenu } from './BaseContextMenu.js';
import { showToast } from '../../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
@@ -39,8 +39,16 @@ export class RecipeContextMenu extends BaseContextMenu {
this.currentCard.click(); this.currentCard.click();
break; break;
case 'copy': case 'copy':
// Copy recipe to clipboard // Copy recipe syntax to clipboard
this.currentCard.querySelector('.fa-copy')?.click(); this.copyRecipeSyntax();
break;
case 'sendappend':
// Send recipe to workflow (append mode)
this.sendRecipeToWorkflow(false);
break;
case 'sendreplace':
// Send recipe to workflow (replace mode)
this.sendRecipeToWorkflow(true);
break; break;
case 'share': case 'share':
// Share recipe // Share recipe
@@ -61,6 +69,52 @@ export class RecipeContextMenu extends BaseContextMenu {
} }
} }
// New method to copy recipe syntax to clipboard
copyRecipeSyntax() {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('Cannot copy recipe: Missing recipe ID', 'error');
return;
}
fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to copy recipe syntax: ', err);
showToast('Failed to copy recipe syntax', 'error');
});
}
// New method to send recipe to workflow
sendRecipeToWorkflow(replaceMode) {
const recipeId = this.currentCard.dataset.id;
if (!recipeId) {
showToast('Cannot send recipe: Missing recipe ID', 'error');
return;
}
fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json())
.then(data => {
if (data.success && data.syntax) {
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
} else {
throw new Error(data.error || 'No syntax returned');
}
})
.catch(err => {
console.error('Failed to send recipe to workflow: ', err);
showToast('Failed to send recipe to workflow', 'error');
});
}
// View all LoRAs in the recipe // View all LoRAs in the recipe
viewRecipeLoRAs(recipeId) { viewRecipeLoRAs(recipeId) {
if (!recipeId) { if (!recipeId) {

View File

@@ -2,7 +2,6 @@
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { RecipeCard } from './RecipeCard.js'; import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
export class DuplicatesManager { export class DuplicatesManager {
constructor(recipeManager) { constructor(recipeManager) {
@@ -14,8 +13,6 @@ export class DuplicatesManager {
async findDuplicates() { async findDuplicates() {
try { try {
document.body.classList.add('loading');
const response = await fetch('/api/recipes/find-duplicates'); const response = await fetch('/api/recipes/find-duplicates');
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to find duplicates'); throw new Error('Failed to find duplicates');
@@ -39,8 +36,6 @@ export class DuplicatesManager {
console.error('Error finding duplicates:', error); console.error('Error finding duplicates:', error);
showToast('Failed to find duplicates: ' + error.message, 'error'); showToast('Failed to find duplicates: ' + error.message, 'error');
return false; return false;
} finally {
document.body.classList.remove('loading');
} }
} }
@@ -100,14 +95,7 @@ export class DuplicatesManager {
} }
// Re-enable virtual scrolling // Re-enable virtual scrolling
if (state.virtualScroller) { state.virtualScroller.enable();
state.virtualScroller.enable();
} else {
// If virtual scroller doesn't exist, reinitialize it
setTimeout(() => {
initializeInfiniteScroll('recipes');
}, 100);
}
} }
renderDuplicateGroups() { renderDuplicateGroups() {
@@ -234,7 +222,7 @@ export class DuplicatesManager {
} }
updateSelectedCount() { updateSelectedCount() {
const selectedCountEl = document.getElementById('selectedCount'); const selectedCountEl = document.getElementById('duplicatesSelectedCount');
if (selectedCountEl) { if (selectedCountEl) {
selectedCountEl.textContent = this.selectedForDeletion.size; selectedCountEl.textContent = this.selectedForDeletion.size;
} }
@@ -359,8 +347,6 @@ export class DuplicatesManager {
// Add new method to execute deletion after confirmation // Add new method to execute deletion after confirmation
async confirmDeleteDuplicates() { async confirmDeleteDuplicates() {
try { try {
document.body.classList.add('loading');
// Close the modal // Close the modal
modalManager.closeModal('duplicateDeleteModal'); modalManager.closeModal('duplicateDeleteModal');
@@ -395,8 +381,6 @@ export class DuplicatesManager {
} catch (error) { } catch (error) {
console.error('Error deleting recipes:', error); console.error('Error deleting recipes:', error);
showToast('Failed to delete recipes: ' + error.message, 'error'); showToast('Failed to delete recipes: ' + error.message, 'error');
} finally {
document.body.classList.remove('loading');
} }
} }
} }

View File

@@ -75,7 +75,9 @@ export class HeaderManager {
const supportToggle = document.getElementById('supportToggleBtn'); const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) { if (supportToggle) {
supportToggle.addEventListener('click', () => { supportToggle.addEventListener('click', () => {
// Handle support panel logic if (window.modalManager) {
window.modalManager.toggleModal('supportModal');
}
}); });
} }
@@ -106,5 +108,15 @@ export class HeaderManager {
} }
}); });
} }
// Handle help toggle
// const helpToggle = document.querySelector('.help-toggle');
// if (helpToggle) {
// helpToggle.addEventListener('click', () => {
// if (window.modalManager) {
// window.modalManager.toggleModal('helpModal');
// }
// });
// }
} }
} }

View File

@@ -1,5 +1,5 @@
import { showToast, openCivitai, copyToClipboard } from '../utils/uiHelpers.js'; import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../utils/uiHelpers.js';
import { state } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showLoraModal } from './loraModal/index.js'; import { showLoraModal } from './loraModal/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS } from '../utils/constants.js'; import { NSFW_LEVELS } from '../utils/constants.js';
@@ -51,15 +51,15 @@ function handleLoraCardEvent(event) {
return; return;
} }
if (event.target.closest('.fa-copy')) { if (event.target.closest('.fa-paper-plane')) {
event.stopPropagation(); event.stopPropagation();
copyLoraCode(card); sendLoraToComfyUI(card, event.shiftKey);
return; return;
} }
if (event.target.closest('.fa-trash')) { if (event.target.closest('.fa-copy')) {
event.stopPropagation(); event.stopPropagation();
showDeleteModal(card.dataset.filepath); copyLoraSyntax(card);
return; return;
} }
@@ -69,10 +69,20 @@ function handleLoraCardEvent(event) {
return; return;
} }
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
openExampleImagesFolder(card.dataset.sha256);
return;
}
// If no specific element was clicked, handle the card click (show modal or toggle selection) // If no specific element was clicked, handle the card click (show modal or toggle selection)
const pageState = getCurrentPageState();
if (state.bulkMode) { if (state.bulkMode) {
// Toggle selection using the bulk manager // Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card); bulkManager.toggleCardSelection(card);
} else if (pageState && pageState.duplicatesMode) {
// In duplicates mode, don't open modal when clicking cards
return;
} else { } else {
// Normal behavior - show modal // Normal behavior - show modal
const loraMeta = { const loraMeta = {
@@ -173,12 +183,22 @@ async function toggleFavorite(card) {
} }
} }
async function copyLoraCode(card) { // Function to send LoRA to ComfyUI workflow
async function sendLoraToComfyUI(card, replaceMode) {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1; const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`; const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
await copyToClipboard(loraSyntax, 'LoRA syntax copied'); sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
}
// Add function to copy lora syntax
function copyLoraSyntax(card) {
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
const strength = usageTips.strength || 1;
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
} }
export function createLoraCard(lora) { export function createLoraCard(lora) {
@@ -269,12 +289,12 @@ export function createLoraCard(lora) {
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}" title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}> ${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
</i> </i>
<i class="fas fa-paper-plane"
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
</i>
<i class="fas fa-copy" <i class="fas fa-copy"
title="Copy LoRA Syntax"> title="Copy LoRA Syntax">
</i> </i>
<i class="fas fa-trash"
title="Delete Model">
</i>
</div> </div>
</div> </div>
${shouldBlur ? ` ${shouldBlur ? `
@@ -290,8 +310,8 @@ export function createLoraCard(lora) {
<span class="model-name">${lora.model_name}</span> <span class="model-name">${lora.model_name}</span>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<i class="fas fa-image" <i class="fas fa-folder-open"
title="Replace Preview Image"> title="Open Example Images Folder">
</i> </i>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,599 @@
// Model Duplicates Manager Component for LoRAs and Checkpoints
import { showToast } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js';
export class ModelDuplicatesManager {
constructor(pageManager, modelType = 'loras') {
this.pageManager = pageManager;
this.duplicateGroups = [];
this.inDuplicateMode = false;
this.selectedForDeletion = new Set();
this.modelType = modelType; // Use the provided modelType or default to 'loras'
// Bind methods
this.renderModelCard = this.renderModelCard.bind(this);
this.renderTooltip = this.renderTooltip.bind(this);
this.checkDuplicatesCount = this.checkDuplicatesCount.bind(this);
// Keep track of which controls need to be re-enabled
this.disabledControls = [];
// Check for duplicates on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this.checkDuplicatesCount);
} else {
this.checkDuplicatesCount();
}
}
// Method to check for duplicates count using existing endpoint
async checkDuplicatesCount() {
try {
const endpoint = `/api/${this.modelType}/find-duplicates`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
const duplicatesCount = (data.duplicates || []).length;
this.updateDuplicatesBadge(duplicatesCount);
} else {
this.updateDuplicatesBadge(0);
}
} catch (error) {
console.error('Error checking duplicates count:', error);
this.updateDuplicatesBadge(0);
}
}
// Method to update the badge
updateDuplicatesBadge(count) {
const badge = document.getElementById('duplicatesBadge');
if (!badge) return;
if (count > 0) {
badge.textContent = count;
badge.classList.add('pulse');
} else {
badge.textContent = '';
badge.classList.remove('pulse');
}
}
// Toggle method to enter/exit duplicates mode
toggleDuplicateMode() {
if (this.inDuplicateMode) {
this.exitDuplicateMode();
} else {
this.findDuplicates();
}
}
async findDuplicates() {
try {
// Determine API endpoint based on model type
const endpoint = `/api/${this.modelType}/find-duplicates`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to find duplicates: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error finding duplicates');
}
this.duplicateGroups = data.duplicates || [];
// Update the badge with the current count
this.updateDuplicatesBadge(this.duplicateGroups.length);
if (this.duplicateGroups.length === 0) {
showToast('No duplicate models found', 'info');
return false;
}
this.enterDuplicateMode();
return true;
} catch (error) {
console.error('Error finding duplicates:', error);
showToast('Failed to find duplicates: ' + error.message, 'error');
return false;
}
}
enterDuplicateMode() {
this.inDuplicateMode = true;
this.selectedForDeletion.clear();
// Update state
const pageState = getCurrentPageState();
pageState.duplicatesMode = true;
// Show duplicates banner
const banner = document.getElementById('duplicatesBanner');
const countSpan = document.getElementById('duplicatesCount');
if (banner && countSpan) {
countSpan.textContent = `Found ${this.duplicateGroups.length} duplicate group${this.duplicateGroups.length !== 1 ? 's' : ''}`;
banner.style.display = 'block';
// Setup help tooltip behavior
this.setupHelpTooltip();
}
// Disable virtual scrolling if active
if (state.virtualScroller) {
state.virtualScroller.disable();
}
// Add duplicate-mode class to the body
document.body.classList.add('duplicate-mode');
// Render duplicate groups
this.renderDuplicateGroups();
// Update selected count
this.updateSelectedCount();
// Update Duplicates button to show active state
const duplicatesBtn = document.getElementById('findDuplicatesBtn');
if (duplicatesBtn) {
duplicatesBtn.classList.add('active');
duplicatesBtn.title = 'Exit Duplicates Mode';
// Change icon and text to indicate it's now an exit button
duplicatesBtn.innerHTML = '<i class="fas fa-times"></i> Exit Duplicates';
}
// Disable all control buttons except the duplicates button
this.disableControlButtons();
}
exitDuplicateMode() {
this.inDuplicateMode = false;
this.selectedForDeletion.clear();
// Update state
const pageState = getCurrentPageState();
pageState.duplicatesMode = false;
// Hide duplicates banner
const banner = document.getElementById('duplicatesBanner');
if (banner) {
banner.style.display = 'none';
}
// Remove duplicate-mode class from the body
document.body.classList.remove('duplicate-mode');
// Clear the model grid first
const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid');
if (modelGrid) {
modelGrid.innerHTML = '';
}
// Re-enable virtual scrolling
state.virtualScroller.enable();
// Restore Duplicates button to its original state
const duplicatesBtn = document.getElementById('findDuplicatesBtn');
if (duplicatesBtn) {
duplicatesBtn.classList.remove('active');
duplicatesBtn.title = 'Find duplicate models';
duplicatesBtn.innerHTML = '<i class="fas fa-clone"></i> Duplicates <span id="duplicatesBadge" class="badge"></span>';
// Restore badge
const newBadge = duplicatesBtn.querySelector('#duplicatesBadge');
const oldBadge = document.getElementById('duplicatesBadge');
if (oldBadge && oldBadge.textContent) {
newBadge.textContent = oldBadge.textContent;
newBadge.classList.add('pulse');
}
}
// Re-enable all control buttons
this.enableControlButtons();
this.checkDuplicatesCount();
}
// Disable all control buttons except the duplicates button
disableControlButtons() {
this.disabledControls = [];
// Select all control buttons except the duplicates button
const controlButtons = document.querySelectorAll('.control-group button:not(#findDuplicatesBtn), .dropdown-group, .toggle-folders-btn, #favoriteFilterBtn');
controlButtons.forEach(button => {
// Only disable enabled buttons (don't disable already disabled buttons)
if (!button.disabled && !button.classList.contains('disabled')) {
this.disabledControls.push(button);
button.disabled = true;
button.classList.add('disabled-during-duplicates');
}
});
}
// Re-enable all previously disabled control buttons
enableControlButtons() {
this.disabledControls.forEach(button => {
button.disabled = false;
button.classList.remove('disabled-during-duplicates');
});
this.disabledControls = [];
}
renderDuplicateGroups() {
const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid');
if (!modelGrid) return;
// Clear existing content
modelGrid.innerHTML = '';
// Render each duplicate group
this.duplicateGroups.forEach((group, groupIndex) => {
const groupDiv = document.createElement('div');
groupDiv.className = 'duplicate-group';
groupDiv.dataset.hash = group.hash;
// Create group header
const header = document.createElement('div');
header.className = 'duplicate-group-header';
header.innerHTML = `
<span>Duplicate Group #${groupIndex + 1} (${group.models.length} models with same hash: ${group.hash})</span>
<span>
<button class="btn-select-all" onclick="modelDuplicatesManager.toggleSelectAllInGroup('${group.hash}')">
Select All
</button>
</span>
`;
groupDiv.appendChild(header);
// Create cards container
const cardsDiv = document.createElement('div');
cardsDiv.className = 'card-group-container';
// Add scrollable class if there are many models in the group
if (group.models.length > 6) {
cardsDiv.classList.add('scrollable');
// Add expand/collapse toggle button
const toggleBtn = document.createElement('button');
toggleBtn.className = 'group-toggle-btn';
toggleBtn.innerHTML = '<i class="fas fa-chevron-down"></i>';
toggleBtn.title = "Expand/Collapse";
toggleBtn.onclick = function() {
cardsDiv.classList.toggle('scrollable');
this.innerHTML = cardsDiv.classList.contains('scrollable') ?
'<i class="fas fa-chevron-down"></i>' :
'<i class="fas fa-chevron-up"></i>';
};
groupDiv.appendChild(toggleBtn);
}
// Add all model cards in this group
group.models.forEach(model => {
const card = this.renderModelCard(model, group.hash);
cardsDiv.appendChild(card);
});
groupDiv.appendChild(cardsDiv);
modelGrid.appendChild(groupDiv);
});
}
renderModelCard(model, groupHash) {
// Create basic card structure
const card = document.createElement('div');
card.className = 'lora-card duplicate';
card.dataset.hash = model.sha256;
card.dataset.filePath = model.file_path;
// Create card content using structure similar to createLoraCard in LoraCard.js
const previewContainer = document.createElement('div');
previewContainer.className = 'card-preview';
// Determine if preview is a video
const isVideo = model.preview_url && model.preview_url.endsWith('.mp4');
let preview;
if (isVideo) {
// Create video element for MP4 previews
preview = document.createElement('video');
preview.loading = 'lazy';
preview.controls = true;
preview.muted = true;
preview.loop = true;
const source = document.createElement('source');
source.src = model.preview_url;
source.type = 'video/mp4';
preview.appendChild(source);
} else {
// Create image element for standard previews
preview = document.createElement('img');
preview.loading = 'lazy';
preview.alt = model.model_name;
if (model.preview_url) {
preview.src = model.preview_url;
} else {
// Use placeholder
preview.src = '/loras_static/images/no-preview.png';
}
}
// Add NSFW blur if needed
if (model.preview_nsfw_level > 0) {
preview.classList.add('nsfw');
}
previewContainer.appendChild(preview);
// Move tooltip listeners to the preview container for consistent behavior
// regardless of whether the preview is an image or video
previewContainer.addEventListener('mouseover', () => this.renderTooltip(card, model));
previewContainer.addEventListener('mouseout', () => {
const tooltip = document.querySelector('.model-tooltip');
if (tooltip) tooltip.remove();
});
// Add card footer with just model name
const footer = document.createElement('div');
footer.className = 'card-footer';
const modelInfo = document.createElement('div');
modelInfo.className = 'model-info';
const modelName = document.createElement('span');
modelName.className = 'model-name';
modelName.textContent = model.model_name;
modelInfo.appendChild(modelName);
footer.appendChild(modelInfo);
previewContainer.appendChild(footer);
card.appendChild(previewContainer);
// Add selection checkbox
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'selector-checkbox';
checkbox.dataset.filePath = model.file_path;
checkbox.dataset.groupHash = groupHash;
// Check if already selected
if (this.selectedForDeletion.has(model.file_path)) {
checkbox.checked = true;
card.classList.add('duplicate-selected');
}
// Add change event to checkbox
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
this.toggleCardSelection(model.file_path, card, checkbox);
});
// Make the entire card clickable for selection
card.addEventListener('click', (e) => {
// Don't toggle if clicking on the checkbox directly or card actions
if (e.target === checkbox || e.target.closest('.card-actions')) {
return;
}
// Toggle checkbox state
checkbox.checked = !checkbox.checked;
this.toggleCardSelection(model.file_path, card, checkbox);
});
card.appendChild(checkbox);
return card;
}
renderTooltip(card, model) {
// Remove any existing tooltips
const existingTooltip = document.querySelector('.model-tooltip');
if (existingTooltip) existingTooltip.remove();
// Create tooltip
const tooltip = document.createElement('div');
tooltip.className = 'model-tooltip';
// Add model information to tooltip
tooltip.innerHTML = `
<div class="tooltip-header">${model.model_name}</div>
<div class="tooltip-info">
<div><strong>Version:</strong> ${model.civitai?.name || 'Unknown'}</div>
<div><strong>Filename:</strong> ${model.file_name}</div>
<div><strong>Path:</strong> ${model.file_path}</div>
<div><strong>Base Model:</strong> ${model.base_model || 'Unknown'}</div>
<div><strong>Modified:</strong> ${formatDate(model.modified)}</div>
</div>
`;
// Position tooltip relative to card
const cardRect = card.getBoundingClientRect();
tooltip.style.top = `${cardRect.top + window.scrollY - 10}px`;
tooltip.style.left = `${cardRect.left + window.scrollX + cardRect.width + 10}px`;
// Add tooltip to document
document.body.appendChild(tooltip);
// Check if tooltip is outside viewport and adjust if needed
const tooltipRect = tooltip.getBoundingClientRect();
if (tooltipRect.right > window.innerWidth) {
tooltip.style.left = `${cardRect.left + window.scrollX - tooltipRect.width - 10}px`;
}
}
// Helper method to toggle card selection state
toggleCardSelection(filePath, card, checkbox) {
if (checkbox.checked) {
this.selectedForDeletion.add(filePath);
card.classList.add('duplicate-selected');
} else {
this.selectedForDeletion.delete(filePath);
card.classList.remove('duplicate-selected');
}
this.updateSelectedCount();
}
updateSelectedCount() {
const selectedCountEl = document.getElementById('duplicatesSelectedCount');
if (selectedCountEl) {
selectedCountEl.textContent = this.selectedForDeletion.size;
}
// Update delete button state
const deleteBtn = document.querySelector('.btn-delete-selected');
if (deleteBtn) {
deleteBtn.disabled = this.selectedForDeletion.size === 0;
deleteBtn.classList.toggle('disabled', this.selectedForDeletion.size === 0);
}
}
toggleSelectAllInGroup(hash) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`);
const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked);
// If all are selected, deselect all; otherwise select all
checkboxes.forEach(checkbox => {
checkbox.checked = !allSelected;
const filePath = checkbox.dataset.filePath;
const card = checkbox.closest('.lora-card');
if (!allSelected) {
this.selectedForDeletion.add(filePath);
card.classList.add('duplicate-selected');
} else {
this.selectedForDeletion.delete(filePath);
card.classList.remove('duplicate-selected');
}
});
// Update the button text
const button = document.querySelector(`.duplicate-group[data-hash="${hash}"] .btn-select-all`);
if (button) {
button.textContent = !allSelected ? "Deselect All" : "Select All";
}
this.updateSelectedCount();
}
async deleteSelectedDuplicates() {
if (this.selectedForDeletion.size === 0) {
showToast('No models selected for deletion', 'info');
return;
}
try {
// Show the delete confirmation modal instead of a simple confirm
const modelDuplicateDeleteCount = document.getElementById('modelDuplicateDeleteCount');
if (modelDuplicateDeleteCount) {
modelDuplicateDeleteCount.textContent = this.selectedForDeletion.size;
}
// Use the modal manager to show the confirmation modal
modalManager.showModal('modelDuplicateDeleteModal');
} catch (error) {
console.error('Error preparing delete:', error);
showToast('Error: ' + error.message, 'error');
}
}
// Execute deletion after confirmation
async confirmDeleteDuplicates() {
try {
// Close the modal
modalManager.closeModal('modelDuplicateDeleteModal');
// Prepare file paths for deletion
const filePaths = Array.from(this.selectedForDeletion);
// Call API to bulk delete
const response = await fetch(`/api/${this.modelType}/bulk-delete`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ file_paths: filePaths })
});
if (!response.ok) {
throw new Error('Failed to delete selected models');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error deleting models');
}
showToast(`Successfully deleted ${data.total_deleted} models`, 'success');
// Exit duplicate mode if deletions were successful
if (data.total_deleted > 0) {
// Check duplicates count after deletion
this.checkDuplicatesCount();
this.exitDuplicateMode();
}
} catch (error) {
console.error('Error deleting models:', error);
showToast('Failed to delete models: ' + error.message, 'error');
}
}
// Public method to update the badge after refresh
updateDuplicatesBadgeAfterRefresh() {
// Use this method after refresh operations
this.checkDuplicatesCount();
}
// Add this new method for tooltip behavior
setupHelpTooltip() {
const helpIcon = document.getElementById('duplicatesHelp');
const helpTooltip = document.getElementById('duplicatesHelpTooltip');
if (!helpIcon || !helpTooltip) return;
helpIcon.addEventListener('mouseenter', (e) => {
// Get the container's positioning context
const bannerContent = helpIcon.closest('.banner-content');
// Get positions relative to the viewport
const iconRect = helpIcon.getBoundingClientRect();
const bannerRect = bannerContent.getBoundingClientRect();
// Set initial position relative to the banner content
helpTooltip.style.display = 'block';
helpTooltip.style.top = `${iconRect.bottom - bannerRect.top + 10}px`;
helpTooltip.style.left = `${iconRect.left - bannerRect.left - 10}px`;
// Check if the tooltip is going off-screen to the right
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (tooltipRect.right > viewportWidth - 20) {
// Reposition relative to container if too close to right edge
helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`;
}
});
// Rest of the event listeners remain unchanged
helpIcon.addEventListener('mouseleave', () => {
helpTooltip.style.display = 'none';
});
document.addEventListener('click', (e) => {
if (!helpIcon.contains(e.target)) {
helpTooltip.style.display = 'none';
}
});
}
}

View File

@@ -1,5 +1,5 @@
// Recipe Card Component // Recipe Card Component
import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
@@ -52,7 +52,7 @@ class RecipeCard {
</div> </div>
<div class="card-actions"> <div class="card-actions">
<i class="fas fa-share-alt" title="Share Recipe"></i> <i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-copy" title="Copy Recipe Syntax"></i> <i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
<i class="fas fa-trash" title="Delete Recipe"></i> <i class="fas fa-trash" title="Delete Recipe"></i>
</div> </div>
</div> </div>
@@ -94,10 +94,10 @@ class RecipeCard {
this.shareRecipe(); this.shareRecipe();
}); });
// Copy button click event - prevent propagation to card // Send button click event - prevent propagation to card
card.querySelector('.fa-copy')?.addEventListener('click', (e) => { card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
this.copyRecipeSyntax(); this.sendRecipeToWorkflow(e.shiftKey);
}); });
// Delete button click event - prevent propagation to card // Delete button click event - prevent propagation to card
@@ -108,33 +108,32 @@ class RecipeCard {
} }
} }
copyRecipeSyntax() { // Replace copyRecipeSyntax with sendRecipeToWorkflow
sendRecipeToWorkflow(replaceMode = false) {
try { try {
// Get recipe ID // Get recipe ID
const recipeId = this.recipe.id; const recipeId = this.recipe.id;
if (!recipeId) { if (!recipeId) {
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error'); showToast('Cannot send recipe: Missing recipe ID', 'error');
return; return;
} }
// Fallback if button not found
fetch(`/api/recipe/${recipeId}/syntax`) fetch(`/api/recipe/${recipeId}/syntax`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success && data.syntax) { if (data.success && data.syntax) {
return copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard'); return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
} else { } else {
throw new Error(data.error || 'No syntax returned'); throw new Error(data.error || 'No syntax returned');
} }
}) })
.catch(err => { .catch(err => {
console.error('Failed to copy: ', err); console.error('Failed to send recipe to workflow: ', err);
showToast('Failed to copy recipe syntax', 'error'); showToast('Failed to send recipe to workflow', 'error');
}); });
} catch (error) { } catch (error) {
console.error('Error copying recipe syntax:', error); console.error('Error sending recipe to workflow:', error);
showToast('Error copying recipe syntax', 'error'); showToast('Error sending recipe to workflow', 'error');
} }
} }

View File

@@ -5,7 +5,7 @@
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js'; import { BASE_MODELS } from '../../utils/constants.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js'; import { updateCheckpointCard } from '../../utils/cardUpdater.js';
import { saveModelMetadata } from '../../api/checkpointApi.js'; import { saveModelMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
/** /**
* Set up model name editing functionality * Set up model name editing functionality
@@ -17,6 +17,9 @@ export function setupModelNameEditing(filePath) {
if (!modelNameContent || !editBtn) return; if (!modelNameContent || !editBtn) return;
// Store the file path in a data attribute for later use
modelNameContent.dataset.filePath = filePath;
// Show edit button on hover // Show edit button on hover
const modelNameHeader = document.querySelector('.model-name-header'); const modelNameHeader = document.querySelector('.model-name-header');
modelNameHeader.addEventListener('mouseenter', () => { modelNameHeader.addEventListener('mouseenter', () => {
@@ -24,14 +27,17 @@ export function setupModelNameEditing(filePath) {
}); });
modelNameHeader.addEventListener('mouseleave', () => { modelNameHeader.addEventListener('mouseleave', () => {
if (!modelNameContent.getAttribute('data-editing')) { if (!modelNameHeader.classList.contains('editing')) {
editBtn.classList.remove('visible'); editBtn.classList.remove('visible');
} }
}); });
// Handle edit button click // Handle edit button click
editBtn.addEventListener('click', () => { editBtn.addEventListener('click', () => {
modelNameContent.setAttribute('data-editing', 'true'); modelNameHeader.classList.add('editing');
modelNameContent.setAttribute('contenteditable', 'true');
// Store original value for comparison later
modelNameContent.dataset.originalValue = modelNameContent.textContent.trim();
modelNameContent.focus(); modelNameContent.focus();
// Place cursor at the end // Place cursor at the end
@@ -47,33 +53,25 @@ export function setupModelNameEditing(filePath) {
editBtn.classList.add('visible'); editBtn.classList.add('visible');
}); });
// Handle focus out // Handle keyboard events in edit mode
modelNameContent.addEventListener('blur', function() {
this.removeAttribute('data-editing');
editBtn.classList.remove('visible');
if (this.textContent.trim() === '') {
// Restore original model name if empty
// Use the passed filePath to find the card
const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`);
if (checkpointCard) {
this.textContent = checkpointCard.dataset.model_name;
}
}
});
// Handle enter key
modelNameContent.addEventListener('keydown', function(e) { modelNameContent.addEventListener('keydown', function(e) {
if (!this.getAttribute('contenteditable')) return;
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
// Use the passed filePath this.blur(); // Trigger save on Enter
saveModelName(filePath); } else if (e.key === 'Escape') {
this.blur(); e.preventDefault();
// Restore original value
this.textContent = this.dataset.originalValue;
exitEditMode();
} }
}); });
// Limit model name length // Limit model name length
modelNameContent.addEventListener('input', function() { modelNameContent.addEventListener('input', function() {
if (!this.getAttribute('contenteditable')) return;
if (this.textContent.length > 100) { if (this.textContent.length > 100) {
this.textContent = this.textContent.substring(0, 100); this.textContent = this.textContent.substring(0, 100);
// Place cursor at the end // Place cursor at the end
@@ -87,44 +85,59 @@ export function setupModelNameEditing(filePath) {
showToast('Model name is limited to 100 characters', 'warning'); showToast('Model name is limited to 100 characters', 'warning');
} }
}); });
}
/** // Handle focus out - save changes
* Save model name modelNameContent.addEventListener('blur', async function() {
* @param {string} filePath - File path if (!this.getAttribute('contenteditable')) return;
*/
async function saveModelName(filePath) {
const modelNameElement = document.querySelector('.model-name-content');
const newModelName = modelNameElement.textContent.trim();
// Validate model name const newModelName = this.textContent.trim();
if (!newModelName) { const originalValue = this.dataset.originalValue;
showToast('Model name cannot be empty', 'error');
return;
}
// Check if model name is too long // Basic validation
if (newModelName.length > 100) { if (!newModelName) {
showToast('Model name is too long (maximum 100 characters)', 'error'); // Restore original value if empty
// Truncate the displayed text this.textContent = originalValue;
modelNameElement.textContent = newModelName.substring(0, 100); showToast('Model name cannot be empty', 'error');
return; exitEditMode();
} return;
}
try { if (newModelName === originalValue) {
await saveModelMetadata(filePath, { model_name: newModelName }); // No changes, just exit edit mode
exitEditMode();
return;
}
// Update the card with the new model name try {
updateCheckpointCard(filePath, { name: newModelName }); // Get the file path from the dataset
const filePath = this.dataset.filePath;
showToast('Model name updated successfully', 'success'); await saveModelMetadata(filePath, { model_name: newModelName });
// No need to reload the entire page // Update the corresponding checkpoint card's dataset and display
// setTimeout(() => { updateCheckpointCard(filePath, { model_name: newModelName });
// window.location.reload();
// }, 1500); // BUGFIX: Directly update the card's dataset.name attribute to ensure
} catch (error) { // it's correctly read when reopening the modal
showToast('Failed to update model name', 'error'); const checkpointCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (checkpointCard) {
checkpointCard.dataset.name = newModelName;
}
showToast('Model name updated successfully', 'success');
} catch (error) {
console.error('Error updating model name:', error);
this.textContent = originalValue; // Restore original model name
showToast('Failed to update model name', 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
modelNameContent.removeAttribute('contenteditable');
modelNameHeader.classList.remove('editing');
editBtn.classList.remove('visible');
} }
} }
@@ -138,6 +151,9 @@ export function setupBaseModelEditing(filePath) {
if (!baseModelContent || !editBtn) return; if (!baseModelContent || !editBtn) return;
// Store the file path in a data attribute for later use
baseModelContent.dataset.filePath = filePath;
// Show edit button on hover // Show edit button on hover
const baseModelDisplay = document.querySelector('.base-model-display'); const baseModelDisplay = document.querySelector('.base-model-display');
baseModelDisplay.addEventListener('mouseenter', () => { baseModelDisplay.addEventListener('mouseenter', () => {
@@ -303,6 +319,9 @@ export function setupFileNameEditing(filePath) {
if (!fileNameContent || !editBtn) return; if (!fileNameContent || !editBtn) return;
// Store the original file path
fileNameContent.dataset.filePath = filePath;
// Show edit button on hover // Show edit button on hover
const fileNameWrapper = document.querySelector('.file-name-wrapper'); const fileNameWrapper = document.querySelector('.file-name-wrapper');
fileNameWrapper.addEventListener('mouseenter', () => { fileNameWrapper.addEventListener('mouseenter', () => {
@@ -400,19 +419,8 @@ export function setupFileNameEditing(filePath) {
try { try {
// Use the passed filePath (which includes the original filename) // Use the passed filePath (which includes the original filename)
// Call API to rename the file // Call API to rename the file using the new function from checkpointApi.js
const response = await fetch('/api/rename_checkpoint', { const result = await renameCheckpointFile(filePath, newFileName);
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath, // Use the full original path
new_file_name: newFileName
})
});
const result = await response.json();
if (result.success) { if (result.success) {
showToast('File name updated successfully', 'success'); showToast('File name updated successfully', 'success');

View File

@@ -2,44 +2,22 @@
* ShowcaseView.js * ShowcaseView.js
* Handles showcase content (images, videos) display for checkpoint modal * Handles showcase content (images, videos) display for checkpoint modal
*/ */
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop
} from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
/**
* Get the local URL for an example image if available
* @param {Object} img - Image object
* @param {number} index - Image index
* @param {string} modelHash - Model hash
* @returns {string|null} - Local URL or null if not available
*/
function getLocalExampleImageUrl(img, index, modelHash) {
if (!modelHash) return null;
// Get remote extension
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
// If it's a video (mp4), use that extension
if (remoteExt === 'mp4') {
return `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
}
// For images, check if optimization is enabled (defaults to true)
const optimizeImages = state.settings.optimizeExampleImages !== false;
// Use .webp for images if optimization enabled, otherwise use original extension
const extension = optimizeImages ? 'webp' : remoteExt;
return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`;
}
/** /**
* Render showcase content * Render showcase content
* @param {Array} images - Array of images/videos to show * @param {Array} images - Array of images/videos to show
* @param {string} modelHash - Model hash for identifying local files * @param {string} modelHash - Model hash for identifying local files
* @param {Array} exampleFiles - Local example files already fetched
* @returns {string} HTML content * @returns {string} HTML content
*/ */
export function renderShowcaseContent(images, modelHash) { export function renderShowcaseContent(images, exampleFiles = []) {
if (!images?.length) return '<div class="no-examples">No example images available</div>'; if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting // Filter images based on SFW setting
@@ -82,9 +60,85 @@ export function renderShowcaseContent(images, modelHash) {
${hiddenNotification} ${hiddenNotification}
<div class="carousel-container"> <div class="carousel-container">
${filteredImages.map((img, index) => { ${filteredImages.map((img, index) => {
// Try to get local URL for the example image // Find matching file in our list of actual files
const localUrl = getLocalExampleImageUrl(img, index, modelHash); let localFile = null;
return generateMediaWrapper(img, localUrl); if (exampleFiles.length > 0) {
// Try to find the corresponding file by index first
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
// If not found by index, just use the same position in the array if available
if (!localFile && index < exampleFiles.length) {
localFile = exampleFiles[index];
}
}
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Calculate appropriate aspect ratio
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
Math.min(maxHeightPercent, aspectRatio)
);
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
// Determine NSFW warning text based on level
let nsfwText = "Mature Content";
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = "XXX-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = "X-rated Content";
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = "R-rated Content";
}
// 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;
// Create metadata panel content
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
// Check if this is a video or image
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}).join('')} }).join('')}
</div> </div>
</div> </div>
@@ -96,11 +150,8 @@ export function renderShowcaseContent(images, modelHash) {
* @param {Object} media - Media object with image or video data * @param {Object} media - Media object with image or video data
* @returns {string} HTML content * @returns {string} HTML content
*/ */
function generateMediaWrapper(media, localUrl = null) { function generateMediaWrapper(media, urls) {
// Calculate appropriate aspect ratio: // Calculate appropriate aspect ratio
// 1. Keep original aspect ratio
// 2. Limit maximum height to 60% of viewport height
// 3. Ensure minimum height is 40% of container width
const aspectRatio = (media.height / media.width) * 100; const aspectRatio = (media.height / media.width) * 100;
const containerWidth = 800; // modal content maximum width const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40; const minHeightPercent = 40;
@@ -149,10 +200,10 @@ function generateMediaWrapper(media, localUrl = null) {
// Check if this is a video or image // Check if this is a video or image
if (media.type === 'video') { if (media.type === 'video') {
return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl); return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
} }
return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl); return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
} }
/** /**
@@ -225,7 +276,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si
/** /**
* Generate video wrapper HTML * Generate video wrapper HTML
*/ */
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) { function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return ` return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%"> <div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? ` ${shouldBlur ? `
@@ -236,9 +287,9 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
<video controls autoplay muted loop crossorigin="anonymous" <video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}" data-local-src="${localUrl || ''}"
data-remote-src="${media.url}" data-remote-src="${remoteUrl}"
class="lazy ${shouldBlur ? 'blurred' : ''}"> class="lazy ${shouldBlur ? 'blurred' : ''}">
<source data-local-src="${localUrl || ''}" data-remote-src="${media.url}" type="video/mp4"> <source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
Your browser does not support video playback Your browser does not support video playback
</video> </video>
${shouldBlur ? ` ${shouldBlur ? `
@@ -257,7 +308,7 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
/** /**
* Generate image wrapper HTML * Generate image wrapper HTML
*/ */
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) { function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return ` return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%"> <div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? ` ${shouldBlur ? `
@@ -266,7 +317,7 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
</button> </button>
` : ''} ` : ''}
<img data-local-src="${localUrl || ''}" <img data-local-src="${localUrl || ''}"
data-remote-src="${media.url}" data-remote-src="${remoteUrl}"
alt="Preview" alt="Preview"
crossorigin="anonymous" crossorigin="anonymous"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
@@ -286,410 +337,10 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
`; `;
} }
/** // Use the shared setupShowcaseScroll function with the correct modal ID
* Toggle showcase expansion export { setupShowcaseScroll, scrollToTop, toggleShowcase };
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed'); // Initialize the showcase scroll when this module is imported
document.addEventListener('DOMContentLoaded', () => {
if (isCollapsed) { setupShowcaseScroll('checkpointModal');
const count = carousel.querySelectorAll('.media-wrapper').length; });
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initLazyLoading(carousel);
// 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');
}
}
/**
* Initialize metadata panel interaction handlers
*/
function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
const mediaElement = wrapper.querySelector('img, video');
if (!metadataPanel || !mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
// Only hide panel when mouse leaves the wrapper and not over the metadata panel
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
}
});
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt buttons
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();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
});
// Prevent panel scroll from causing modal scroll
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 });
});
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @returns {Object} - Rect with left, top, right, bottom coordinates
*/
function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
// Get natural dimensions of the media
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
if (!naturalWidth || !naturalHeight) {
// Fallback if dimensions cannot be determined
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
}
// Calculate aspect ratios
const containerRatio = containerWidth / containerHeight;
const mediaRatio = naturalWidth / naturalHeight;
let renderedWidth, renderedHeight, left = 0, top = 0;
// Apply object-fit: contain logic
if (containerRatio > mediaRatio) {
// Container is wider than media - will have empty space on sides
renderedHeight = containerHeight;
renderedWidth = renderedHeight * mediaRatio;
left = (containerWidth - renderedWidth) / 2;
} else {
// Container is taller than media - will have empty space top/bottom
renderedWidth = containerWidth;
renderedHeight = renderedWidth / mediaRatio;
top = (containerHeight - renderedHeight) / 2;
}
return {
left,
top,
right: left + renderedWidth,
bottom: top + renderedHeight
};
}
/**
* Initialize blur toggle handlers
*/
function initNsfwBlurHandlers(container) {
// Handle toggle blur buttons
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
toggleButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
const isBlurred = media.classList.toggle('blurred');
const icon = btn.querySelector('i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
});
// Handle "Show" buttons in overlays
const showButtons = container.querySelectorAll('.show-content-btn');
showButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
media.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
});
}
/**
* Initialize lazy loading for images and videos
*/
function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
const localSrc = element.dataset.localSrc;
const remoteSrc = element.dataset.remoteSrc;
// Check if element is an image or video
if (element.tagName.toLowerCase() === 'video') {
// Try local first, then remote
tryLocalOrFallbackToRemote(element, localSrc, remoteSrc);
} else {
// For images, we'll use an Image object to test if local file exists
tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc);
}
element.classList.remove('lazy');
};
// Try to load local image first, fall back to remote if local fails
const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
const testImg = new Image();
testImg.onload = () => {
// Local image loaded successfully
imgElement.src = localSrc;
};
testImg.onerror = () => {
// Local image failed, use remote
imgElement.src = remoteSrc;
};
// Start loading test image
testImg.src = localSrc;
} else {
// No local path, use remote directly
imgElement.src = remoteSrc;
}
};
// Try to load local video first, fall back to remote if local fails
const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
// Try to fetch local file headers to see if it exists
fetch(localSrc, { method: 'HEAD' })
.then(response => {
if (response.ok) {
// Local video exists, use it
videoElement.src = localSrc;
videoElement.querySelector('source').src = localSrc;
} else {
// Local video doesn't exist, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
}
videoElement.load();
})
.catch(() => {
// Error fetching, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
});
} else {
// No local path, use remote directly
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
}
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
lazyLoad(entry.target);
observer.unobserve(entry.target);
}
});
});
lazyElements.forEach(element => observer.observe(element));
}
/**
* Set up showcase scroll functionality
*/
export function setupShowcaseScroll() {
// Listen for wheel events
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector('#checkpointModal .modal-content');
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false });
// Use MutationObserver to set up back-to-top button when modal content is added
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const checkpointModal = document.getElementById('checkpointModal');
if (checkpointModal && checkpointModal.querySelector('.modal-content')) {
setupBackToTopButton(checkpointModal.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('#checkpointModal .modal-content');
if (modalContent) {
setupBackToTopButton(modalContent);
}
}
/**
* Set up 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'));
}
/**
* Scroll to top of modal content
*/
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}

View File

@@ -3,8 +3,7 @@
* *
* Modularized checkpoint modal component that handles checkpoint model details display * Modularized checkpoint modal component that handles checkpoint model details display
*/ */
import { showToast } from '../../utils/uiHelpers.js'; import { showToast, getExampleImageFiles, initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers } from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js'; import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
@@ -16,6 +15,7 @@ import {
import { saveModelMetadata } from '../../api/checkpointApi.js'; import { saveModelMetadata } from '../../api/checkpointApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js'; import { updateCheckpointCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/** /**
* Display the checkpoint modal with the given checkpoint data * Display the checkpoint modal with the given checkpoint data
@@ -27,11 +27,25 @@ export function showCheckpointModal(checkpoint) {
<button class="close" onclick="modalManager.closeModal('checkpointModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('checkpointModal')">&times;</button>
<header class="modal-header"> <header class="modal-header">
<div class="model-name-header"> <div class="model-name-header">
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${checkpoint.model_name || 'Checkpoint Details'}</h2> <h2 class="model-name-content">${checkpoint.model_name || 'Checkpoint Details'}</h2>
<button class="edit-model-name-btn" title="Edit model name"> <button class="edit-model-name-btn" title="Edit model name">
<i class="fas fa-pencil-alt"></i> <i class="fas fa-pencil-alt"></i>
</button> </button>
</div> </div>
${checkpoint.civitai?.creator ? `
<div class="creator-info">
${checkpoint.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${checkpoint.civitai.creator.image}" alt="${checkpoint.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${checkpoint.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(checkpoint.tags || [])} ${renderCompactTags(checkpoint.tags || [])}
</header> </header>
@@ -83,7 +97,7 @@ export function showCheckpointModal(checkpoint) {
</div> </div>
<div class="info-item full-width"> <div class="info-item full-width">
<label>About this version</label> <label>About this version</label>
<div class="description-text">${checkpoint.description || 'N/A'}</div> <div class="description-text">${checkpoint.civitai?.description || 'N/A'}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -96,7 +110,9 @@ export function showCheckpointModal(checkpoint) {
<div class="tab-content"> <div class="tab-content">
<div id="showcase-tab" class="tab-pane active"> <div id="showcase-tab" class="tab-pane active">
${renderShowcaseContent(checkpoint.civitai?.images || [], checkpoint.sha256)} <div class="recipes-loading">
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
</div>
</div> </div>
<div id="description-tab" class="tab-pane"> <div id="description-tab" class="tab-pane">
@@ -132,6 +148,69 @@ export function showCheckpointModal(checkpoint) {
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) { if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path); loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path);
} }
// Load example images asynchronously
loadExampleImages(checkpoint.civitai?.images, checkpoint.sha256, checkpoint.file_path);
}
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects
* @param {string} modelHash - Model hash for fetching local files
* @param {string} filePath - File path for fetching local files
*/
async function loadExampleImages(images, modelHash, filePath) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
// Choose endpoint based on centralized examples setting
const useCentralized = state.global.settings.useCentralizedExamples !== false;
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
// Use different params based on endpoint
const params = useCentralized ?
`model_hash=${modelHash}` :
`file_path=${encodeURIComponent(filePath)}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel');
if (carousel) {
// Only initialize if we actually have examples and they're expanded
if (!carousel.classList.contains('collapsed')) {
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
}
}
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Error loading example images
</div>
`;
}
}
} }
/** /**

View File

@@ -33,8 +33,8 @@ export class CheckpointsControls extends PageControls {
return await resetAndReload(updateFolders); return await resetAndReload(updateFolders);
}, },
refreshModels: async () => { refreshModels: async (fullRebuild = false) => {
return await refreshCheckpoints(); return await refreshCheckpoints(fullRebuild);
}, },
// Add fetch from Civitai functionality for checkpoints // Add fetch from Civitai functionality for checkpoints

View File

@@ -36,8 +36,8 @@ export class LorasControls extends PageControls {
return await resetAndReload(updateFolders); return await resetAndReload(updateFolders);
}, },
refreshModels: async () => { refreshModels: async (fullRebuild = false) => {
return await refreshLoras(); return await refreshLoras(fullRebuild);
}, },
// LoRA-specific API functions // LoRA-specific API functions

View File

@@ -83,9 +83,12 @@ export class PageControls {
// Refresh button handler // Refresh button handler
const refreshBtn = document.querySelector('[data-action="refresh"]'); const refreshBtn = document.querySelector('[data-action="refresh"]');
if (refreshBtn) { if (refreshBtn) {
refreshBtn.addEventListener('click', () => this.refreshModels()); refreshBtn.addEventListener('click', () => this.refreshModels(false)); // Regular refresh (incremental)
} }
// Initialize dropdown functionality
this.initDropdowns();
// Toggle folders button // Toggle folders button
const toggleFoldersBtn = document.querySelector('.toggle-folders-btn'); const toggleFoldersBtn = document.querySelector('.toggle-folders-btn');
if (toggleFoldersBtn) { if (toggleFoldersBtn) {
@@ -102,6 +105,61 @@ export class PageControls {
this.initPageSpecificListeners(); this.initPageSpecificListeners();
} }
/**
* Initialize dropdown functionality
*/
initDropdowns() {
// Handle dropdown toggles
const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
dropdownToggles.forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent triggering parent button
const dropdownGroup = toggle.closest('.dropdown-group');
// Close all other open dropdowns first
document.querySelectorAll('.dropdown-group.active').forEach(group => {
if (group !== dropdownGroup) {
group.classList.remove('active');
}
});
// Toggle current dropdown
dropdownGroup.classList.toggle('active');
});
});
// Handle quick refresh option
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
if (quickRefreshOption) {
quickRefreshOption.addEventListener('click', (e) => {
e.stopPropagation();
this.refreshModels(false);
// Close the dropdown
document.querySelector('.dropdown-group.active')?.classList.remove('active');
});
}
// Handle full rebuild option
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
if (fullRebuildOption) {
fullRebuildOption.addEventListener('click', (e) => {
e.stopPropagation();
this.refreshModels(true);
// Close the dropdown
document.querySelector('.dropdown-group.active')?.classList.remove('active');
});
}
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.dropdown-group')) {
document.querySelectorAll('.dropdown-group.active').forEach(group => {
group.classList.remove('active');
});
}
});
}
/** /**
* Initialize page-specific event listeners * Initialize page-specific event listeners
*/ */
@@ -117,6 +175,12 @@ export class PageControls {
downloadButton.addEventListener('click', () => this.showDownloadModal()); downloadButton.addEventListener('click', () => this.showDownloadModal());
} }
// Find duplicates button - available for both loras and checkpoints
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
if (duplicatesButton) {
duplicatesButton.addEventListener('click', () => this.findDuplicates());
}
if (this.pageType === 'loras') { if (this.pageType === 'loras') {
// Bulk operations button - LoRAs only // Bulk operations button - LoRAs only
const bulkButton = document.querySelector('[data-action="bulk"]'); const bulkButton = document.querySelector('[data-action="bulk"]');
@@ -327,18 +391,24 @@ export class PageControls {
/** /**
* Refresh models list * Refresh models list
* @param {boolean} fullRebuild - Whether to perform a full rebuild
*/ */
async refreshModels() { async refreshModels(fullRebuild = false) {
if (!this.api) { if (!this.api) {
console.error('API methods not registered'); console.error('API methods not registered');
return; return;
} }
try { try {
await this.api.refreshModels(); await this.api.refreshModels(fullRebuild);
} catch (error) { } catch (error) {
console.error(`Error refreshing ${this.pageType}:`, error); console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
showToast(`Failed to refresh ${this.pageType}: ${error.message}`, 'error'); showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
}
if (window.modelDuplicatesManager) {
// Update duplicates badge after refresh
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
} }
} }
@@ -440,4 +510,16 @@ export class PageControls {
// Reload models with new filter // Reload models with new filter
await this.resetAndReload(true); await this.resetAndReload(true);
} }
/**
* Find duplicate models
*/
findDuplicates() {
if (window.modelDuplicatesManager) {
// Change to toggle functionality
window.modelDuplicatesManager.toggleDuplicateMode();
} else {
console.error('Model duplicates manager not available');
}
}
} }

View File

@@ -2,7 +2,6 @@
import { PageControls } from './PageControls.js'; import { PageControls } from './PageControls.js';
import { LorasControls } from './LorasControls.js'; import { LorasControls } from './LorasControls.js';
import { CheckpointsControls } from './CheckpointsControls.js'; import { CheckpointsControls } from './CheckpointsControls.js';
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
// Export the classes // Export the classes
export { PageControls, LorasControls, CheckpointsControls }; export { PageControls, LorasControls, CheckpointsControls };
@@ -22,16 +21,3 @@ export function createPageControls(pageType) {
return null; return null;
} }
} }
// Example for a filter method:
function applyFilter(filterType, value) {
// ...existing filter logic...
// After filters are applied, refresh the virtual scroll if it exists
if (state.virtualScroller) {
refreshVirtualScroll();
} else {
// Fall back to existing reset and reload logic
resetAndReload(true);
}
}

View File

@@ -5,7 +5,7 @@
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js'; import { BASE_MODELS } from '../../utils/constants.js';
import { updateLoraCard } from '../../utils/cardUpdater.js'; import { updateLoraCard } from '../../utils/cardUpdater.js';
import { saveModelMetadata } from '../../api/loraApi.js'; import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js';
/** /**
* 设置模型名称编辑功能 * 设置模型名称编辑功能
@@ -27,14 +27,17 @@ export function setupModelNameEditing(filePath) {
}); });
modelNameHeader.addEventListener('mouseleave', () => { modelNameHeader.addEventListener('mouseleave', () => {
if (!modelNameContent.getAttribute('data-editing')) { if (!modelNameHeader.classList.contains('editing')) {
editBtn.classList.remove('visible'); editBtn.classList.remove('visible');
} }
}); });
// Handle edit button click // Handle edit button click
editBtn.addEventListener('click', () => { editBtn.addEventListener('click', () => {
modelNameContent.setAttribute('data-editing', 'true'); modelNameHeader.classList.add('editing');
modelNameContent.setAttribute('contenteditable', 'true');
// Store original value for comparison later
modelNameContent.dataset.originalValue = modelNameContent.textContent.trim();
modelNameContent.focus(); modelNameContent.focus();
// Place cursor at the end // Place cursor at the end
@@ -50,33 +53,25 @@ export function setupModelNameEditing(filePath) {
editBtn.classList.add('visible'); editBtn.classList.add('visible');
}); });
// Handle focus out // Handle keyboard events in edit mode
modelNameContent.addEventListener('blur', function() {
this.removeAttribute('data-editing');
editBtn.classList.remove('visible');
if (this.textContent.trim() === '') {
// Restore original model name if empty
const filePath = this.dataset.filePath;
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) { modelNameContent.addEventListener('keydown', function(e) {
if (!this.getAttribute('contenteditable')) return;
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
const filePath = this.dataset.filePath; this.blur(); // Trigger save on Enter
saveModelName(filePath); } else if (e.key === 'Escape') {
this.blur(); e.preventDefault();
// Restore original value
this.textContent = this.dataset.originalValue;
exitEditMode();
} }
}); });
// Limit model name length // Limit model name length
modelNameContent.addEventListener('input', function() { modelNameContent.addEventListener('input', function() {
if (!this.getAttribute('contenteditable')) return;
// Limit model name length // Limit model name length
if (this.textContent.length > 100) { if (this.textContent.length > 100) {
this.textContent = this.textContent.substring(0, 100); this.textContent = this.textContent.substring(0, 100);
@@ -91,6 +86,60 @@ export function setupModelNameEditing(filePath) {
showToast('Model name is limited to 100 characters', 'warning'); showToast('Model name is limited to 100 characters', 'warning');
} }
}); });
// Handle focus out - save changes
modelNameContent.addEventListener('blur', async function() {
if (!this.getAttribute('contenteditable')) return;
const newModelName = this.textContent.trim();
const originalValue = this.dataset.originalValue;
// Basic validation
if (!newModelName) {
// Restore original value if empty
this.textContent = originalValue;
showToast('Model name cannot be empty', 'error');
exitEditMode();
return;
}
if (newModelName === originalValue) {
// No changes, just exit edit mode
exitEditMode();
return;
}
try {
// Get the file path from the dataset
const filePath = this.dataset.filePath;
await saveModelMetadata(filePath, { model_name: newModelName });
// Update the corresponding lora card's dataset and display
updateLoraCard(filePath, { model_name: newModelName });
// BUGFIX: Directly update the card's dataset.name attribute to ensure
// it's correctly read when reopening the modal
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
loraCard.dataset.name = newModelName;
}
showToast('Model name updated successfully', 'success');
} catch (error) {
console.error('Error updating model name:', error);
this.textContent = originalValue; // Restore original model name
showToast('Failed to update model name', 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
modelNameContent.removeAttribute('contenteditable');
modelNameHeader.classList.remove('editing');
editBtn.classList.remove('visible');
}
} }
/** /**
@@ -410,19 +459,8 @@ export function setupFileNameEditing(filePath) {
// Get the file path from the dataset // Get the file path from the dataset
const filePath = this.dataset.filePath; const filePath = this.dataset.filePath;
// Call API to rename the file // Call API to rename the file using the new function from loraApi.js
const response = await fetch('/api/rename_lora', { const result = await renameLoraFile(filePath, newFileName);
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) { if (result.success) {
showToast('File name updated successfully', 'success'); showToast('File name updated successfully', 'success');

View File

@@ -2,44 +2,21 @@
* ShowcaseView.js * ShowcaseView.js
* 处理LoRA模型展示内容图片、视频的功能模块 * 处理LoRA模型展示内容图片、视频的功能模块
*/ */
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop
} from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
/** /**
* Get the local URL for an example image if available * 获取展示内容并进行渲染
* @param {Object} img - Image object
* @param {number} index - Image index
* @param {string} modelHash - Model hash
* @returns {string|null} - Local URL or null if not available
*/
function getLocalExampleImageUrl(img, index, modelHash) {
if (!modelHash) return null;
// Get remote extension
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
// If it's a video (mp4), use that extension
if (remoteExt === 'mp4') {
return `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
}
// For images, check if optimization is enabled (defaults to true)
const optimizeImages = state.settings.optimizeExampleImages !== false;
// Use .webp for images if optimization enabled, otherwise use original extension
const extension = optimizeImages ? 'webp' : remoteExt;
return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`;
}
/**
* 渲染展示内容
* @param {Array} images - 要展示的图片/视频数组 * @param {Array} images - 要展示的图片/视频数组
* @param {string} modelHash - Model hash for identifying local files * @param {Array} exampleFiles - Local example files already fetched
* @returns {string} HTML内容 * @returns {Promise<string>} HTML内容
*/ */
export function renderShowcaseContent(images, modelHash) { export function renderShowcaseContent(images, exampleFiles = []) {
if (!images?.length) return '<div class="no-examples">No example images available</div>'; if (!images?.length) return '<div class="no-examples">No example images available</div>';
// Filter images based on SFW setting // Filter images based on SFW setting
@@ -82,21 +59,30 @@ export function renderShowcaseContent(images, modelHash) {
${hiddenNotification} ${hiddenNotification}
<div class="carousel-container"> <div class="carousel-container">
${filteredImages.map((img, index) => { ${filteredImages.map((img, index) => {
// Try to get local URL for the example image // Find matching file in our list of actual files
const localUrl = getLocalExampleImageUrl(img, index, modelHash); let localFile = null;
if (exampleFiles.length > 0) {
// Try to find the corresponding file by index first
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
// Create data attributes for both remote and local URLs // If not found by index, just use the same position in the array if available
const remoteUrl = img.url; if (!localFile && index < exampleFiles.length) {
const dataRemoteSrc = remoteUrl; localFile = exampleFiles[index];
const dataLocalSrc = localUrl; }
}
// 计算适当的展示高度: const remoteUrl = img.url || '';
// 1. 保持原始宽高比 const localUrl = localFile ? localFile.path : '';
// 2. 限制最大高度为视窗高度的60% const isVideo = localFile ? localFile.is_video :
// 3. 确保最小高度为容器宽度的40% remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// 计算适当的展示高度
const aspectRatio = (img.height / img.width) * 100; const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content的最大宽度 const containerWidth = 800;
const minHeightPercent = 40; // 最小高度为容器宽度的40% const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100; const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max( const heightPercent = Math.max(
minHeightPercent, minHeightPercent,
@@ -129,96 +115,98 @@ export function renderShowcaseContent(images, modelHash) {
const cfgScale = meta.cfgScale || ''; const cfgScale = meta.cfgScale || '';
const clipSkip = meta.clipSkip || ''; const clipSkip = meta.clipSkip || '';
// Check if we have any meaningful generation parameters
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip; const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
const hasPrompts = prompt || negativePrompt; const hasPrompts = prompt || negativePrompt;
// If no metadata available, show a message const metadataPanel = generateMetadataPanel(
if (!hasParams && !hasPrompts) { hasParams, hasPrompts,
const metadataPanel = ` prompt, negativePrompt,
<div class="image-metadata-panel"> size, seed, model, steps, sampler, cfgScale, clipSkip
<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') { if (isVideo) {
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc); return generateVideoWrapper(
} img, heightPercent, shouldBlur, nsfwText, metadataPanel,
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc); localUrl, remoteUrl
);
} }
return generateImageWrapper(
// Create a data attribute with the prompt for copying instead of trying to handle it in the onclick img, heightPercent, shouldBlur, nsfwText, metadataPanel,
// This avoids issues with quotes and special characters localUrl, remoteUrl
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, dataLocalSrc, dataRemoteSrc);
}
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
}).join('')} }).join('')}
</div> </div>
</div> </div>
`; `;
} }
/**
* Generate metadata panel HTML
*/
function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) {
// Create unique IDs for prompt copying
const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15);
let content = '<div class="image-metadata-panel"><div class="metadata-content">';
if (hasParams) {
content += `
<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>
`;
}
if (!hasParams && !hasPrompts) {
content += `
<div class="no-metadata-message">
<i class="fas fa-info-circle"></i>
<span>No generation parameters available</span>
</div>
`;
}
if (prompt) {
content += `
<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>
`;
}
if (negativePrompt) {
content += `
<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>
`;
}
content += '</div></div>';
return content;
}
/** /**
* 生成视频包装HTML * 生成视频包装HTML
*/ */
@@ -283,422 +271,10 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
`; `;
} }
/** // Use the shared setupShowcaseScroll function with the correct modal ID
* 切换展示区域的显示状态 export { setupShowcaseScroll, scrollToTop, toggleShowcase };
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed'); // Initialize the showcase scroll when this module is imported
document.addEventListener('DOMContentLoaded', () => {
if (isCollapsed) { setupShowcaseScroll('loraModal');
const count = carousel.querySelectorAll('.media-wrapper').length; });
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initLazyLoading(carousel);
// 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 initMetadataPanelHandlers(container) {
// Find all media wrappers
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
const mediaElement = wrapper.querySelector('img, video');
if (!metadataPanel || !mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel when over media content
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
// Only hide panel when mouse leaves the wrapper and not over the metadata panel
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
}
});
// 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 copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} 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 });
});
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @returns {Object} - Rect with left, top, right, bottom coordinates
*/
function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
// Get natural dimensions of the media
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
if (!naturalWidth || !naturalHeight) {
// Fallback if dimensions cannot be determined
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
}
// Calculate aspect ratios
const containerRatio = containerWidth / containerHeight;
const mediaRatio = naturalWidth / naturalHeight;
let renderedWidth, renderedHeight, left = 0, top = 0;
// Apply object-fit: contain logic
if (containerRatio > mediaRatio) {
// Container is wider than media - will have empty space on sides
renderedHeight = containerHeight;
renderedWidth = renderedHeight * mediaRatio;
left = (containerWidth - renderedWidth) / 2;
} else {
// Container is taller than media - will have empty space top/bottom
renderedWidth = containerWidth;
renderedHeight = renderedWidth / mediaRatio;
top = (containerHeight - renderedHeight) / 2;
}
return {
left,
top,
right: left + renderedWidth,
bottom: top + renderedHeight
};
}
/**
* 初始化模糊切换处理
*/
function initNsfwBlurHandlers(container) {
// Handle toggle blur buttons
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
toggleButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
const isBlurred = media.classList.toggle('blurred');
const icon = btn.querySelector('i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
});
// Handle "Show" buttons in overlays
const showButtons = container.querySelectorAll('.show-content-btn');
showButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
media.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
});
}
/**
* 初始化延迟加载
*/
function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
const localSrc = element.dataset.localSrc;
const remoteSrc = element.dataset.remoteSrc;
// Check if element is an image or video
if (element.tagName.toLowerCase() === 'video') {
// Try local first, then remote
tryLocalOrFallbackToRemote(element, localSrc, remoteSrc);
} else {
// For images, we'll use an Image object to test if local file exists
tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc);
}
element.classList.remove('lazy');
};
// Try to load local image first, fall back to remote if local fails
const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
const testImg = new Image();
testImg.onload = () => {
// Local image loaded successfully
imgElement.src = localSrc;
};
testImg.onerror = () => {
// Local image failed, use remote
imgElement.src = remoteSrc;
};
// Start loading test image
testImg.src = localSrc;
} else {
// No local path, use remote directly
imgElement.src = remoteSrc;
}
};
// Try to load local video first, fall back to remote if local fails
const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => {
// Only try local if we have a local path
if (localSrc) {
// Try to fetch local file headers to see if it exists
fetch(localSrc, { method: 'HEAD' })
.then(response => {
if (response.ok) {
// Local video exists, use it
videoElement.src = localSrc;
videoElement.querySelector('source').src = localSrc;
} else {
// Local video doesn't exist, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
}
videoElement.load();
})
.catch(() => {
// Error fetching, use remote
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
});
} else {
// No local path, use remote directly
videoElement.src = remoteSrc;
videoElement.querySelector('source').src = remoteSrc;
videoElement.load();
}
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
lazyLoad(entry.target);
observer.unobserve(entry.target);
}
});
});
lazyElements.forEach(element => observer.observe(element));
}
/**
* 设置展示区域的滚动处理
*/
export function setupShowcaseScroll() {
// Add event listener to document for wheel events
document.addEventListener('wheel', (event) => {
// Find the active modal content
const modalContent = document.querySelector('#loraModal .modal-content');
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false });
// 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);
}
}
/**
* 设置返回顶部按钮
*/
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) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}

View File

@@ -1,15 +1,181 @@
/** /**
* TriggerWords.js * TriggerWords.js
* 处理LoRA模型触发词相关的功能模块 * Module that handles trigger word functionality for LoRA models
*/ */
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js'; import { saveModelMetadata } from '../../api/loraApi.js';
/** /**
* 渲染触发词 * Fetch trained words for a model
* @param {Array} words - 触发词数组 * @param {string} filePath - Path to the model file
* @param {string} filePath - 文件路径 * @returns {Promise<Object>} - Object with trained words and class tokens
* @returns {string} HTML内容 */
async function fetchTrainedWords(filePath) {
try {
const response = await fetch(`/api/trained-words?file_path=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (data.success) {
return {
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
classTokens: data.class_tokens // Can be null or a string
};
} else {
throw new Error(data.error || 'Failed to fetch trained words');
}
} catch (error) {
console.error('Error fetching trained words:', error);
showToast('Could not load trained words', 'error');
return { trainedWords: [], classTokens: null };
}
}
/**
* Create suggestion dropdown with trained words as tags
* @param {Array} trainedWords - Array of [word, frequency] pairs
* @param {string|null} classTokens - Class tokens from training
* @param {Array} existingWords - Already added trigger words
* @returns {HTMLElement} - Dropdown element
*/
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
const dropdown = document.createElement('div');
dropdown.className = 'trained-words-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'trained-words-header';
// No suggestions case
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
header.innerHTML = '<span>No suggestions available</span>';
dropdown.appendChild(header);
dropdown.innerHTML += '<div class="no-trained-words">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
return dropdown;
}
// Sort trained words by frequency (highest first) if available
if (trainedWords && trainedWords.length > 0) {
trainedWords.sort((a, b) => b[1] - a[1]);
}
// Add class tokens section if available
if (classTokens) {
// Add class tokens header
const classTokensHeader = document.createElement('div');
classTokensHeader.className = 'trained-words-header';
classTokensHeader.innerHTML = `
<span>Class Token</span>
<small>Add to your prompt for best results</small>
`;
dropdown.appendChild(classTokensHeader);
// Add class tokens container
const classTokensContainer = document.createElement('div');
classTokensContainer.className = 'class-tokens-container';
// Create a special item for the class token
const tokenItem = document.createElement('div');
tokenItem.className = `trained-word-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.title = `Class token: ${classTokens}`;
tokenItem.innerHTML = `
<span class="trained-word-text">${classTokens}</span>
<div class="trained-word-meta">
<span class="token-badge">Class Token</span>
${existingWords.includes(classTokens) ?
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
</div>
`;
// Add click handler if not already added
if (!existingWords.includes(classTokens)) {
tokenItem.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(classTokens);
// Also populate the input field for potential editing
const input = document.querySelector('.new-trigger-word-input');
if (input) input.value = classTokens;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
classTokensContainer.appendChild(tokenItem);
dropdown.appendChild(classTokensContainer);
// Add separator if we also have trained words
if (trainedWords && trainedWords.length > 0) {
const separator = document.createElement('div');
separator.className = 'dropdown-separator';
dropdown.appendChild(separator);
}
}
// Add trained words header if we have any
if (trainedWords && trainedWords.length > 0) {
header.innerHTML = `
<span>Word Suggestions</span>
<small>${trainedWords.length} words found</small>
`;
dropdown.appendChild(header);
// Create tag container for trained words
const container = document.createElement('div');
container.className = 'trained-words-container';
// Add each trained word as a tag
trainedWords.forEach(([word, frequency]) => {
const isAdded = existingWords.includes(word);
const item = document.createElement('div');
item.className = `trained-word-item ${isAdded ? 'already-added' : ''}`;
item.title = word; // Show full word on hover if truncated
item.innerHTML = `
<span class="trained-word-text">${word}</span>
<div class="trained-word-meta">
<span class="trained-word-freq">${frequency}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
</div>
`;
if (!isAdded) {
item.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(word);
// Also populate the input field for potential editing
const input = document.querySelector('.new-trigger-word-input');
if (input) input.value = word;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
} else if (!classTokens) {
// If we have neither class tokens nor trained words
dropdown.innerHTML += '<div class="no-trained-words">No word suggestions found in this model. You can manually enter trigger words.</div>';
}
return dropdown;
}
/**
* Render trigger words
* @param {Array} words - Array of trigger words
* @param {string} filePath - File path
* @returns {string} HTML content
*/ */
export function renderTriggerWords(words, filePath) { export function renderTriggerWords(words, filePath) {
if (!words.length) return ` if (!words.length) return `
@@ -25,17 +191,12 @@ export function renderTriggerWords(words, filePath) {
<div class="trigger-words-tags" style="display:none;"></div> <div class="trigger-words-tags" style="display:none;"></div>
</div> </div>
<div class="trigger-words-edit-controls" style="display:none;"> <div class="trigger-words-edit-controls" style="display:none;">
<button class="add-trigger-word-btn" title="Add a trigger word">
<i class="fas fa-plus"></i> Add
</button>
<button class="save-trigger-words-btn" title="Save changes"> <button class="save-trigger-words-btn" title="Save changes">
<i class="fas fa-save"></i> Save <i class="fas fa-save"></i> Save
</button> </button>
</div> </div>
<div class="add-trigger-word-form" style="display:none;"> <div class="add-trigger-word-form" style="display:none;">
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word"> <input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
<button class="confirm-add-trigger-word-btn">Add</button>
<button class="cancel-add-trigger-word-btn">Cancel</button>
</div> </div>
</div> </div>
`; `;
@@ -64,43 +225,53 @@ export function renderTriggerWords(words, filePath) {
</div> </div>
</div> </div>
<div class="trigger-words-edit-controls" style="display:none;"> <div class="trigger-words-edit-controls" style="display:none;">
<button class="add-trigger-word-btn" title="Add a trigger word">
<i class="fas fa-plus"></i> Add
</button>
<button class="save-trigger-words-btn" title="Save changes"> <button class="save-trigger-words-btn" title="Save changes">
<i class="fas fa-save"></i> Save <i class="fas fa-save"></i> Save
</button> </button>
</div> </div>
<div class="add-trigger-word-form" style="display:none;"> <div class="add-trigger-word-form" style="display:none;">
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word"> <input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
<button class="confirm-add-trigger-word-btn">Add</button>
<button class="cancel-add-trigger-word-btn">Cancel</button>
</div> </div>
</div> </div>
`; `;
} }
/** /**
* 设置触发词编辑模式 * Set up trigger words edit mode
*/ */
export function setupTriggerWordsEditMode() { export function setupTriggerWordsEditMode() {
// Store trained words data
let trainedWordsList = [];
let classTokensValue = null;
let isTrainedWordsLoaded = false;
// Store original trigger words for restoring on cancel
let originalTriggerWords = [];
const editBtn = document.querySelector('.edit-trigger-words-btn'); const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return; if (!editBtn) return;
editBtn.addEventListener('click', function() { editBtn.addEventListener('click', async function() {
const triggerWordsSection = this.closest('.trigger-words'); const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode'); const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements // Toggle edit mode UI elements
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag'); const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls'); const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls');
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words'); const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags'); const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (isEditMode) { if (isEditMode) {
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = "Cancel editing"; this.title = "Cancel editing";
// Store original trigger words for potential restoration
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
// Show edit controls and input form
editControls.style.display = 'flex'; editControls.style.display = 'flex';
addForm.style.display = 'flex';
// If we have no trigger words yet, hide the "No trigger word needed" text // If we have no trigger words yet, hide the "No trigger word needed" text
// and show the empty tags container // and show the empty tags container
@@ -112,13 +283,67 @@ export function setupTriggerWordsEditMode() {
// Disable click-to-copy and show delete buttons // Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => { triggerWordTags.forEach(tag => {
tag.onclick = null; tag.onclick = null;
tag.querySelector('.trigger-word-copy').style.display = 'none'; const copyIcon = tag.querySelector('.trigger-word-copy');
tag.querySelector('.delete-trigger-word-btn').style.display = 'block'; const deleteBtn = tag.querySelector('.delete-trigger-word-btn');
if (copyIcon) copyIcon.style.display = 'none';
if (deleteBtn) {
deleteBtn.style.display = 'block';
// Re-attach event listener to ensure it works every time
// First remove any existing listeners to avoid duplication
deleteBtn.removeEventListener('click', deleteTriggerWord);
deleteBtn.addEventListener('click', deleteTriggerWord);
}
}); });
// Load trained words and display dropdown when entering edit mode
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'trained-words-loading';
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
addForm.appendChild(loadingIndicator);
// Get currently added trigger words
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Asynchronously load trained words if not already loaded
if (!isTrainedWordsLoaded) {
const result = await fetchTrainedWords(filePath);
trainedWordsList = result.trainedWords;
classTokensValue = result.classTokens;
isTrainedWordsLoaded = true;
}
// Remove loading indicator
loadingIndicator.remove();
// Create and display suggestion dropdown
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
addForm.appendChild(dropdown);
// Focus the input
addForm.querySelector('input').focus();
} else { } else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit trigger words"; this.title = "Edit trigger words";
// Hide edit controls and input form
editControls.style.display = 'none'; editControls.style.display = 'none';
addForm.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original trigger words
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
} else {
// If saving, reset UI state on current trigger words
resetTriggerWordsUIState(triggerWordsSection);
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
// If we have no trigger words, show the "No trigger word needed" text // If we have no trigger words, show the "No trigger word needed" text
// and hide the empty tags container // and hide the empty tags container
@@ -128,57 +353,26 @@ export function setupTriggerWordsEditMode() {
if (tagsContainer) tagsContainer.style.display = 'none'; if (tagsContainer) tagsContainer.style.display = 'none';
} }
// Restore original state // Remove dropdown if present
triggerWordTags.forEach(tag => { const dropdown = document.querySelector('.trained-words-dropdown');
const word = tag.dataset.word; if (dropdown) dropdown.remove();
tag.onclick = () => copyTriggerWord(word);
tag.querySelector('.trigger-word-copy').style.display = 'flex';
tag.querySelector('.delete-trigger-word-btn').style.display = 'none';
});
// Hide add form if open
triggerWordsSection.querySelector('.add-trigger-word-form').style.display = 'none';
} }
}); });
// Set up add trigger word button // Set up input for adding trigger words
const addBtn = document.querySelector('.add-trigger-word-btn');
if (addBtn) {
addBtn.addEventListener('click', function() {
const triggerWordsSection = this.closest('.trigger-words');
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
addForm.style.display = 'flex';
addForm.querySelector('input').focus();
});
}
// Set up confirm and cancel add buttons
const confirmAddBtn = document.querySelector('.confirm-add-trigger-word-btn');
const cancelAddBtn = document.querySelector('.cancel-add-trigger-word-btn');
const triggerWordInput = document.querySelector('.new-trigger-word-input'); const triggerWordInput = document.querySelector('.new-trigger-word-input');
if (confirmAddBtn && triggerWordInput) { if (triggerWordInput) {
confirmAddBtn.addEventListener('click', function() {
addNewTriggerWord(triggerWordInput.value);
});
// Add keydown event to input // Add keydown event to input
triggerWordInput.addEventListener('keydown', function(e) { triggerWordInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
addNewTriggerWord(this.value); addNewTriggerWord(this.value);
this.value = ''; // Clear input after adding
} }
}); });
} }
if (cancelAddBtn) {
cancelAddBtn.addEventListener('click', function() {
const addForm = this.closest('.add-trigger-word-form');
addForm.style.display = 'none';
addForm.querySelector('input').value = '';
});
}
// Set up save button // Set up save button
const saveBtn = document.querySelector('.save-trigger-words-btn'); const saveBtn = document.querySelector('.save-trigger-words-btn');
if (saveBtn) { if (saveBtn) {
@@ -187,17 +381,92 @@ export function setupTriggerWordsEditMode() {
// Set up delete buttons // Set up delete buttons
document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => { document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => {
btn.addEventListener('click', function(e) { // Remove any existing listeners to avoid duplication
e.stopPropagation(); btn.removeEventListener('click', deleteTriggerWord);
const tag = this.closest('.trigger-word-tag'); btn.addEventListener('click', deleteTriggerWord);
tag.remove();
});
}); });
} }
/** /**
* 添加新触发词 * Delete trigger word event handler
* @param {string} word - 要添加的触发词 * @param {Event} e - Click event
*/
function deleteTriggerWord(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
tag.remove();
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
/**
* Reset UI state for trigger words after saving
* @param {HTMLElement} section - The trigger words section
*/
function resetTriggerWordsUIState(section) {
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.delete-trigger-word-btn');
// Restore click-to-copy functionality
tag.onclick = () => copyTriggerWord(word);
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
if (deleteBtn) deleteBtn.style.display = 'none';
});
}
/**
* Restore original trigger words when canceling edit
* @param {HTMLElement} section - The trigger words section
* @param {Array} originalWords - Original trigger words
*/
function restoreOriginalTriggerWords(section, originalWords) {
const tagsContainer = section.querySelector('.trigger-words-tags');
const noTriggerWords = section.querySelector('.no-trigger-words');
if (!tagsContainer) return;
// Clear current tags
tagsContainer.innerHTML = '';
if (originalWords.length === 0) {
if (noTriggerWords) noTriggerWords.style.display = '';
tagsContainer.style.display = 'none';
return;
}
// Hide "no trigger words" message
if (noTriggerWords) noTriggerWords.style.display = 'none';
tagsContainer.style.display = 'flex';
// Recreate original tags
originalWords.forEach(word => {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.onclick = () => copyTriggerWord(word);
tag.innerHTML = `
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="delete-trigger-word-btn" style="display:none;" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
tagsContainer.appendChild(tag);
});
}
/**
* Add a new trigger word
* @param {string} word - Trigger word to add
*/ */
function addNewTriggerWord(word) { function addNewTriggerWord(word) {
word = word.trim(); word = word.trim();
@@ -263,24 +532,79 @@ function addNewTriggerWord(word) {
// Add event listener to delete button // Add event listener to delete button
const deleteBtn = newTag.querySelector('.delete-trigger-word-btn'); const deleteBtn = newTag.querySelector('.delete-trigger-word-btn');
deleteBtn.addEventListener('click', function() { deleteBtn.addEventListener('click', deleteTriggerWord);
newTag.remove();
});
tagsContainer.appendChild(newTag); tagsContainer.appendChild(newTag);
// Clear and hide the input form // Update status of items in the trained words dropdown
const triggerWordInput = document.querySelector('.new-trigger-word-input'); updateTrainedWordsDropdown();
triggerWordInput.value = '';
document.querySelector('.add-trigger-word-form').style.display = 'none';
} }
/** /**
* 保存触发词 * Update status of items in the trained words dropdown
*/
function updateTrainedWordsDropdown() {
const dropdown = document.querySelector('.trained-words-dropdown');
if (!dropdown) return;
// Get all current trigger words
const currentTags = document.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Update status of each item in dropdown
dropdown.querySelectorAll('.trained-word-item').forEach(item => {
const wordText = item.querySelector('.trained-word-text').textContent;
const isAdded = existingWords.includes(wordText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
const meta = item.querySelector('.trained-word-meta');
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
meta.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const word = item.querySelector('.trained-word-text').textContent;
addNewTriggerWord(word);
// Also populate the input field
const input = document.querySelector('.new-trigger-word-input');
if (input) input.value = word;
// Focus the input
if (input) input.focus();
};
}
}
});
}
/**
* Save trigger words
*/ */
async function saveTriggerWords() { async function saveTriggerWords() {
const filePath = document.querySelector('.edit-trigger-words-btn').dataset.filePath; const editBtn = document.querySelector('.edit-trigger-words-btn');
const triggerWordTags = document.querySelectorAll('.trigger-word-tag'); const filePath = editBtn.dataset.filePath;
const triggerWordsSection = editBtn.closest('.trigger-words');
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word); const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
try { try {
@@ -289,9 +613,11 @@ async function saveTriggerWords() {
civitai: { trainedWords: words } civitai: { trainedWords: words }
}); });
// Update UI // Set flag to skip restoring original words when exiting edit mode
const editBtn = document.querySelector('.edit-trigger-words-btn'); editBtn.dataset.skipRestore = "true";
editBtn.click(); // Exit edit mode
// Exit edit mode without restoring original trigger words
editBtn.click();
// Update the LoRA card's dataset // Update the LoRA card's dataset
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
@@ -316,8 +642,8 @@ async function saveTriggerWords() {
} }
// If we saved an empty array and there's a no-trigger-words element, show it // If we saved an empty array and there's a no-trigger-words element, show it
const noTriggerWords = document.querySelector('.no-trigger-words'); const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = document.querySelector('.trigger-words-tags'); const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (words.length === 0 && noTriggerWords) { if (words.length === 0 && noTriggerWords) {
noTriggerWords.style.display = ''; noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none'; if (tagsContainer) tagsContainer.style.display = 'none';
@@ -331,8 +657,8 @@ async function saveTriggerWords() {
} }
/** /**
* 复制触发词到剪贴板 * Copy a trigger word to clipboard
* @param {string} word - 要复制的触发词 * @param {string} word - Word to copy
*/ */
window.copyTriggerWord = async function(word) { window.copyTriggerWord = async function(word) {
try { try {

View File

@@ -3,7 +3,7 @@
* *
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件 * 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
*/ */
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { showToast, copyToClipboard, getExampleImageFiles } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js'; import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
@@ -18,6 +18,7 @@ import {
import { saveModelMetadata } from '../../api/loraApi.js'; import { saveModelMetadata } from '../../api/loraApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateLoraCard } from '../../utils/cardUpdater.js'; import { updateLoraCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/** /**
* 显示LoRA模型弹窗 * 显示LoRA模型弹窗
@@ -32,11 +33,25 @@ export function showLoraModal(lora) {
<button class="close" onclick="modalManager.closeModal('loraModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('loraModal')">&times;</button>
<header class="modal-header"> <header class="modal-header">
<div class="model-name-header"> <div class="model-name-header">
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${lora.model_name}</h2> <h2 class="model-name-content">${lora.model_name}</h2>
<button class="edit-model-name-btn" title="Edit model name"> <button class="edit-model-name-btn" title="Edit model name">
<i class="fas fa-pencil-alt"></i> <i class="fas fa-pencil-alt"></i>
</button> </button>
</div> </div>
${lora.civitai?.creator ? `
<div class="creator-info">
${lora.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${lora.civitai.creator.image}" alt="${lora.civitai.creator.username}" onerror="this.onerror=null; this.src='static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
<span class="creator-username">${lora.civitai.creator.username}</span>
</div>` : ''}
${renderCompactTags(lora.tags || [])} ${renderCompactTags(lora.tags || [])}
</header> </header>
@@ -108,7 +123,7 @@ export function showLoraModal(lora) {
</div> </div>
<div class="info-item full-width"> <div class="info-item full-width">
<label>About this version</label> <label>About this version</label>
<div class="description-text">${lora.description || 'N/A'}</div> <div class="description-text">${lora.civitai?.description || 'N/A'}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -122,7 +137,9 @@ export function showLoraModal(lora) {
<div class="tab-content"> <div class="tab-content">
<div id="showcase-tab" class="tab-pane active"> <div id="showcase-tab" class="tab-pane active">
${renderShowcaseContent(lora.civitai?.images, lora.sha256)} <div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> Loading example images...
</div>
</div> </div>
<div id="description-tab" class="tab-pane"> <div id="description-tab" class="tab-pane">
@@ -168,6 +185,70 @@ export function showLoraModal(lora) {
// Load recipes for this Lora // Load recipes for this Lora
loadRecipesForLora(lora.model_name, lora.sha256); loadRecipesForLora(lora.model_name, lora.sha256);
// Load example images asynchronously
loadExampleImages(lora.civitai?.images, lora.sha256, lora.file_path);
}
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects
* @param {string} modelHash - Model hash for fetching local files
* @param {string} filePath - File path for fetching local files
*/
async function loadExampleImages(images, modelHash, filePath) {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
// Choose endpoint based on centralized examples setting
const useCentralized = state.global.settings.useCentralizedExamples !== false;
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
// Use different params based on endpoint
const params = useCentralized ?
`model_hash=${modelHash}` :
`file_path=${encodeURIComponent(filePath)}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel');
if (carousel) {
// Only initialize if we actually have examples and they're expanded
if (!carousel.classList.contains('collapsed')) {
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
}
}
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Error loading example images
</div>
`;
}
}
} }
// Copy file name function // Copy file name function

View File

@@ -6,7 +6,8 @@ import { updateService } from './managers/UpdateService.js';
import { HeaderManager } from './components/Header.js'; import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js'; import { settingsManager } from './managers/SettingsManager.js';
import { exampleImagesManager } from './managers/ExampleImagesManager.js'; import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js'; import { helpManager } from './managers/HelpManager.js';
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js'; import { migrateStorageItems } from './utils/storageHelpers.js';
import { setupLoraCardEventDelegation } from './components/LoraCard.js'; import { setupLoraCardEventDelegation } from './components/LoraCard.js';
@@ -30,6 +31,7 @@ export class AppCore {
window.modalManager = modalManager; window.modalManager = modalManager;
window.settingsManager = settingsManager; window.settingsManager = settingsManager;
window.exampleImagesManager = exampleImagesManager; window.exampleImagesManager = exampleImagesManager;
window.helpManager = helpManager;
// Initialize UI components // Initialize UI components
window.headerManager = new HeaderManager(); window.headerManager = new HeaderManager();
@@ -38,6 +40,8 @@ export class AppCore {
// Initialize the example images manager // Initialize the example images manager
exampleImagesManager.initialize(); exampleImagesManager.initialize();
// Initialize the help manager
helpManager.initialize();
// Mark as initialized // Mark as initialized
this.initialized = true; this.initialized = true;
@@ -61,9 +65,6 @@ export class AppCore {
initializePageFeatures() { initializePageFeatures() {
const pageType = this.getPageType(); const pageType = this.getPageType();
// Initialize lazy loading for images on all pages
lazyLoadImages();
// Setup event delegation for lora cards if on the loras page // Setup event delegation for lora cards if on the loras page
if (pageType === 'loras') { if (pageType === 'loras') {
setupLoraCardEventDelegation(); setupLoraCardEventDelegation();
@@ -85,6 +86,3 @@ document.addEventListener('DOMContentLoaded', () => {
// Create and export a singleton instance // Create and export a singleton instance
export const appCore = new AppCore(); export const appCore = new AppCore();
// Export common utilities for global use
export { showToast, lazyLoadImages, initializeInfiniteScroll };

View File

@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
import { LoraContextMenu } from './components/ContextMenu/index.js'; import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js'; import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
// Initialize the LoRA page // Initialize the LoRA page
class LoraPageManager { class LoraPageManager {
@@ -23,6 +24,9 @@ class LoraPageManager {
// Initialize page controls // Initialize page controls
this.pageControls = createPageControls('loras'); this.pageControls = createPageControls('loras');
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this);
// Expose necessary functions to the page that still need global access // Expose necessary functions to the page that still need global access
// These will be refactored in future updates // These will be refactored in future updates
this._exposeRequiredGlobalFunctions(); this._exposeRequiredGlobalFunctions();
@@ -49,6 +53,9 @@ class LoraPageManager {
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax(); window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount(); window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager; window.bulkManager = bulkManager;
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
} }
async initialize() { async initialize() {

View File

@@ -1,6 +1,7 @@
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { showToast, copyToClipboard } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/LoraCard.js'; import { updateCardsForBulkMode } from '../components/LoraCard.js';
import { modalManager } from './ModalManager.js';
export class BulkManager { export class BulkManager {
constructor() { constructor() {
@@ -208,6 +209,131 @@ export class BulkManager {
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`); await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
} }
// Add method to send all selected loras to workflow
async sendAllLorasToWorkflow() {
if (state.selectedLoras.size === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
const loraSyntaxes = [];
const missingLoras = [];
// Process all selected loras using our metadata cache
for (const filepath of state.selectedLoras) {
const metadata = state.loraMetadataCache.get(filepath);
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
const strength = usageTips.strength || 1;
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
} else {
// If we don't have metadata, this is an error case
missingLoras.push(filepath);
}
}
// Handle any loras with missing metadata
if (missingLoras.length > 0) {
console.warn('Missing metadata for some selected loras:', missingLoras);
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
}
if (loraSyntaxes.length === 0) {
showToast('No valid LoRAs to send', 'error');
return;
}
// Send the loras to the workflow
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
}
// Show the bulk delete confirmation modal
showBulkDeleteModal() {
if (state.selectedLoras.size === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
// Update the count in the modal
const countElement = document.getElementById('bulkDeleteCount');
if (countElement) {
countElement.textContent = state.selectedLoras.size;
}
// Show the modal
modalManager.showModal('bulkDeleteModal');
}
// Confirm bulk delete action
async confirmBulkDelete() {
if (state.selectedLoras.size === 0) {
showToast('No LoRAs selected', 'warning');
modalManager.closeModal('bulkDeleteModal');
return;
}
// Close the modal first before showing loading indicator
modalManager.closeModal('bulkDeleteModal');
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Deleting models...');
// Gather all file paths for deletion
const filePaths = Array.from(state.selectedLoras);
// Call the backend API
const response = await fetch('/api/loras/bulk-delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_paths: filePaths
})
});
const result = await response.json();
if (result.success) {
showToast(`Successfully deleted ${result.deleted_count} models`, 'success');
// If virtual scroller exists, update the UI without page reload
if (state.virtualScroller) {
// Remove each deleted item from the virtual scroller
filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
});
// Clear the selection
this.clearSelection();
} else {
// Clear the selection
this.clearSelection();
// Fall back to page reload for non-virtual scroll mode
setTimeout(() => {
window.location.reload();
}, 1500);
}
if (window.modelDuplicatesManager) {
// Update duplicates badge after refresh
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
} else {
showToast(`Error: ${result.error || 'Failed to delete models'}`, 'error');
}
} catch (error) {
console.error('Error during bulk delete:', error);
showToast('Failed to delete models', 'error');
} finally {
// Hide loading indicator
state.loadingManager.hide();
}
}
// Create and show the thumbnail strip of selected LoRAs // Create and show the thumbnail strip of selected LoRAs
toggleThumbnailStrip() { toggleThumbnailStrip() {
// If no items are selected, do nothing // If no items are selected, do nothing

View File

@@ -36,6 +36,14 @@ export class CheckpointDownloadManager {
this.cleanupFolderBrowser(); this.cleanupFolderBrowser();
}); });
this.resetSteps(); this.resetSteps();
// Auto-focus on the URL input
setTimeout(() => {
const urlInput = document.getElementById('checkpointUrl');
if (urlInput) {
urlInput.focus();
}
}, 100); // Small delay to ensure the modal is fully displayed
} }
resetSteps() { resetSteps() {
@@ -178,6 +186,11 @@ export class CheckpointDownloadManager {
`; `;
}).join(''); }).join('');
// Auto-select the version if there's only one
if (this.versions.length === 1 && !this.currentVersion) {
this.selectVersion(this.versions[0].id.toString());
}
// Update Next button state based on initial selection // Update Next button state based on initial selection
this.updateNextButtonState(); this.updateNextButtonState();
} }

View File

@@ -38,6 +38,14 @@ export class DownloadManager {
this.cleanupFolderBrowser(); this.cleanupFolderBrowser();
}); });
this.resetSteps(); this.resetSteps();
// Auto-focus on the URL input
setTimeout(() => {
const urlInput = document.getElementById('loraUrl');
if (urlInput) {
urlInput.focus();
}
}, 100); // Small delay to ensure the modal is fully displayed
} }
resetSteps() { resetSteps() {
@@ -182,6 +190,11 @@ export class DownloadManager {
`; `;
}).join(''); }).join('');
// Auto-select the version if there's only one
if (this.versions.length === 1 && !this.currentVersion) {
this.selectVersion(this.versions[0].id.toString());
}
// Update Next button state based on initial selection // Update Next button state based on initial selection
this.updateNextButtonState(); this.updateNextButtonState();
} }

View File

@@ -11,6 +11,8 @@ class ExampleImagesManager {
this.progressPanel = null; this.progressPanel = null;
this.isProgressPanelCollapsed = false; this.isProgressPanelCollapsed = false;
this.pauseButton = null; // Store reference to the pause button this.pauseButton = null; // Store reference to the pause button
this.isMigrating = false; // Track migration state separately from downloading
this.hasShownCompletionToast = false; // Flag to track if completion toast has been shown
// Initialize download path field and check download status // Initialize download path field and check download status
this.initializePathOptions(); this.initializePathOptions();
@@ -46,6 +48,12 @@ class ExampleImagesManager {
if (collapseBtn) { if (collapseBtn) {
collapseBtn.onclick = () => this.toggleProgressPanel(); collapseBtn.onclick = () => this.toggleProgressPanel();
} }
// Initialize migration button handler
const migrateBtn = document.getElementById('exampleImagesMigrateBtn');
if (migrateBtn) {
migrateBtn.onclick = () => this.handleMigrateButton();
}
} }
// Initialize event listeners for buttons // Initialize event listeners for buttons
@@ -141,6 +149,95 @@ class ExampleImagesManager {
} }
} }
// Method to handle migrate button click
async handleMigrateButton() {
if (this.isDownloading || this.isMigrating) {
if (this.isPaused) {
// If paused, resume
this.resumeDownload();
} else {
showToast('Migration or download already in progress', 'info');
}
return;
}
// Start migration
this.startMigrate();
}
async startMigrate() {
try {
const outputDir = document.getElementById('exampleImagesPath').value || '';
if (!outputDir) {
showToast('Please enter a download location first', 'warning');
return;
}
// Update path in backend settings before starting migration
try {
const pathUpdateResponse = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
example_images_path: outputDir
})
});
if (!pathUpdateResponse.ok) {
throw new Error(`HTTP error! Status: ${pathUpdateResponse.status}`);
}
} catch (error) {
console.error('Failed to update example images path:', error);
}
const pattern = document.getElementById('exampleImagesMigratePattern').value || '{model}.example.{index}.{ext}';
const optimize = document.getElementById('optimizeExampleImages').checked;
const response = await fetch('/api/migrate-example-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
output_dir: outputDir,
pattern: pattern,
optimize: optimize,
model_types: ['lora', 'checkpoint']
})
});
const data = await response.json();
if (data.success) {
this.isDownloading = true;
this.isMigrating = true;
this.isPaused = false;
this.hasShownCompletionToast = false; // Reset toast flag when starting new migration
this.startTime = new Date();
this.updateUI(data.status);
this.showProgressPanel();
this.startProgressUpdates();
// Update button text
const btnTextElement = document.getElementById('exampleDownloadBtnText');
if (btnTextElement) {
btnTextElement.textContent = "Resume";
}
showToast('Example images migration started', 'success');
// Close settings modal
modalManager.closeModal('settingsModal');
} else {
showToast(data.error || 'Failed to start migration', 'error');
}
} catch (error) {
console.error('Failed to start migration:', error);
showToast('Failed to start migration', 'error');
}
}
async checkDownloadStatus() { async checkDownloadStatus() {
try { try {
const response = await fetch('/api/example-images-status'); const response = await fetch('/api/example-images-status');
@@ -224,6 +321,7 @@ class ExampleImagesManager {
if (data.success) { if (data.success) {
this.isDownloading = true; this.isDownloading = true;
this.isPaused = false; this.isPaused = false;
this.hasShownCompletionToast = false; // Reset toast flag when starting new download
this.startTime = new Date(); this.startTime = new Date();
this.updateUI(data.status); this.updateUI(data.status);
this.showProgressPanel(); this.showProgressPanel();
@@ -334,6 +432,7 @@ class ExampleImagesManager {
if (data.success) { if (data.success) {
this.isDownloading = data.is_downloading; this.isDownloading = data.is_downloading;
this.isPaused = data.status.status === 'paused'; this.isPaused = data.status.status === 'paused';
this.isMigrating = data.is_migrating || false;
// Update download button text // Update download button text
this.updateDownloadButtonText(); this.updateDownloadButtonText();
@@ -345,12 +444,19 @@ class ExampleImagesManager {
clearInterval(this.progressUpdateInterval); clearInterval(this.progressUpdateInterval);
this.progressUpdateInterval = null; this.progressUpdateInterval = null;
if (data.status.status === 'completed') { if (data.status.status === 'completed' && !this.hasShownCompletionToast) {
showToast('Example images download completed', 'success'); const actionType = this.isMigrating ? 'migration' : 'download';
showToast(`Example images ${actionType} completed`, 'success');
// Mark as shown to prevent duplicate toasts
this.hasShownCompletionToast = true;
// Reset migration flag
this.isMigrating = false;
// Hide the panel after a delay // Hide the panel after a delay
setTimeout(() => this.hideProgressPanel(), 5000); setTimeout(() => this.hideProgressPanel(), 5000);
} else if (data.status.status === 'error') { } else if (data.status.status === 'error') {
showToast('Example images download failed', 'error'); const actionType = this.isMigrating ? 'migration' : 'download';
showToast(`Example images ${actionType} failed`, 'error');
this.isMigrating = false;
} }
} }
} }
@@ -441,6 +547,19 @@ class ExampleImagesManager {
this.updateMiniProgress(progressPercent); this.updateMiniProgress(progressPercent);
} }
} }
// Update title text
const titleElement = document.querySelector('.progress-panel-title');
if (titleElement) {
const titleIcon = titleElement.querySelector('i');
if (titleIcon) {
titleIcon.className = this.isMigrating ? 'fas fa-file-import' : 'fas fa-images';
}
titleElement.innerHTML =
`<i class="${this.isMigrating ? 'fas fa-file-import' : 'fas fa-images'}"></i> ` +
`${this.isMigrating ? 'Example Images Migration' : 'Example Images Download'}`;
}
} }
// Update the mini progress circle in the pause button // Update the mini progress circle in the pause button
@@ -536,8 +655,10 @@ class ExampleImagesManager {
} }
getStatusText(status) { getStatusText(status) {
const prefix = this.isMigrating ? 'Migrating' : 'Downloading';
switch (status) { switch (status) {
case 'running': return 'Downloading'; case 'running': return this.isMigrating ? 'Migrating' : 'Downloading';
case 'paused': return 'Paused'; case 'paused': return 'Paused';
case 'completed': return 'Completed'; case 'completed': return 'Completed';
case 'error': return 'Error'; case 'error': return 'Error';

View File

@@ -0,0 +1,155 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
/**
* Manages help modal functionality and tutorial update notifications
*/
export class HelpManager {
constructor() {
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0);
this.latestContentTimestamp = 0; // Will be updated from server or config
this.isInitialized = false;
// Default latest content data - could be fetched from server
this.latestVideoData = {
timestamp: new Date('2024-06-09').getTime(), // Default timestamp
walkthrough: {
id: 'hvKw31YpE-U',
title: 'Getting Started with LoRA Manager'
},
playlistUpdated: true
};
}
/**
* Initialize the help manager
*/
initialize() {
if (this.isInitialized) return;
console.log('HelpManager: Initializing...');
// Set up event handlers
this.setupEventListeners();
// Check if we need to show the badge
this.updateHelpBadge();
// Fetch latest video data (could be implemented to fetch from remote source)
this.fetchLatestVideoData();
this.isInitialized = true;
return this;
}
/**
* Set up event listeners for help modal
*/
setupEventListeners() {
// Help toggle button
const helpToggleBtn = document.getElementById('helpToggleBtn');
if (helpToggleBtn) {
helpToggleBtn.addEventListener('click', () => this.openHelpModal());
}
// Help modal tab functionality
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', (event) => {
// Remove active class from all buttons and panes
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
pane.classList.remove('active');
});
// Add active class to clicked button
event.currentTarget.classList.add('active');
// Show corresponding tab content
const tabId = event.currentTarget.getAttribute('data-tab');
document.getElementById(tabId).classList.add('active');
});
});
}
/**
* Open the help modal
*/
openHelpModal() {
// Use modalManager to open the help modal
if (window.modalManager) {
window.modalManager.toggleModal('helpModal');
// Update the last viewed timestamp
this.markContentAsViewed();
// Hide the badge
this.hideHelpBadge();
}
}
/**
* Mark content as viewed by saving current timestamp
*/
markContentAsViewed() {
this.lastViewedTimestamp = Date.now();
setStorageItem('help_last_viewed', this.lastViewedTimestamp);
}
/**
* Fetch latest video data (could be implemented to actually fetch from a remote source)
*/
fetchLatestVideoData() {
// In a real implementation, you'd fetch this from your server
// For now, we'll just use the hardcoded data from constructor
// Update the timestamp with the latest data
this.latestContentTimestamp = this.latestVideoData.timestamp;
// Check again if we need to show the badge with this new data
this.updateHelpBadge();
}
/**
* Update help badge visibility based on timestamps
*/
updateHelpBadge() {
if (this.hasNewContent()) {
this.showHelpBadge();
} else {
this.hideHelpBadge();
}
}
/**
* Check if there's new content the user hasn't seen
*/
hasNewContent() {
// If user has never viewed the help, or the content is newer than last viewed
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp;
}
/**
* Show the help badge
*/
showHelpBadge() {
const helpBadge = document.querySelector('#helpToggleBtn .update-badge');
if (helpBadge) {
helpBadge.classList.add('visible');
}
}
/**
* Hide the help badge
*/
hideHelpBadge() {
const helpBadge = document.querySelector('#helpToggleBtn .update-badge');
if (helpBadge) {
helpBadge.classList.remove('visible');
}
}
}
// Create singleton instance
export const helpManager = new HelpManager();

View File

@@ -58,8 +58,17 @@ export class ImportManager {
this.stepManager.removeInjectedStyles(); this.stepManager.removeInjectedStyles();
}); });
// Verify visibility // Verify visibility and focus on URL input
setTimeout(() => this.ensureModalVisible(), 50); setTimeout(() => {
this.ensureModalVisible();
// Ensure URL option is selected and focus on the input
this.toggleImportMode('url');
const urlInput = document.getElementById('imageUrlInput');
if (urlInput) {
urlInput.focus();
}
}, 50);
} }
resetSteps() { resetSteps() {

View File

@@ -3,6 +3,7 @@ export class ModalManager {
this.modals = new Map(); this.modals = new Map();
this.scrollPosition = 0; this.scrollPosition = 0;
this.currentOpenModal = null; // Track currently open modal this.currentOpenModal = null; // Track currently open modal
this.mouseDownOnBackground = false; // Track if mousedown happened on modal background
} }
initialize() { initialize() {
@@ -44,8 +45,7 @@ export class ModalManager {
onClose: () => { onClose: () => {
this.getModal('checkpointDownloadModal').element.style.display = 'none'; this.getModal('checkpointDownloadModal').element.style.display = 'none';
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
}, }
closeOnOutsideClick: true
}); });
} }
@@ -68,8 +68,7 @@ export class ModalManager {
onClose: () => { onClose: () => {
this.getModal('excludeModal').element.classList.remove('show'); this.getModal('excludeModal').element.classList.remove('show');
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
}, }
closeOnOutsideClick: true
}); });
} }
@@ -93,7 +92,8 @@ export class ModalManager {
onClose: () => { onClose: () => {
this.getModal('settingsModal').element.style.display = 'none'; this.getModal('settingsModal').element.style.display = 'none';
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
} },
closeOnOutsideClick: true
}); });
} }
@@ -117,7 +117,8 @@ export class ModalManager {
onClose: () => { onClose: () => {
this.getModal('supportModal').element.style.display = 'none'; this.getModal('supportModal').element.style.display = 'none';
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
} },
closeOnOutsideClick: true
}); });
} }
@@ -129,7 +130,8 @@ export class ModalManager {
onClose: () => { onClose: () => {
this.getModal('updateModal').element.style.display = 'none'; this.getModal('updateModal').element.style.display = 'none';
document.body.classList.remove('modal-open'); document.body.classList.remove('modal-open');
} },
closeOnOutsideClick: true
}); });
} }
@@ -170,10 +172,66 @@ export class ModalManager {
}); });
} }
// Set up event listeners for modal toggles // Add modelDuplicateDeleteModal registration
const supportToggle = document.getElementById('supportToggleBtn'); const modelDuplicateDeleteModal = document.getElementById('modelDuplicateDeleteModal');
if (supportToggle) { if (modelDuplicateDeleteModal) {
supportToggle.addEventListener('click', () => this.toggleModal('supportModal')); this.registerModal('modelDuplicateDeleteModal', {
element: modelDuplicateDeleteModal,
onClose: () => {
this.getModal('modelDuplicateDeleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add clearCacheModal registration
const clearCacheModal = document.getElementById('clearCacheModal');
if (clearCacheModal) {
this.registerModal('clearCacheModal', {
element: clearCacheModal,
onClose: () => {
this.getModal('clearCacheModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add bulkDeleteModal registration
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
if (bulkDeleteModal) {
this.registerModal('bulkDeleteModal', {
element: bulkDeleteModal,
onClose: () => {
this.getModal('bulkDeleteModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
}
});
}
// Add helpModal registration
const helpModal = document.getElementById('helpModal');
if (helpModal) {
this.registerModal('helpModal', {
element: helpModal,
onClose: () => {
this.getModal('helpModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add relinkCivitaiModal registration
const relinkCivitaiModal = document.getElementById('relinkCivitaiModal');
if (relinkCivitaiModal) {
this.registerModal('relinkCivitaiModal', {
element: relinkCivitaiModal,
onClose: () => {
this.getModal('relinkCivitaiModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
} }
document.addEventListener('keydown', this.boundHandleEscape); document.addEventListener('keydown', this.boundHandleEscape);
@@ -189,10 +247,27 @@ export class ModalManager {
// Add click outside handler if specified in config // Add click outside handler if specified in config
if (config.closeOnOutsideClick) { if (config.closeOnOutsideClick) {
config.element.addEventListener('click', (e) => { // Track mousedown on modal background
config.element.addEventListener('mousedown', (e) => {
if (e.target === config.element) { if (e.target === config.element) {
this.mouseDownOnBackground = true;
} else {
this.mouseDownOnBackground = false;
}
});
// Only close if mouseup is also on the background
config.element.addEventListener('mouseup', (e) => {
if (e.target === config.element && this.mouseDownOnBackground) {
this.closeModal(id); this.closeModal(id);
} }
// Reset flag regardless of target
this.mouseDownOnBackground = false;
});
// Cancel the flag if mouse leaves the document entirely
document.addEventListener('mouseleave', () => {
this.mouseDownOnBackground = false;
}); });
} }
} }
@@ -233,10 +308,17 @@ export class ModalManager {
// Store current scroll position before showing modal // Store current scroll position before showing modal
this.scrollPosition = window.scrollY; this.scrollPosition = window.scrollY;
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal') { if (
modal.element.classList.add('show'); id === "deleteModal" ||
id === "excludeModal" ||
id === "duplicateDeleteModal" ||
id === "modelDuplicateDeleteModal" ||
id === "clearCacheModal" ||
id === "bulkDeleteModal"
) {
modal.element.classList.add("show");
} else { } else {
modal.element.style.display = 'block'; modal.element.style.display = "block";
} }
modal.isOpen = true; modal.isOpen = true;

View File

@@ -31,6 +31,26 @@ export class SettingsManager {
if (state.global.settings.compactMode === undefined) { if (state.global.settings.compactMode === undefined) {
state.global.settings.compactMode = false; state.global.settings.compactMode = false;
} }
// Set default for optimizeExampleImages if undefined
if (state.global.settings.optimizeExampleImages === undefined) {
state.global.settings.optimizeExampleImages = true;
}
// Set default for useCentralizedExamples if undefined
if (state.global.settings.useCentralizedExamples === undefined) {
state.global.settings.useCentralizedExamples = true;
}
// Convert old boolean compactMode to new displayDensity string
if (typeof state.global.settings.displayDensity === 'undefined') {
if (state.global.settings.compactMode === true) {
state.global.settings.displayDensity = 'compact';
} else {
state.global.settings.displayDensity = 'default';
}
// We can delete the old setting, but keeping it for backwards compatibility
}
} }
initialize() { initialize() {
@@ -82,10 +102,24 @@ export class SettingsManager {
autoplayOnHoverCheckbox.checked = state.global.settings.autoplayOnHover || false; autoplayOnHoverCheckbox.checked = state.global.settings.autoplayOnHover || false;
} }
// Set compact mode setting // Set display density setting
const compactModeCheckbox = document.getElementById('compactMode'); const displayDensitySelect = document.getElementById('displayDensity');
if (compactModeCheckbox) { if (displayDensitySelect) {
compactModeCheckbox.checked = state.global.settings.compactMode || false; displayDensitySelect.value = state.global.settings.displayDensity || 'default';
}
// Set optimize example images setting
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
if (optimizeExampleImagesCheckbox) {
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
}
// Set centralized examples setting
const useCentralizedExamplesCheckbox = document.getElementById('useCentralizedExamples');
if (useCentralizedExamplesCheckbox) {
useCentralizedExamplesCheckbox.checked = state.global.settings.useCentralizedExamples !== false;
// Update dependent controls
this.updateExamplesControlsState();
} }
// Load default lora root // Load default lora root
@@ -162,6 +196,10 @@ export class SettingsManager {
state.global.settings.optimizeExampleImages = value; state.global.settings.optimizeExampleImages = value;
} else if (settingKey === 'compact_mode') { } else if (settingKey === 'compact_mode') {
state.global.settings.compactMode = value; state.global.settings.compactMode = value;
} else if (settingKey === 'use_centralized_examples') {
state.global.settings.useCentralizedExamples = value;
// Update dependent controls state
this.updateExamplesControlsState();
} else { } else {
// For any other settings that might be added in the future // For any other settings that might be added in the future
state.global.settings[settingKey] = value; state.global.settings[settingKey] = value;
@@ -172,7 +210,7 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // For backend settings, make API call
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover'].includes(settingKey)) { if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover', 'optimize_example_images', 'use_centralized_examples'].includes(settingKey)) {
const payload = {}; const payload = {};
payload[settingKey] = value; payload[settingKey] = value;
@@ -219,6 +257,11 @@ export class SettingsManager {
// Update frontend state // Update frontend state
if (settingKey === 'default_lora_root') { if (settingKey === 'default_lora_root') {
state.global.settings.default_loras_root = value; state.global.settings.default_loras_root = value;
} else if (settingKey === 'display_density') {
state.global.settings.displayDensity = value;
// Also update compactMode for backwards compatibility
state.global.settings.compactMode = (value !== 'default');
} else { } else {
// For any other settings that might be added in the future // For any other settings that might be added in the future
state.global.settings[settingKey] = value; state.global.settings[settingKey] = value;
@@ -229,22 +272,38 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // For backend settings, make API call
const payload = {}; if (settingKey === 'default_lora_root') {
payload[settingKey] = value; const payload = {};
payload[settingKey] = value;
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to save setting'); throw new Error('Failed to save setting');
}
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
} }
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success'); // Apply frontend settings immediately
this.applyFrontendSettings();
// Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
let densityName = "Default";
if (value === 'medium') densityName = "Medium";
if (value === 'compact') densityName = "Compact";
showToast(`Display Density set to ${densityName}`, 'success');
}
} catch (error) { } catch (error) {
showToast('Failed to save setting: ' + error.message, 'error'); showToast('Failed to save setting: ' + error.message, 'error');
@@ -310,6 +369,37 @@ export class SettingsManager {
} }
} }
confirmClearCache() {
// Show confirmation modal
modalManager.showModal('clearCacheModal');
}
async executeClearCache() {
try {
// Call the API endpoint to clear cache files
const response = await fetch('/api/clear-cache', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const result = await response.json();
if (result.success) {
showToast('Cache files have been cleared successfully. Cache will rebuild on next action.', 'success');
} else {
showToast(`Failed to clear cache: ${result.error}`, 'error');
}
// Close the confirmation modal
modalManager.closeModal('clearCacheModal');
} catch (error) {
showToast(`Error clearing cache: ${error.message}`, 'error');
modalManager.closeModal('clearCacheModal');
}
}
async reloadContent() { async reloadContent() {
if (this.currentPage === 'loras') { if (this.currentPage === 'loras') {
// Reload the loras without updating folders // Reload the loras without updating folders
@@ -329,6 +419,7 @@ export class SettingsManager {
const showOnlySFW = document.getElementById('showOnlySFW').checked; const showOnlySFW = document.getElementById('showOnlySFW').checked;
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value; const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
const autoplayOnHover = document.getElementById('autoplayOnHover').checked; const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
const optimizeExampleImages = document.getElementById('optimizeExampleImages').checked;
// Get backend settings // Get backend settings
const apiKey = document.getElementById('civitaiApiKey').value; const apiKey = document.getElementById('civitaiApiKey').value;
@@ -338,6 +429,7 @@ export class SettingsManager {
state.global.settings.show_only_sfw = showOnlySFW; state.global.settings.show_only_sfw = showOnlySFW;
state.global.settings.default_loras_root = defaultLoraRoot; state.global.settings.default_loras_root = defaultLoraRoot;
state.global.settings.autoplayOnHover = autoplayOnHover; state.global.settings.autoplayOnHover = autoplayOnHover;
state.global.settings.optimizeExampleImages = optimizeExampleImages;
// Save settings to localStorage // Save settings to localStorage
setStorageItem('settings', state.global.settings); setStorageItem('settings', state.global.settings);
@@ -351,7 +443,8 @@ export class SettingsManager {
}, },
body: JSON.stringify({ body: JSON.stringify({
civitai_api_key: apiKey, civitai_api_key: apiKey,
show_only_sfw: showOnlySFW show_only_sfw: showOnlySFW,
optimize_example_images: optimizeExampleImages
}) })
}); });
@@ -419,8 +512,53 @@ export class SettingsManager {
videoParent.replaceChild(videoClone, video); videoParent.replaceChild(videoClone, video);
}); });
// For show_only_sfw, there's no immediate action needed as it affects content loading // Apply display density class to grid
// The setting will take effect on next reload const grid = document.querySelector('.card-grid');
if (grid) {
const density = state.global.settings.displayDensity || 'default';
// Remove all density classes first
grid.classList.remove('default-density', 'medium-density', 'compact-density');
// Add the appropriate density class
grid.classList.add(`${density}-density`);
}
// Apply centralized examples toggle state
this.updateExamplesControlsState();
}
// Add new method to update example control states
updateExamplesControlsState() {
const useCentralized = state.global.settings.useCentralizedExamples !== false;
// Find all controls that require centralized mode
const exampleSections = document.querySelectorAll('[data-requires-centralized="true"]');
exampleSections.forEach(section => {
// Enable/disable all inputs and buttons in the section
const controls = section.querySelectorAll('input, button, select');
controls.forEach(control => {
control.disabled = !useCentralized;
// Add/remove disabled class for styling
if (control.classList.contains('primary-btn') || control.classList.contains('secondary-btn')) {
if (!useCentralized) {
control.classList.add('disabled');
} else {
control.classList.remove('disabled');
}
}
});
// Visually show the section as disabled
if (!useCentralized) {
section.style.opacity = '0.6';
section.style.pointerEvents = 'none';
} else {
section.style.opacity = '';
section.style.pointerEvents = '';
}
});
} }
} }

View File

@@ -119,7 +119,6 @@ export class UpdateService {
updateBadgeVisibility() { updateBadgeVisibility() {
const updateToggle = document.querySelector('.update-toggle'); const updateToggle = document.querySelector('.update-toggle');
const updateBadge = document.querySelector('.update-toggle .update-badge'); const updateBadge = document.querySelector('.update-toggle .update-badge');
const cornerBadge = document.querySelector('.corner-badge');
if (updateToggle) { if (updateToggle) {
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
@@ -134,11 +133,6 @@ export class UpdateService {
updateBadge.classList.toggle('hidden', !shouldShow); updateBadge.classList.toggle('hidden', !shouldShow);
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible"); console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
} }
if (cornerBadge) {
cornerBadge.classList.toggle('hidden', !shouldShow);
console.log("Corner badge visibility:", !shouldShow ? "hidden" : "visible");
}
} }
updateModalContent() { updateModalContent() {

View File

@@ -6,8 +6,8 @@ import { getCurrentPageState, state } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js'; import { DuplicatesManager } from './components/DuplicatesManager.js';
import { initializeInfiniteScroll, refreshVirtualScroll } from './utils/infiniteScroll.js'; import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { resetAndReload, refreshRecipes } from './api/recipeApi.js'; import { refreshRecipes } from './api/recipeApi.js';
class RecipeManager { class RecipeManager {
constructor() { constructor() {
@@ -228,11 +228,6 @@ class RecipeManager {
} }
this.duplicatesManager.exitDuplicateMode(); this.duplicatesManager.exitDuplicateMode();
// Use a small delay before initializing to ensure DOM is ready
setTimeout(() => {
initializeInfiniteScroll('recipes');
}, 100);
} }
} }

View File

@@ -44,6 +44,7 @@ export const state = {
selectedLoras: new Set(), selectedLoras: new Set(),
loraMetadataCache: new Map(), loraMetadataCache: new Map(),
showFavoritesOnly: false, showFavoritesOnly: false,
duplicatesMode: false,
}, },
recipes: { recipes: {
@@ -86,6 +87,7 @@ export const state = {
tags: [] tags: []
}, },
showFavoritesOnly: false, showFavoritesOnly: false,
duplicatesMode: false,
} }
}, },

View File

@@ -15,6 +15,10 @@ export class VirtualScroller {
this.itemAspectRatio = 896/1152; // Aspect ratio of cards this.itemAspectRatio = 896/1152; // Aspect ratio of cards
this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px) this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
// Add container padding properties
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
// Add data windowing enable/disable flag // Add data windowing enable/disable flag
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false; this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
@@ -74,11 +78,20 @@ export class VirtualScroller {
this.gridElement.style.position = 'relative'; this.gridElement.style.position = 'relative';
this.gridElement.style.minHeight = '0'; this.gridElement.style.minHeight = '0';
// Apply padding directly to ensure consistency
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
// Place the spacer inside the grid container // Place the spacer inside the grid container
this.gridElement.appendChild(this.spacerElement); this.gridElement.appendChild(this.spacerElement);
} }
calculateLayout() { calculateLayout() {
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
return false
}
// Get container width and style information // Get container width and style information
const containerWidth = this.containerElement.clientWidth; const containerWidth = this.containerElement.clientWidth;
const containerStyle = getComputedStyle(this.containerElement); const containerStyle = getComputedStyle(this.containerElement);
@@ -88,22 +101,40 @@ export class VirtualScroller {
// Calculate available content width (excluding padding) // Calculate available content width (excluding padding)
const availableContentWidth = containerWidth - paddingLeft - paddingRight; const availableContentWidth = containerWidth - paddingLeft - paddingRight;
// Get compact mode setting // Get display density setting
const compactMode = state.global.settings?.compactMode || false; const displayDensity = state.global.settings?.displayDensity || 'default';
// Set exact column counts and grid widths to match CSS container widths // Set exact column counts and grid widths to match CSS container widths
let maxColumns, maxGridWidth; let maxColumns, maxGridWidth;
// Match exact column counts and CSS container width values // Match exact column counts and CSS container width values based on density
if (window.innerWidth >= 3000) { // 4K if (window.innerWidth >= 3000) { // 4K
maxColumns = compactMode ? 10 : 8; if (displayDensity === 'default') {
maxColumns = 8;
} else if (displayDensity === 'medium') {
maxColumns = 9;
} else { // compact
maxColumns = 10;
}
maxGridWidth = 2400; // Match exact CSS container width for 4K maxGridWidth = 2400; // Match exact CSS container width for 4K
} else if (window.innerWidth >= 2000) { // 2K/1440p } else if (window.innerWidth >= 2000) { // 2K/1440p
maxColumns = compactMode ? 8 : 6; if (displayDensity === 'default') {
maxColumns = 6;
} else if (displayDensity === 'medium') {
maxColumns = 7;
} else { // compact
maxColumns = 8;
}
maxGridWidth = 1800; // Match exact CSS container width for 2K maxGridWidth = 1800; // Match exact CSS container width for 2K
} else { } else {
// 1080p // 1080p
maxColumns = compactMode ? 7 : 5; if (displayDensity === 'default') {
maxColumns = 5;
} else if (displayDensity === 'medium') {
maxColumns = 6;
} else { // compact
maxColumns = 7;
}
maxGridWidth = 1400; // Match exact CSS container width for 1080p maxGridWidth = 1400; // Match exact CSS container width for 1080p
} }
@@ -145,7 +176,7 @@ export class VirtualScroller {
leftOffset: this.leftOffset, leftOffset: this.leftOffset,
paddingLeft, paddingLeft,
paddingRight, paddingRight,
compactMode, displayDensity,
maxColumns, maxColumns,
baseCardWidth, baseCardWidth,
rowGap: this.rowGap rowGap: this.rowGap
@@ -154,12 +185,9 @@ export class VirtualScroller {
// Update grid element max-width to match available width // Update grid element max-width to match available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`; this.gridElement.style.maxWidth = `${actualGridWidth}px`;
// Add or remove compact-mode class for style adjustments // Add or remove density classes for style adjustments
if (compactMode) { this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
this.gridElement.classList.add('compact-mode'); this.gridElement.classList.add(`${displayDensity}-density`);
} else {
this.gridElement.classList.remove('compact-mode');
}
// Update spacer height // Update spacer height
this.updateSpacerHeight(); this.updateSpacerHeight();
@@ -321,8 +349,11 @@ export class VirtualScroller {
// Add row gaps to the total height calculation // Add row gaps to the total height calculation
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap; const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
// Include container padding in the total height
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
// Update spacer height to represent all items // Update spacer height to represent all items
this.spacerElement.style.height = `${totalHeight}px`; this.spacerElement.style.height = `${spacerHeight}px`;
} }
getVisibleRange() { getVisibleRange() {
@@ -450,7 +481,8 @@ export class VirtualScroller {
const col = index % this.columnsCount; const col = index % this.columnsCount;
// Calculate precise positions with row gap included // Calculate precise positions with row gap included
const topPos = row * (this.itemHeight + this.rowGap); // Add the top padding to account for container padding
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
// Position correctly with leftOffset (no need to add padding as absolute // Position correctly with leftOffset (no need to add padding as absolute
// positioning is already relative to the padding edge of the container) // positioning is already relative to the padding edge of the container)
@@ -734,8 +766,24 @@ export class VirtualScroller {
// Reattach scroll event listener // Reattach scroll event listener
this.scrollContainer.addEventListener('scroll', this.scrollHandler); this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Show the spacer element // Check if spacer element exists in the DOM, if not, recreate it
if (this.spacerElement) { if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
console.log('Spacer element not found in DOM, recreating it');
// Create a new spacer element
this.spacerElement = document.createElement('div');
this.spacerElement.className = 'virtual-scroll-spacer';
this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px';
this.spacerElement.style.pointerEvents = 'none';
// Append it to the grid
this.gridElement.appendChild(this.spacerElement);
// Update the spacer height
this.updateSpacerHeight();
} else {
// Show the spacer element if it exists
this.spacerElement.style.display = 'block'; this.spacerElement.style.display = 'block';
} }
@@ -804,4 +852,103 @@ export class VirtualScroller {
console.log(`Removed item with file path ${filePath} from virtual scroller data`); console.log(`Removed item with file path ${filePath} from virtual scroller data`);
return true; return true;
} }
// Add keyboard navigation methods
handlePageUpDown(direction) {
// Prevent duplicate animations by checking last trigger time
const now = Date.now();
if (this.lastPageNavTime && now - this.lastPageNavTime < 300) {
return; // Ignore rapid repeated triggers
}
this.lastPageNavTime = now;
const scrollContainer = this.scrollContainer;
const viewportHeight = scrollContainer.clientHeight;
// Calculate scroll distance (one viewport minus 10% overlap for context)
const scrollDistance = viewportHeight * 0.9;
// Determine the new scroll position
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
// Remove any existing transition indicators
this.removeExistingTransitionIndicator();
// Scroll to the new position with smooth animation
scrollContainer.scrollTo({
top: newScrollTop,
behavior: 'smooth'
});
// Page transition indicator removed
// this.showTransitionIndicator();
// Force render after scrolling
setTimeout(() => this.renderItems(), 100);
setTimeout(() => this.renderItems(), 300);
}
// Helper to remove existing indicators
removeExistingTransitionIndicator() {
const existingIndicator = document.querySelector('.page-transition-indicator');
if (existingIndicator) {
existingIndicator.remove();
}
}
scrollToTop() {
this.removeExistingTransitionIndicator();
// Page transition indicator removed
// this.showTransitionIndicator();
this.scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
});
// Force render after scrolling
setTimeout(() => this.renderItems(), 100);
}
scrollToBottom() {
this.removeExistingTransitionIndicator();
// Page transition indicator removed
// this.showTransitionIndicator();
// Start loading all remaining pages to ensure content is available
this.loadRemainingPages().then(() => {
// After loading all content, scroll to the very bottom
const maxScroll = this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight;
this.scrollContainer.scrollTo({
top: maxScroll,
behavior: 'smooth'
});
});
}
// New method to load all remaining pages
async loadRemainingPages() {
// If we're already at the end or loading, don't proceed
if (!this.hasMore || this.isLoading) return;
console.log('Loading all remaining pages for End key navigation...');
// Keep loading pages until we reach the end
while (this.hasMore && !this.isLoading) {
await this.loadMoreItems();
// Force render after each page load
this.renderItems();
// Small delay to prevent overwhelming the browser
await new Promise(resolve => setTimeout(resolve, 50));
}
console.log('Finished loading all pages');
// Final render to ensure all content is displayed
this.renderItems();
}
} }

View File

@@ -10,3 +10,14 @@ export function formatFileSize(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(1024)); const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i]; return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
} }
/**
* Convert timestamp to human readable date string
* @param {number} modified - Timestamp in seconds
* @returns {string} Formatted date string
*/
export function formatDate(modified) {
if (!modified) return '';
const date = new Date(modified * 1000);
return date.toLocaleString();
}

View File

@@ -122,6 +122,8 @@ async function initializeVirtualScroll(pageType) {
fetchItemsFn: fetchDataFn, fetchItemsFn: fetchDataFn,
pageSize: 100, pageSize: 100,
rowGap: 20, rowGap: 20,
containerPaddingTop: 4,
containerPaddingBottom: 4,
enableDataWindowing: false // Explicitly set to false to disable data windowing enableDataWindowing: false // Explicitly set to false to disable data windowing
}); });
@@ -131,6 +133,9 @@ async function initializeVirtualScroll(pageType) {
// Add grid class for CSS styling // Add grid class for CSS styling
grid.classList.add('virtual-scroll'); grid.classList.add('virtual-scroll');
// Setup keyboard navigation
setupKeyboardNavigation();
} catch (error) { } catch (error) {
console.error(`Error initializing virtual scroller for ${pageType}:`, error); console.error(`Error initializing virtual scroller for ${pageType}:`, error);
showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error'); showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error');
@@ -145,6 +150,61 @@ async function initializeVirtualScroll(pageType) {
} }
} }
// Add keyboard navigation setup function
function setupKeyboardNavigation() {
// Keep track of the last keypress time to prevent multiple rapid triggers
let lastKeyTime = 0;
const keyDelay = 300; // ms between allowed keypresses
// Store the event listener reference so we can remove it later if needed
const keyboardNavHandler = (event) => {
// Only handle keyboard events when not in form elements
if (event.target.matches('input, textarea, select')) return;
// Prevent rapid keypresses
const now = Date.now();
if (now - lastKeyTime < keyDelay) return;
lastKeyTime = now;
// Handle navigation keys
if (event.key === 'PageUp') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.handlePageUpDown('up');
}
} else if (event.key === 'PageDown') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.handlePageUpDown('down');
}
} else if (event.key === 'Home') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.scrollToTop();
}
} else if (event.key === 'End') {
event.preventDefault();
if (state.virtualScroller) {
state.virtualScroller.scrollToBottom();
}
}
};
// Add the event listener
document.addEventListener('keydown', keyboardNavHandler);
// Store the handler in state for potential cleanup
state.keyboardNavHandler = keyboardNavHandler;
}
// Add cleanup function to remove keyboard navigation when needed
export function cleanupKeyboardNavigation() {
if (state.keyboardNavHandler) {
document.removeEventListener('keydown', state.keyboardNavHandler);
state.keyboardNavHandler = null;
}
}
// Export a method to refresh the virtual scroller when filters change // Export a method to refresh the virtual scroller when filters change
export function refreshVirtualScroll() { export function refreshVirtualScroll() {
if (state.virtualScroller) { if (state.virtualScroller) {

View File

@@ -37,6 +37,10 @@ export async function confirmDelete() {
} }
closeDeleteModal(); closeDeleteModal();
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
} catch (error) { } catch (error) {
console.error('Error deleting model:', error); console.error('Error deleting model:', error);
alert(`Error deleting model: ${error}`); alert(`Error deleting model: ${error}`);
@@ -86,6 +90,10 @@ export async function confirmExclude() {
} }
closeExcludeModal(); closeExcludeModal();
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
} catch (error) { } catch (error) {
console.error('Error excluding model:', error); console.error('Error excluding model:', error);
} }

View File

@@ -1,6 +1,7 @@
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js'; import { resetAndReload } from '../api/loraApi.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js'; import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NSFW_LEVELS } from './constants.js';
/** /**
* Utility function to copy text to clipboard with fallback for older browsers * Utility function to copy text to clipboard with fallback for older browsers
@@ -91,19 +92,6 @@ export function showToast(message, type = 'info') {
}); });
} }
export function lazyLoadImages() {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.target.dataset.src) {
entry.target.src = entry.target.dataset.src;
observer.unobserve(entry.target);
}
});
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
}
export function restoreFolderFilter() { export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder'); const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`); const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
@@ -352,3 +340,622 @@ export function getNSFWLevelName(level) {
if (level >= 1) return 'PG'; if (level >= 1) return 'PG';
return 'Unknown'; return 'Unknown';
} }
/**
* Sends LoRA syntax to the active ComfyUI workflow
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs (true) or append (false)
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
* @returns {Promise<boolean>} - Whether the operation was successful
*/
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
try {
let loraNodes = [];
let isDesktopMode = false;
// Get the current workflow from localStorage
const workflowData = localStorage.getItem('workflow');
if (workflowData) {
// Web browser mode - extract node IDs from workflow
const workflow = JSON.parse(workflowData);
// Find all Lora Loader (LoraManager) nodes
if (workflow.nodes && Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
if (node.type === "Lora Loader (LoraManager)") {
loraNodes.push(node.id);
}
}
}
if (loraNodes.length === 0) {
showToast('No Lora Loader nodes found in the workflow', 'warning');
return false;
}
} else {
// ComfyUI Desktop mode - don't specify node IDs and let backend handle it
isDesktopMode = true;
}
// Call the backend API to update the lora code
const response = await fetch('/api/update-lora-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
node_ids: isDesktopMode ? undefined : loraNodes,
lora_code: loraSyntax,
mode: replaceMode ? 'replace' : 'append'
})
});
const result = await response.json();
if (result.success) {
// Use different toast messages based on syntax type
if (syntaxType === 'recipe') {
showToast(`Recipe ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
} else {
showToast(`LoRA ${replaceMode ? 'replaced' : 'added'} to workflow`, 'success');
}
return true;
} else {
showToast(result.error || `Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
return false;
}
} catch (error) {
console.error('Failed to send to workflow:', error);
showToast(`Failed to send ${syntaxType === 'recipe' ? 'recipe' : 'LoRA'} to workflow`, 'error');
return false;
}
}
/**
* Opens the example images folder for a specific model
* @param {string} modelHash - The SHA256 hash of the model
*/
export async function openExampleImagesFolder(modelHash) {
try {
const response = await fetch('/api/open-example-images-folder', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_hash: modelHash
})
});
const result = await response.json();
if (result.success) {
showToast('Opening example images folder', 'success');
return true;
} else {
showToast(result.error || 'Failed to open example images folder', 'error');
return false;
}
} catch (error) {
console.error('Failed to open example images folder:', error);
showToast('Failed to open example images folder', 'error');
return false;
}
}
/**
* Gets local URLs for example images with primary and fallback options
* @param {Object} img - Image object
* @param {number} index - Image index
* @param {string} modelHash - Model hash
* @returns {Object} - Object with primary and fallback URLs
*/
export function getLocalExampleImageUrl(img, index, modelHash) {
if (!modelHash) return { primary: null, fallback: null };
// Get remote extension
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
// If it's a video (mp4), use that extension with no fallback
if (remoteExt === 'mp4') {
const videoUrl = `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
return { primary: videoUrl, fallback: null };
}
// For images, prepare both possible formats
const basePath = `/example_images_static/${modelHash}/image_${index + 1}`;
const webpUrl = `${basePath}.webp`;
const originalExtUrl = remoteExt ? `${basePath}.${remoteExt}` : `${basePath}.jpg`;
// Check if optimization is enabled (defaults to true)
const optimizeImages = state.settings.optimizeExampleImages !== false;
// Return primary and fallback URLs based on current settings
return {
primary: optimizeImages ? webpUrl : originalExtUrl,
fallback: optimizeImages ? originalExtUrl : webpUrl
};
}
/**
* Try to load local image first, fall back to remote if local fails
* @param {HTMLImageElement} imgElement - The image element to update
* @param {Object} urls - Object with local URLs {primary, fallback} and remote URL
*/
export function tryLocalImageOrFallbackToRemote(imgElement, urls) {
const { primary: localUrl, fallback: fallbackUrl } = urls.local || {};
const remoteUrl = urls.remote;
// If no local options, use remote directly
if (!localUrl) {
imgElement.src = remoteUrl;
return;
}
// Try primary local URL
const testImg = new Image();
testImg.onload = () => {
// Primary local image loaded successfully
imgElement.src = localUrl;
};
testImg.onerror = () => {
// Try fallback URL if available
if (fallbackUrl) {
const fallbackImg = new Image();
fallbackImg.onload = () => {
imgElement.src = fallbackUrl;
};
fallbackImg.onerror = () => {
// Both local options failed, use remote
imgElement.src = remoteUrl;
};
fallbackImg.src = fallbackUrl;
} else {
// No fallback, use remote
imgElement.src = remoteUrl;
}
};
testImg.src = localUrl;
}
/**
* Try to load local video first, fall back to remote if local fails
* @param {HTMLVideoElement} videoElement - The video element to update
* @param {Object} urls - Object with local URLs {primary} and remote URL
*/
export function tryLocalVideoOrFallbackToRemote(videoElement, urls) {
const { primary: localUrl } = urls.local || {};
const remoteUrl = urls.remote;
// Only try local if we have a local path
if (localUrl) {
// Try to fetch local file headers to see if it exists
fetch(localUrl, { method: 'HEAD' })
.then(response => {
if (response.ok) {
// Local video exists, use it
videoElement.src = localUrl;
const source = videoElement.querySelector('source');
if (source) source.src = localUrl;
} else {
// Local video doesn't exist, use remote
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
}
videoElement.load();
})
.catch(() => {
// Error fetching, use remote
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
videoElement.load();
});
} else {
// No local path, use remote directly
videoElement.src = remoteUrl;
const source = videoElement.querySelector('source');
if (source) source.src = remoteUrl;
videoElement.load();
}
}
/**
* Initialize lazy loading for images and videos in a container
* @param {HTMLElement} container - The container with lazy-loadable elements
*/
export function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
// Get URLs from data attributes
const localUrls = {
primary: element.dataset.localSrc || null,
fallback: element.dataset.localFallbackSrc || null
};
const remoteUrl = element.dataset.remoteSrc;
const urls = {
local: localUrls,
remote: remoteUrl
};
// Check if element is a video or image
if (element.tagName.toLowerCase() === 'video') {
tryLocalVideoOrFallbackToRemote(element, urls);
} else {
tryLocalImageOrFallbackToRemote(element, urls);
}
element.classList.remove('lazy');
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
lazyLoad(entry.target);
observer.unobserve(entry.target);
}
});
});
lazyElements.forEach(element => observer.observe(element));
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @returns {Object} - Rect with left, top, right, bottom coordinates
*/
export function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
// Get natural dimensions of the media
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
if (!naturalWidth || !naturalHeight) {
// Fallback if dimensions cannot be determined
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
}
// Calculate aspect ratios
const containerRatio = containerWidth / containerHeight;
const mediaRatio = naturalWidth / naturalHeight;
let renderedWidth, renderedHeight, left = 0, top = 0;
// Apply object-fit: contain logic
if (containerRatio > mediaRatio) {
// Container is wider than media - will have empty space on sides
renderedHeight = containerHeight;
renderedWidth = renderedHeight * mediaRatio;
left = (containerWidth - renderedWidth) / 2;
} else {
// Container is taller than media - will have empty space top/bottom
renderedWidth = containerWidth;
renderedHeight = renderedWidth / mediaRatio;
top = (containerHeight - renderedHeight) / 2;
}
return {
left,
top,
right: left + renderedWidth,
bottom: top + renderedHeight
};
}
/**
* Initialize metadata panel interaction handlers
* @param {HTMLElement} container - Container element with media wrappers
*/
export function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
const mediaElement = wrapper.querySelector('img, video');
if (!metadataPanel || !mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
}
});
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt buttons
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();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
});
// Prevent panel scroll from causing modal scroll
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 });
});
}
/**
* Initialize NSFW content blur toggle handlers
* @param {HTMLElement} container - Container element with media wrappers
*/
export function initNsfwBlurHandlers(container) {
// Handle toggle blur buttons
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
toggleButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
const isBlurred = media.classList.toggle('blurred');
const icon = btn.querySelector('i');
// Update the icon based on blur state
if (isBlurred) {
icon.className = 'fas fa-eye';
} else {
icon.className = 'fas fa-eye-slash';
}
// Toggle the overlay visibility
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
});
// Handle "Show" buttons in overlays
const showButtons = container.querySelectorAll('.show-content-btn');
showButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wrapper = btn.closest('.media-wrapper');
const media = wrapper.querySelector('img, video');
media.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = wrapper.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
});
}
/**
* Toggle showcase expansion
* @param {HTMLElement} element - The scroll indicator element
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed');
if (isCollapsed) {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initLazyLoading(carousel);
// 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);
}
}
}
/**
* Set up showcase scroll functionality
* @param {string} modalId - ID of the modal element
*/
export function setupShowcaseScroll(modalId) {
// Listen for wheel events
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false });
// Use MutationObserver to set up back-to-top button when modal content is added
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const modal = document.getElementById(modalId);
if (modal && modal.querySelector('.modal-content')) {
setupBackToTopButton(modal.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(`#${modalId} .modal-content`);
if (modalContent) {
setupBackToTopButton(modalContent);
}
}
/**
* Set up back-to-top button
* @param {HTMLElement} modalContent - Modal content element
*/
export 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'));
}
/**
* Scroll to top of modal content
* @param {HTMLElement} button - Back to top button element
*/
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
/**
* Get example image files for a specific model from the backend
* @param {string} modelHash - The model's hash
* @returns {Promise<Array>} Array of file objects with path and metadata
*/
export async function getExampleImageFiles(modelHash) {
try {
const response = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
const result = await response.json();
if (result.success) {
return result.files;
} else {
console.error('Failed to get example image files:', result.error);
return [];
}
} catch (error) {
console.error('Error fetching example image files:', error);
return [];
}
}

View File

@@ -18,8 +18,10 @@
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> --> <!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div> <div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div>
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div> <div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div>
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div> <div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
<div class="context-menu-item" data-action="preview"><i class="fas fa-image"></i> Replace Preview</div> <div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div> <div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
<div class="context-menu-separator"></div> <div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> Move to Folder</div> <div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> Move to Folder</div>
@@ -30,6 +32,28 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
<div class="banner-content">
<i class="fas fa-exclamation-triangle"></i>
<span id="duplicatesCount">Found 0 duplicate groups</span>
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
<div class="banner-actions">
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
</button>
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
<i class="fas fa-times"></i> Exit Mode
</button>
</div>
</div>
<div class="help-tooltip" id="duplicatesHelpTooltip">
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
</div>
</div>
<!-- Checkpoint cards container --> <!-- Checkpoint cards container -->
<div class="card-grid" id="checkpointGrid"> <div class="card-grid" id="checkpointGrid">
<!-- Cards will be dynamically inserted here --> <!-- Cards will be dynamically inserted here -->

View File

@@ -8,10 +8,22 @@
<div class="context-menu-item" data-action="refresh-metadata"> <div class="context-menu-item" data-action="refresh-metadata">
<i class="fas fa-sync"></i> Refresh Civitai Data <i class="fas fa-sync"></i> Refresh Civitai Data
</div> </div>
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-link"></i> Re-link to Civitai
</div>
<div class="context-menu-item" data-action="copyname"> <div class="context-menu-item" data-action="copyname">
<i class="fas fa-copy"></i> Copy LoRA Syntax <i class="fas fa-copy"></i> Copy LoRA Syntax
</div> </div>
<div class="context-menu-item" data-action="sendappend">
<i class="fas fa-paper-plane"></i> Send to Workflow (Append)
</div>
<div class="context-menu-item" data-action="sendreplace">
<i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)
</div>
<div class="context-menu-item" data-action="preview"> <div class="context-menu-item" data-action="preview">
<i class="fas fa-folder-open"></i> Open Examples Folder
</div>
<div class="context-menu-item" data-action="replace-preview">
<i class="fas fa-image"></i> Replace Preview <i class="fas fa-image"></i> Replace Preview
</div> </div>
<div class="context-menu-item" data-action="set-nsfw"> <div class="context-menu-item" data-action="set-nsfw">

View File

@@ -15,8 +15,19 @@
<option value="date">Date</option> <option value="date">Date</option>
</select> </select>
</div> </div>
<div title="Refresh model list" class="control-group"> <div title="Refresh model list" class="control-group dropdown-group">
<button data-action="refresh"><i class="fas fa-sync"></i> Refresh</button> <button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> Refresh</button>
<button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i>
</button>
<div class="dropdown-menu">
<div class="dropdown-item" data-action="quick-refresh">
<i class="fas fa-bolt"></i> Quick Refresh (incremental)
</div>
<div class="dropdown-item" data-action="full-rebuild">
<i class="fas fa-tools"></i> Full Rebuild (complete)
</div>
</div>
</div> </div>
<div class="control-group"> <div class="control-group">
@@ -35,6 +46,12 @@
</button> </button>
</div> </div>
{% endif %} {% endif %}
<div class="control-group">
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
<i class="fas fa-clone"></i> Duplicates
<span id="duplicatesBadge" class="badge"></span>
</button>
</div>
<div class="control-group"> <div class="control-group">
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter" title="Show favorites only"> <button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter" title="Show favorites only">
<i class="fas fa-star"></i> Favorites <i class="fas fa-star"></i> Favorites
@@ -47,10 +64,38 @@
</div> </div>
</div> </div>
</div> </div>
<div class="toggle-folders-container">
<button class="toggle-folders-btn icon-only" title="Toggle folder tags"> <div class="controls-right">
<i class="fas fa-tags"></i> <div class="toggle-folders-container">
</button> <button class="toggle-folders-btn icon-only" title="Toggle folder tags">
<i class="fas fa-tags"></i>
</button>
</div>
<div class="keyboard-nav-hint tooltip">
<i class="fas fa-keyboard"></i>
<span class="tooltiptext">
Keyboard Navigation:
<table class="keyboard-shortcuts">
<tr>
<td><span class="key">Page Up</span></td>
<td>Scroll up one page</td>
</tr>
<tr>
<td><span class="key">Page Down</span></td>
<td>Scroll down one page</td>
</tr>
<tr>
<td><span class="key">Home</span></td>
<td>Jump to top</td>
</tr>
<tr>
<td><span class="key">End</span></td>
<td>Jump to bottom</td>
</tr>
</table>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -62,12 +107,18 @@
0 selected <i class="fas fa-caret-down dropdown-caret"></i> 0 selected <i class="fas fa-caret-down dropdown-caret"></i>
</span> </span>
<div class="bulk-operations-actions"> <div class="bulk-operations-actions">
<button onclick="bulkManager.sendAllLorasToWorkflow()" title="Send all selected LoRAs to workflow">
<i class="fas fa-arrow-right"></i> Send to Workflow
</button>
<button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax"> <button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax">
<i class="fas fa-copy"></i> Copy All <i class="fas fa-copy"></i> Copy All
</button> </button>
<button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder"> <button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
<i class="fas fa-folder-open"></i> Move All <i class="fas fa-folder-open"></i> Move All
</button> </button>
<button onclick="bulkManager.showBulkDeleteModal()" title="Delete selected LoRAs" class="danger-btn">
<i class="fas fa-trash"></i> Delete All
</button>
<button onclick="bulkManager.clearSelection()" title="Clear selection"> <button onclick="bulkManager.clearSelection()" title="Clear selection">
<i class="fas fa-times"></i> Clear <i class="fas fa-times"></i> Clear
</button> </button>

View File

@@ -43,6 +43,10 @@
<div class="settings-toggle" title="Settings"> <div class="settings-toggle" title="Settings">
<i class="fas fa-cog"></i> <i class="fas fa-cog"></i>
</div> </div>
<div class="help-toggle" id="helpToggleBtn" title="Help & Tutorials">
<i class="fas fa-question-circle"></i>
<span class="update-badge"></span>
</div>
<div class="update-toggle" id="updateToggleBtn" title="Check Updates"> <div class="update-toggle" id="updateToggleBtn" title="Check Updates">
<i class="fas fa-bell"></i> <i class="fas fa-bell"></i>
<span class="update-badge hidden"></span> <span class="update-badge hidden"></span>

View File

@@ -24,7 +24,7 @@
</div> </div>
</div> </div>
<!-- Duplicate Delete Confirmation Modal --> <!-- Recipes Duplicate Delete Confirmation Modal -->
<div id="duplicateDeleteModal" class="modal delete-modal"> <div id="duplicateDeleteModal" class="modal delete-modal">
<div class="modal-content delete-modal-content"> <div class="modal-content delete-modal-content">
<h2>Delete Duplicate Recipes</h2> <h2>Delete Duplicate Recipes</h2>
@@ -39,6 +39,51 @@
</div> </div>
</div> </div>
<!-- Models Duplicate Delete Confirmation Modal -->
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2>Delete Duplicate Models</h2>
<p class="delete-message">Are you sure you want to delete the selected duplicate models?</p>
<div class="delete-model-info">
<p><span id="modelDuplicateDeleteCount">0</span> models will be permanently deleted.</p>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('modelDuplicateDeleteModal')">Cancel</button>
<button class="delete-btn" onclick="modelDuplicatesManager.confirmDeleteDuplicates()">Delete</button>
</div>
</div>
</div>
<!-- Cache Clear Confirmation Modal -->
<div id="clearCacheModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2>Clear Cache Files</h2>
<p class="delete-message">Are you sure you want to clear all cache files?</p>
<div class="delete-model-info">
<p>This will remove all cached model data. The system will need to rebuild the cache on next startup, which may take some time depending on your model collection size.</p>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('clearCacheModal')">Cancel</button>
<button class="delete-btn" onclick="settingsManager.executeClearCache()">Clear Cache</button>
</div>
</div>
</div>
<!-- Bulk Delete Confirmation Modal -->
<div id="bulkDeleteModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2>Delete Multiple Models</h2>
<p class="delete-message">Are you sure you want to delete all selected models and their associated files?</p>
<div class="delete-model-info">
<p><span id="bulkDeleteCount">0</span> models will be permanently deleted.</p>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('bulkDeleteModal')">Cancel</button>
<button class="delete-btn" onclick="bulkManager.confirmBulkDelete()">Delete All</button>
</div>
</div>
</div>
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settingsModal" class="modal"> <div id="settingsModal" class="modal">
<div class="modal-content settings-modal"> <div class="modal-content settings-modal">
@@ -161,18 +206,47 @@
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="compactMode">Compact Mode</label> <label for="displayDensity">Display Density</label>
</div> </div>
<div class="setting-control"> <div class="setting-control select-control">
<label class="toggle-switch"> <select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
<input type="checkbox" id="compactMode" <option value="default">Default</option>
onchange="settingsManager.saveToggleSetting('compactMode', 'compact_mode')"> <option value="medium">Medium</option>
<span class="toggle-slider"></span> <option value="compact">Compact</option>
</label> </select>
</div> </div>
</div> </div>
<div class="input-help"> <div class="input-help">
Display more cards per row (7 on 1080p, 8 on 2K, 10 on 4K). <span class="warning-text">Warning: May cause performance issues (lag and lower FPS) on systems with limited resources.</span> Choose how many cards to display per row:
<ul class="density-description">
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
</ul>
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
</div>
</div>
</div>
<!-- Add Cache Management Section -->
<div class="settings-section">
<h3>Cache Management</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Clear Cache Files</label>
</div>
<div class="setting-control">
<button id="clearCacheBtn" class="secondary-btn" onclick="settingsManager.confirmClearCache()">
<i class="fas fa-trash"></i>
<span>Clear Cache</span>
</button>
</div>
</div>
<div class="input-help">
Removes cached model data. System will rebuild cache on next startup or when triggered manually.
<span class="warning-text">May cause temporary performance impact during rebuild.</span>
</div> </div>
</div> </div>
</div> </div>
@@ -184,7 +258,27 @@
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="exampleImagesPath">Download Location</label> <label for="useCentralizedExamples">Use Centralized Example Storage</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="useCentralizedExamples" checked
onchange="settingsManager.saveToggleSetting('useCentralizedExamples', 'use_centralized_examples')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="input-help">
When enabled (recommended), example images are stored in a central folder for better organization and performance.
When disabled, only example images stored alongside models (e.g., model-name.example.0.jpg) will be shown, but download
and management features will be unavailable.
</div>
</div>
<div class="setting-item" data-requires-centralized="true">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesPath">Download Location <i class="fas fa-sync-alt restart-required-icon" title="Requires restart"></i></label>
</div> </div>
<div class="setting-control path-control"> <div class="setting-control path-control">
<input type="text" id="exampleImagesPath" placeholder="Enter folder path for example images" /> <input type="text" id="exampleImagesPath" placeholder="Enter folder path for example images" />
@@ -198,7 +292,28 @@
</div> </div>
</div> </div>
<div class="setting-item"> <!-- New migrate section -->
<div class="setting-item" data-requires-centralized="true">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesMigratePattern">Migrate Existing Example Images</label>
</div>
<div class="setting-control migrate-control">
<input type="text" id="exampleImagesMigratePattern"
placeholder="{model}.example.{index}.{ext}"
value="{model}.example.{index}.{ext}" />
<button id="exampleImagesMigrateBtn" class="secondary-btn">
<i class="fas fa-file-import"></i> <span>Migrate</span>
</button>
</div>
</div>
<div class="input-help">
Pattern to find existing example images. Use {model} for model filename, {index} for numbering, and {ext} for file extension.<br>
Example patterns: "{model}.example.{index}.{ext}", "{model}_{index}.{ext}", "{model}/{model}.example.{index}.{ext}"
</div>
</div>
<div class="setting-item" data-requires-centralized="true">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<label for="optimizeExampleImages">Optimize Downloaded Images</label> <label for="optimizeExampleImages">Optimize Downloaded Images</label>
@@ -348,3 +463,142 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Help Modal -->
<div id="helpModal" class="modal">
<div class="modal-content help-modal">
<button class="close" onclick="modalManager.closeModal('helpModal')">&times;</button>
<div class="help-header">
<h2>Help & Tutorials</h2>
</div>
<div class="help-tabs">
<button class="tab-btn active" data-tab="getting-started">Getting Started</button>
<button class="tab-btn" data-tab="update-vlogs">Update Vlogs</button>
<button class="tab-btn" data-tab="documentation">Documentation</button>
</div>
<div class="help-content">
<!-- Getting Started Tab -->
<div class="tab-pane active" id="getting-started">
<h3>Getting Started with LoRA Manager</h3>
<div class="video-embed">
<iframe src="https://www.youtube.com/embed/hvKw31YpE-U"
title="Getting Started with LoRA Manager"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
<div class="help-text">
<h4>Key Features:</h4>
<ul>
<li><strong>Seamless Integration:</strong> One-click workflow integration with ComfyUI</li>
<li><strong>Visual Management:</strong> Organize and manage all your LoRA models with an intuitive interface</li>
<li><strong>Automatic Metadata:</strong> Fetch previews, trigger words, and details automatically</li>
<li><strong>Civitai Integration:</strong> Direct downloads with full API support</li>
<li><strong>Offline Preview Storage:</strong> Store and manage model examples locally</li>
<li><strong>Advanced Controls:</strong> Trigger word toggles and customizable loader node</li>
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
</ul>
</div>
</div>
<!-- Update Vlogs Tab -->
<div class="tab-pane" id="update-vlogs">
<h3>
Latest Updates
<span class="update-date-badge">
<i class="fas fa-calendar-alt"></i>
Apr 28, 2025
</span>
</h3>
<div class="video-list">
<div class="video-item">
<div class="video-embed small">
<iframe src="https://www.youtube.com/embed/videoseries?list=PLU2fMdHNl8ohz1u7Ke3ooOuMbU5Y4sgoj"
title="LoRA Manager Updates"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</div>
<div class="video-info">
<h4>LoRA Manager Updates Playlist</h4>
<p>Watch all update videos showcasing the latest features and improvements.</p>
</div>
</div>
</div>
</div>
<!-- Documentation Tab -->
<div class="tab-pane" id="documentation">
<h3>Documentation</h3>
<div class="docs-section">
<h4><i class="fas fa-book"></i> General</h4>
<ul class="docs-links">
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki" target="_blank">Wiki Home</a></li>
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/README.md" target="_blank">README</a></li>
</ul>
</div>
<div class="docs-section">
<h4><i class="fas fa-tools"></i> Troubleshooting</h4>
<ul class="docs-links">
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/FAQ-(Frequently-Asked-Questions)" target="_blank">FAQ (Frequently Asked Questions)</a></li>
</ul>
</div>
<div class="docs-section">
<h4><i class="fas fa-layer-group"></i> Model Management</h4>
<ul class="docs-links">
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Example-Images" target="_blank">Example Images (WIP)</a></li>
</ul>
</div>
<div class="docs-section">
<h4><i class="fas fa-book-open"></i> Recipes</h4>
<ul class="docs-links">
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/%F0%9F%93%96-Recipes-Feature-Tutorial-%E2%80%93-ComfyUI-LoRA-Manager" target="_blank">Recipes Tutorial</a></li>
</ul>
</div>
<div class="docs-section">
<h4><i class="fas fa-cog"></i> Settings & Configuration</h4>
<ul class="docs-links">
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Configuration" target="_blank">Configuration Options (WIP)</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Re-link to Civitai Modal -->
<div id="relinkCivitaiModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('relinkCivitaiModal')">&times;</button>
<h2>Re-link to Civitai</h2>
<div class="warning-box">
<i class="fas fa-exclamation-triangle"></i>
<p><strong>Warning:</strong> This is a potentially destructive operation. Re-linking will:</p>
<ul>
<li>Override existing metadata</li>
<li>Potentially modify the model hash</li>
<li>May have other unintended consequences</li>
</ul>
<p>Only proceed if you're sure this is what you want.</p>
</div>
<div class="input-group">
<label for="civitaiModelUrl">Civitai Model URL:</label>
<input type="text" id="civitaiModelUrl" placeholder="https://civitai.com/models/1098030?modelVersionId=1233411" />
<div class="input-error" id="civitaiModelUrlError"></div>
<div class="input-help">
The URL must include the modelVersionId parameter.
</div>
</div>
<div class="modal-actions">
<button class="cancel-btn" onclick="modalManager.closeModal('relinkCivitaiModal')">Cancel</button>
<button class="confirm-btn" id="confirmRelinkBtn">Confirm Re-link</button>
</div>
</div>
</div>

View File

@@ -20,6 +20,28 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/alphabet_bar.html' %} {% include 'components/alphabet_bar.html' %}
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
<div class="banner-content">
<i class="fas fa-exclamation-triangle"></i>
<span id="duplicatesCount">Found 0 duplicate groups</span>
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
<div class="banner-actions">
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
</button>
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
<i class="fas fa-times"></i> Exit Mode
</button>
</div>
</div>
<div class="help-tooltip" id="duplicatesHelpTooltip">
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
</div>
</div>
<!-- Lora卡片容器 --> <!-- Lora卡片容器 -->
<div class="card-grid" id="loraGrid"> <div class="card-grid" id="loraGrid">
<!-- Cards will be dynamically inserted here --> <!-- Cards will be dynamically inserted here -->

View File

@@ -21,6 +21,8 @@
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> --> <!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> Share Recipe</div> <div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> Share Recipe</div>
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> Copy Recipe Syntax</div> <div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> Copy Recipe Syntax</div>
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> Send to Workflow (Append)</div>
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)</div>
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> View All LoRAs</div> <div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> View All LoRAs</div>
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> Download Missing LoRAs</div> <div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> Download Missing LoRAs</div>
<div class="context-menu-separator"></div> <div class="context-menu-separator"></div>
@@ -44,7 +46,7 @@
</div> </div>
<!-- Add duplicate detection button --> <!-- Add duplicate detection button -->
<div title="Find duplicate recipes" class="control-group"> <div title="Find duplicate recipes" class="control-group">
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> Find Duplicates</button> <button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> Duplicates</button>
</div> </div>
<!-- Custom filter indicator button (hidden by default) --> <!-- Custom filter indicator button (hidden by default) -->
<div id="customFilterIndicator" class="control-group hidden"> <div id="customFilterIndicator" class="control-group hidden">
@@ -66,7 +68,7 @@
Keep Latest Versions Keep Latest Versions
</button> </button>
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()"> <button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
Delete Selected (<span id="selectedCount">0</span>) Delete Selected (<span id="duplicatesSelectedCount">0</span>)
</button> </button>
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()"> <button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>

View File

@@ -2,6 +2,71 @@
--- ---
### v0.8.9
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
* **Smoother Page Transitions** - Optimized interface switching between pages, eliminating flash issues particularly noticeable in dark theme
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
### v0.8.8
* **Real-time TriggerWord Updates** - Enhanced TriggerWord Toggle node to instantly update when connected Lora Loader or Lora Stacker nodes change, without requiring workflow execution
* **Optimized Metadata Recovery** - Improved utilization of existing .civitai.info files for faster initialization and preservation of metadata from models deleted from CivitAI
* **Migration Acceleration** - Further speed improvements for users transitioning from A1111/Forge environments
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
### v0.8.7
* **Enhanced Context Menu** - Added comprehensive context menu functionality to Recipes and Checkpoints pages for improved workflow
* **Interactive LoRA Strength Control** - Implemented drag functionality in LoRA Loader for intuitive strength adjustment
* **Metadata Collector Overhaul** - Rebuilt metadata collection system with optimized architecture for better performance
* **Improved Save Image Node** - Enhanced metadata capture and image saving performance with the new metadata collector
* **Streamlined Recipe Saving** - Optimized Save Recipe functionality to work independently without requiring Preview Image nodes
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
### v0.8.6 Major Update
* **Checkpoint Management** - Added comprehensive management for model checkpoints including scanning, searching, filtering, and deletion
* **Enhanced Metadata Support** - New capabilities for retrieving and managing checkpoint metadata with improved operations
* **Improved Initial Loading** - Optimized cache initialization with visual progress indicators for better user experience
### v0.8.5
* **Enhanced LoRA & Recipe Connectivity** - Added Recipes tab in LoRA details to see all recipes using a specific LoRA
* **Improved Navigation** - New shortcuts to jump between related LoRAs and Recipes with one-click navigation
* **Video Preview Controls** - Added "Autoplay Videos on Hover" setting to optimize performance and reduce resource usage
* **UI Experience Refinements** - Smoother transitions between related content pages
### 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.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.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.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.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
### v0.7.37 ### v0.7.37
* Added NSFW content control settings (blur mature content and SFW-only filter) * Added NSFW content control settings (blur mature content and SFW-only filter)
* Implemented intelligent blur effects for previews and showcase media * Implemented intelligent blur effects for previews and showcase media

View File

@@ -5,6 +5,7 @@ import {
collectActiveLorasFromChain, collectActiveLorasFromChain,
updateConnectedTriggerWords updateConnectedTriggerWords
} from "./utils.js"; } from "./utils.js";
import { api } from "../../scripts/api.js";
function mergeLoras(lorasText, lorasArr) { function mergeLoras(lorasText, lorasArr) {
const result = []; const result = [];
@@ -38,6 +39,75 @@ function mergeLoras(lorasText, lorasArr) {
app.registerExtension({ app.registerExtension({
name: "LoraManager.LoraLoader", name: "LoraManager.LoraLoader",
setup() {
// Add message handler to listen for messages from Python
api.addEventListener("lora_code_update", (event) => {
const { id, lora_code, mode } = event.detail;
this.handleLoraCodeUpdate(id, lora_code, mode);
});
},
// Handle lora code updates from Python
handleLoraCodeUpdate(id, loraCode, mode) {
// Handle broadcast mode (for Desktop/non-browser support)
if (id === -1) {
// Find all Lora Loader nodes in the current graph
const loraLoaderNodes = [];
for (const nodeId in app.graph._nodes_by_id) {
const node = app.graph._nodes_by_id[nodeId];
if (node.comfyClass === "Lora Loader (LoraManager)") {
loraLoaderNodes.push(node);
}
}
// Update each Lora Loader node found
if (loraLoaderNodes.length > 0) {
loraLoaderNodes.forEach(node => {
this.updateNodeLoraCode(node, loraCode, mode);
});
console.log(`Updated ${loraLoaderNodes.length} Lora Loader nodes in broadcast mode`);
} else {
console.warn("No Lora Loader nodes found in the workflow for broadcast update");
}
return;
}
// Standard mode - update a specific node
const node = app.graph.getNodeById(+id);
if (!node || node.comfyClass !== "Lora Loader (LoraManager)") {
console.warn("Node not found or not a LoraLoader:", id);
return;
}
this.updateNodeLoraCode(node, loraCode, mode);
},
// Helper method to update a single node's lora code
updateNodeLoraCode(node, loraCode, mode) {
// Update the input widget with new lora code
const inputWidget = node.widgets[0];
if (!inputWidget) return;
// Get the current lora code
const currentValue = inputWidget.value || '';
// Update based on mode (replace or append)
if (mode === 'replace') {
inputWidget.value = loraCode;
} else {
// Append mode - add a space if the current value isn't empty
inputWidget.value = currentValue.trim()
? `${currentValue.trim()} ${loraCode}`
: loraCode;
}
// Trigger the callback to update the loras widget
if (typeof inputWidget.callback === 'function') {
inputWidget.callback(inputWidget.value);
}
},
async nodeCreated(node) { async nodeCreated(node) {
if (node.comfyClass === "Lora Loader (LoraManager)") { if (node.comfyClass === "Lora Loader (LoraManager)") {
// Enable widget serialization // Enable widget serialization
@@ -74,6 +144,12 @@ app.registerExtension({
const result = addLorasWidget(node, "loras", { const result = addLorasWidget(node, "loras", {
defaultVal: mergedLoras // Pass object directly defaultVal: mergedLoras // Pass object directly
}, (value) => { }, (value) => {
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(node);
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(node, allActiveLoraNames);
// Prevent recursive calls // Prevent recursive calls
if (isUpdating) return; if (isUpdating) return;
isUpdating = true; isUpdating = true;
@@ -92,12 +168,6 @@ app.registerExtension({
newText = newText.replace(/\s+/g, ' ').trim(); newText = newText.replace(/\s+/g, ' ').trim();
inputWidget.value = newText; inputWidget.value = newText;
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(node);
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(node, allActiveLoraNames);
} finally { } finally {
isUpdating = false; isUpdating = false;
} }
@@ -116,12 +186,6 @@ app.registerExtension({
const mergedLoras = mergeLoras(value, currentLoras); const mergedLoras = mergeLoras(value, currentLoras);
node.lorasWidget.value = mergedLoras; node.lorasWidget.value = mergedLoras;
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(node);
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(node, allActiveLoraNames);
} finally { } finally {
isUpdating = false; isUpdating = false;
} }

View File

@@ -11,7 +11,7 @@ import {
CONTAINER_PADDING, CONTAINER_PADDING,
EMPTY_CONTAINER_HEIGHT EMPTY_CONTAINER_HEIGHT
} from "./loras_widget_utils.js"; } from "./loras_widget_utils.js";
import { initDrag, createContextMenu } from "./loras_widget_events.js"; import { initDrag, createContextMenu, initHeaderDrag } from "./loras_widget_events.js";
export function addLorasWidget(node, name, opts, callback) { export function addLorasWidget(node, name, opts, callback) {
// Create container for loras // Create container for loras
@@ -83,7 +83,8 @@ export function addLorasWidget(node, name, opts, callback) {
alignItems: "center", alignItems: "center",
padding: "4px 8px", padding: "4px 8px",
borderBottom: "1px solid rgba(226, 232, 240, 0.2)", borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
marginBottom: "5px" marginBottom: "5px",
position: "relative" // Added for positioning the drag hint
}); });
// Add toggle all control // Add toggle all control
@@ -118,7 +119,7 @@ export function addLorasWidget(node, name, opts, callback) {
toggleContainer.appendChild(toggleAll); toggleContainer.appendChild(toggleAll);
toggleContainer.appendChild(toggleLabel); toggleContainer.appendChild(toggleLabel);
// Strength label // Strength label with drag hint
const strengthLabel = document.createElement("div"); const strengthLabel = document.createElement("div");
strengthLabel.textContent = "Strength"; strengthLabel.textContent = "Strength";
Object.assign(strengthLabel.style, { Object.assign(strengthLabel.style, {
@@ -129,12 +130,37 @@ export function addLorasWidget(node, name, opts, callback) {
WebkitUserSelect: "none", WebkitUserSelect: "none",
MozUserSelect: "none", MozUserSelect: "none",
msUserSelect: "none", msUserSelect: "none",
display: "flex",
alignItems: "center"
});
// Add drag hint icon next to strength label
const dragHint = document.createElement("span");
dragHint.innerHTML = "↔"; // Simple left-right arrow as drag indicator
Object.assign(dragHint.style, {
marginLeft: "5px",
fontSize: "11px",
opacity: "0.6",
transition: "opacity 0.2s ease"
});
strengthLabel.appendChild(dragHint);
// Add hover effect to improve discoverability
header.addEventListener("mouseenter", () => {
dragHint.style.opacity = "1";
});
header.addEventListener("mouseleave", () => {
dragHint.style.opacity = "0.6";
}); });
header.appendChild(toggleContainer); header.appendChild(toggleContainer);
header.appendChild(strengthLabel); header.appendChild(strengthLabel);
container.appendChild(header); container.appendChild(header);
// Initialize the header drag functionality
initHeaderDrag(header, widget, renderLoras);
// Track the total visible entries for height calculation // Track the total visible entries for height calculation
let totalVisibleEntries = lorasData.length; let totalVisibleEntries = lorasData.length;

View File

@@ -46,6 +46,55 @@ export function handleStrengthDrag(name, initialStrength, initialX, event, widge
} }
} }
// Function to handle proportional strength adjustment for all LoRAs via header dragging
export function handleAllStrengthsDrag(initialStrengths, initialX, event, widget) {
// Define sensitivity (less sensitive than individual adjustment)
const sensitivity = 0.0005;
// Get current mouse position
const currentX = event.clientX;
// Calculate the distance moved
const deltaX = currentX - initialX;
// Calculate adjustment factor (1.0 means no change, >1.0 means increase, <1.0 means decrease)
// For positive deltaX, we want to increase strengths, for negative we want to decrease
const adjustmentFactor = 1.0 + (deltaX * sensitivity);
// Ensure adjustment factor is reasonable (prevent extreme changes)
const limitedFactor = Math.max(0.01, Math.min(3.0, adjustmentFactor));
// Get current loras data
const lorasData = parseLoraValue(widget.value);
// Apply the adjustment factor to each LoRA's strengths
lorasData.forEach((loraData, index) => {
// Get initial strengths for this LoRA
const initialModelStrength = initialStrengths[index].modelStrength;
const initialClipStrength = initialStrengths[index].clipStrength;
// Apply the adjustment factor to both strengths
let newModelStrength = (initialModelStrength * limitedFactor).toFixed(2);
let newClipStrength = (initialClipStrength * limitedFactor).toFixed(2);
// Limit the values to reasonable bounds (-10 to 10)
newModelStrength = Math.max(-10, Math.min(10, newModelStrength));
newClipStrength = Math.max(-10, Math.min(10, newClipStrength));
// Update strengths
lorasData[index].strength = Number(newModelStrength);
lorasData[index].clipStrength = Number(newClipStrength);
});
// Update widget value
widget.value = formatLoraValue(lorasData);
// Force re-render via callback
if (widget.callback) {
widget.callback(widget.value);
}
}
// Function to initialize drag operation // Function to initialize drag operation
export function initDrag(dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction) { export function initDrag(dragEl, name, widget, isClipStrength = false, previewTooltip, renderFunction) {
let isDragging = false; let isDragging = false;
@@ -119,6 +168,65 @@ export function initDrag(dragEl, name, widget, isClipStrength = false, previewTo
}); });
} }
// Function to initialize header drag for proportional strength adjustment
export function initHeaderDrag(headerEl, widget, renderFunction) {
let isDragging = false;
let initialX = 0;
let initialStrengths = [];
// Add cursor style to indicate draggable
headerEl.style.cursor = 'ew-resize';
// Create a drag handler
headerEl.addEventListener('mousedown', (e) => {
// Skip if clicking on toggle or other interactive elements
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input')) {
return;
}
// Store initial X position
initialX = e.clientX;
// Store initial strengths of all LoRAs
const lorasData = parseLoraValue(widget.value);
initialStrengths = lorasData.map(lora => ({
modelStrength: Number(lora.strength),
clipStrength: Number(lora.clipStrength)
}));
isDragging = true;
// Add class to body to enforce cursor style globally
document.body.classList.add('comfy-lora-dragging');
// Prevent text selection during drag
e.preventDefault();
});
// Handle mouse move for dragging
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
// Call the strength adjustment function
handleAllStrengthsDrag(initialStrengths, initialX, e, widget);
// Force re-render to show updated strength values
if (renderFunction) {
renderFunction(widget.value, widget);
}
});
// Handle mouse up to end dragging
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
// Remove the class to restore normal cursor behavior
document.body.classList.remove('comfy-lora-dragging');
}
});
}
// Function to create context menu // Function to create context menu
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) { export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
// Hide preview tooltip first // Hide preview tooltip first

View File

@@ -49,12 +49,12 @@ app.registerExtension({
// Restore saved value if exists // Restore saved value if exists
if (node.widgets_values && node.widgets_values.length > 0) { if (node.widgets_values && node.widgets_values.length > 0) {
// 0 is group mode, 1 is input, 2 is tag widget, 3 is original message // 0 is group mode, 1 is default_active, 2 is input, 3 is tag widget, 4 is original message
const savedValue = node.widgets_values[1]; const savedValue = node.widgets_values[2];
if (savedValue) { if (savedValue) {
result.widget.value = savedValue; result.widget.value = Array.isArray(savedValue) ? savedValue : [];
} }
const originalMessage = node.widgets_values[2]; const originalMessage = node.widgets_values[3];
if (originalMessage) { if (originalMessage) {
hiddenWidget.value = originalMessage; hiddenWidget.value = originalMessage;
} }
@@ -62,8 +62,16 @@ app.registerExtension({
const groupModeWidget = node.widgets[0]; const groupModeWidget = node.widgets[0];
groupModeWidget.callback = (value) => { groupModeWidget.callback = (value) => {
if (node.widgets[2].value) { if (node.widgets[3].value) {
this.updateTagsBasedOnMode(node, node.widgets[2].value, value); this.updateTagsBasedOnMode(node, node.widgets[3].value, value);
}
}
// Add callback for default_active widget
const defaultActiveWidget = node.widgets[1];
defaultActiveWidget.callback = (value) => {
if (node.widgets[3].value) {
this.updateTagsBasedOnMode(node, node.widgets[3].value, groupModeWidget.value);
} }
} }
}); });
@@ -79,7 +87,7 @@ app.registerExtension({
} }
// Store the original message for mode switching // Store the original message for mode switching
node.widgets[2].value = message; node.widgets[3].value = message;
if (node.tagWidget) { if (node.tagWidget) {
// Parse tags based on current group mode // Parse tags based on current group mode
@@ -100,6 +108,9 @@ app.registerExtension({
existingTagMap[tag.text] = tag.active; existingTagMap[tag.text] = tag.active;
}); });
// Get default active state from the widget
const defaultActive = node.widgets[1] ? node.widgets[1].value : true;
let tagArray = []; let tagArray = [];
if (groupMode) { if (groupMode) {
@@ -114,13 +125,15 @@ app.registerExtension({
.filter(group => group) .filter(group => group)
.map(group => ({ .map(group => ({
text: group, text: group,
active: existingTagMap[group] !== undefined ? existingTagMap[group] : true // Use defaultActive only for new tags
active: existingTagMap[group] !== undefined ? existingTagMap[group] : defaultActive
})); }));
} else { } else {
// If no ',,' delimiter, treat the entire message as one group // If no ',,' delimiter, treat the entire message as one group
tagArray = [{ tagArray = [{
text: message.trim(), text: message.trim(),
active: existingTagMap[message.trim()] !== undefined ? existingTagMap[message.trim()] : true // Use defaultActive only for new tags
active: existingTagMap[message.trim()] !== undefined ? existingTagMap[message.trim()] : defaultActive
}]; }];
} }
} else { } else {
@@ -131,7 +144,8 @@ app.registerExtension({
.filter(word => word) .filter(word => word)
.map(word => ({ .map(word => ({
text: word, text: word,
active: existingTagMap[word] !== undefined ? existingTagMap[word] : true // Use defaultActive only for new tags
active: existingTagMap[word] !== undefined ? existingTagMap[word] : defaultActive
})); }));
} }

View File

@@ -6,6 +6,10 @@ const extension = {
app.registerExtension(extension); app.registerExtension(extension);
const config = { const config = {
newTab: true, newTab: true,
newWindow: {
width: 1200,
height: 800,
}
}; };
const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => { const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => {
@@ -32,12 +36,18 @@ const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => {
return button; return button;
}; };
const onClick = () => { const onClick = (e) => {
const loraManagerUrl = `${window.location.origin}/loras`; const loraManagerUrl = `${window.location.origin}/loras`;
if (config.newTab) {
window.open(loraManagerUrl, '_blank'); // Check if Shift key is pressed to determine how to open
if (e.shiftKey) {
// Open in new window
const { width, height } = config.newWindow;
const windowFeatures = `width=${width},height=${height},resizable=yes,scrollbars=yes,status=yes`;
window.open(loraManagerUrl, '_blank', windowFeatures);
} else { } else {
window.location.href = loraManagerUrl; // Default behavior: open in new tab
window.open(loraManagerUrl, '_blank');
} }
}; };
@@ -53,7 +63,7 @@ const addWidgetMenuRight = (menuRight) => {
const loraManagerButton = createWidget({ const loraManagerButton = createWidget({
className: 'comfyui-button comfyui-menu-mobile-collapse primary', className: 'comfyui-button comfyui-menu-mobile-collapse primary',
text: '', text: '',
tooltip: 'Launch Lora Manager', tooltip: 'Launch Lora Manager (Shift+Click to open in new window)',
includeIcon: true, includeIcon: true,
svgMarkup: getLoraManagerIcon(), svgMarkup: getLoraManagerIcon(),
}); });
@@ -70,7 +80,7 @@ const addWidgetMenu = (menu) => {
const loraManagerButton = createWidget({ const loraManagerButton = createWidget({
className: 'comfy-lora-manager-button', className: 'comfy-lora-manager-button',
text: 'Lora Manager', text: 'Lora Manager',
tooltip: 'Launch Lora Manager', tooltip: 'Launch Lora Manager (Shift+Click to open in new window)',
includeIcon: false, includeIcon: false,
}); });

View File

@@ -4,7 +4,7 @@ import { api } from "../../scripts/api.js";
// Register the extension // Register the extension
app.registerExtension({ app.registerExtension({
name: "ComfyUI-Lora-Manager.UsageStats", name: "Lora-Manager.UsageStats",
init() { init() {
// Listen for successful executions // Listen for successful executions

View File

@@ -155,7 +155,7 @@ export function collectActiveLorasFromChain(node, visited = new Set()) {
// Update trigger words for connected toggle nodes // Update trigger words for connected toggle nodes
export function updateConnectedTriggerWords(node, loraNames) { export function updateConnectedTriggerWords(node, loraNames) {
const connectedNodeIds = getConnectedTriggerToggleNodes(node); const connectedNodeIds = getConnectedTriggerToggleNodes(node);
if (connectedNodeIds.length > 0 && loraNames.size > 0) { if (connectedNodeIds.length > 0) {
fetch("/loramanager/get_trigger_words", { fetch("/loramanager/get_trigger_words", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

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