mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0daf9d92ff | ||
|
|
37de26ce25 | ||
|
|
0eaef7e7a0 | ||
|
|
8063cee3cd | ||
|
|
cbb25b4ac0 | ||
|
|
c62206a157 | ||
|
|
09832141d0 | ||
|
|
bf8e121a10 | ||
|
|
68568073ec | ||
|
|
ec36524c35 | ||
|
|
67acd9fd2c | ||
|
|
f7be5c8d25 | ||
|
|
ceacac75e0 | ||
|
|
bae66f94e8 | ||
|
|
ddf132bd78 | ||
|
|
afb012029f | ||
|
|
651e14c8c3 | ||
|
|
e7c626eb5f | ||
|
|
a0b0d40a19 | ||
|
|
42e3ab9e27 | ||
|
|
6e5f333364 | ||
|
|
f33a9abe60 | ||
|
|
7f1bbdd615 | ||
|
|
d3bf8eaceb | ||
|
|
b9c9d602de | ||
|
|
b25fbd6e24 | ||
|
|
6052608a4e | ||
|
|
a073b82751 | ||
|
|
8250acdfb5 | ||
|
|
8e1f73a34e | ||
|
|
50704bc882 | ||
|
|
35d34e3513 | ||
|
|
ea834f3de6 | ||
|
|
11aedde72f | ||
|
|
488654abc8 | ||
|
|
da1be0dc65 | ||
|
|
d0c728a339 | ||
|
|
66c66c4d9b | ||
|
|
4882721387 | ||
|
|
06a8850c0c | ||
|
|
370aa06c67 | ||
|
|
c9fa0564e7 | ||
|
|
2ba7a0ceba | ||
|
|
276aedfbb9 | ||
|
|
c193c75674 | ||
|
|
a562ba3746 | ||
|
|
2fedd572ff | ||
|
|
db0b49c427 | ||
|
|
03a6f8111c | ||
|
|
925ad7b3e0 | ||
|
|
bf793d5b8b | ||
|
|
64a906ca5e | ||
|
|
99b36442bb | ||
|
|
3c5164d510 | ||
|
|
ec4b5a4d45 | ||
|
|
78e1901779 | ||
|
|
cb539314de | ||
|
|
c7627fe0de | ||
|
|
84bfad7ce5 | ||
|
|
3e06938b05 | ||
|
|
4f712fec14 | ||
|
|
c5c9659c76 | ||
|
|
d6e175c1f1 | ||
|
|
88088e1071 | ||
|
|
958ddbca86 | ||
|
|
6670fd28f4 | ||
|
|
1e59c31de3 | ||
|
|
c966dbbbbc | ||
|
|
af8f5ba04e | ||
|
|
b741ed0b3b | ||
|
|
01ba3c14f8 | ||
|
|
d13b1a83ad | ||
|
|
303477db70 | ||
|
|
311e89e9e7 | ||
|
|
8546cfe714 | ||
|
|
e6f4d84b9a | ||
|
|
ce7e422169 | ||
|
|
e5aec80984 | ||
|
|
6d97817390 | ||
|
|
d516f22159 | ||
|
|
e918c18ca2 | ||
|
|
5dd8d905fa | ||
|
|
1121d1ee6c | ||
|
|
4793f096af | ||
|
|
7b5b4ce082 | ||
|
|
fa08c9c3e4 | ||
|
|
d0d5eb956a | ||
|
|
969f949330 | ||
|
|
9169bbd04d | ||
|
|
99463ad01c | ||
|
|
f1d6b0feda | ||
|
|
e33da50278 | ||
|
|
4034eb3221 | ||
|
|
75a95f0109 | ||
|
|
92fdc16fe6 | ||
|
|
23fa2995c8 | ||
|
|
59aefdff77 | ||
|
|
e92ab9e3cc | ||
|
|
e3bf1f763c | ||
|
|
1c6e9d0b69 | ||
|
|
bfd4eb3e11 | ||
|
|
c9f902a8af | ||
|
|
0b67510ec9 | ||
|
|
b5cd320e8b | ||
|
|
deb25b4987 | ||
|
|
4612da264a | ||
|
|
59b67e1e10 | ||
|
|
5fad936b27 | ||
|
|
e376a45dea | ||
|
|
fd593bb61d | ||
|
|
71b97d5974 | ||
|
|
2b405ae164 | ||
|
|
2fe4736b69 | ||
|
|
184f8ca6cf | ||
|
|
1ff2019dde | ||
|
|
a3d8261686 | ||
|
|
7d0600976e |
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
ko_fi: pixelpawsai
|
||||
custom: ['paypal.me/pixelpawsai']
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ settings.json
|
||||
output/*
|
||||
py/run_test.py
|
||||
.vscode/
|
||||
cache/
|
||||
|
||||
102
README.md
102
README.md
@@ -10,16 +10,47 @@ A comprehensive toolset that streamlines organizing, downloading, and applying L
|
||||
|
||||

|
||||
|
||||
One-click Integration:
|
||||

|
||||
|
||||
## 📺 Tutorial: One-Click LoRA Integration
|
||||
Watch this quick tutorial to learn how to use the new one-click LoRA integration feature:
|
||||
|
||||
[](https://youtu.be/qS95OjX3e70)
|
||||
[](https://youtu.be/VKvTlCB78h4)
|
||||
[](https://youtu.be/hvKw31YpE-U)
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
|
||||
### 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
|
||||
* **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)
|
||||
* **Enhanced LoRA Node Functionality** - Comprehensive improvements to LoRA loader/stacker nodes including real-time trigger word updates (reflecting any change anywhere in the LoRA chain for precise updates) and expanded context menu with "Copy Notes" and "Copy Trigger Words" options for faster workflow
|
||||
|
||||
### v0.8.13
|
||||
* **Enhanced Recipe Management** - Added "Find duplicates" feature to identify and batch delete duplicate recipes with duplicate detection notifications during imports
|
||||
* **Improved Source Tracking** - Source URLs are now saved with recipes imported via URL, allowing users to view original content with one click or manually edit links
|
||||
* **Advanced LoRA Control** - Double-click LoRAs in Loader/Stacker nodes to access expanded CLIP strength controls for more precise adjustments of model and CLIP strength separately
|
||||
* **Lycoris Model Support** - Added compatibility with Lycoris models for expanded creative options
|
||||
* **Bug Fixes & UX Improvements** - Resolved various issues and enhanced overall user experience with numerous optimizations
|
||||
|
||||
### v0.8.12
|
||||
* **Enhanced Model Discovery** - Added alphabetical navigation bar to LoRAs page for faster browsing through large collections
|
||||
* **Optimized Example Images** - Improved download logic to automatically refresh stale metadata before fetching example images
|
||||
@@ -37,71 +68,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
|
||||
* **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 Forge’s 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)
|
||||
|
||||
---
|
||||
@@ -166,7 +132,7 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
|
||||
### 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
|
||||
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
|
||||
4. Run run.bat
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Constants used by the metadata collector"""
|
||||
|
||||
# Metadata collection constants
|
||||
|
||||
# Metadata categories
|
||||
MODELS = "models"
|
||||
PROMPTS = "prompts"
|
||||
@@ -9,6 +7,7 @@ SAMPLING = "sampling"
|
||||
LORAS = "loras"
|
||||
SIZE = "size"
|
||||
IMAGES = "images"
|
||||
IS_SAMPLER = "is_sampler" # New constant to mark sampler nodes
|
||||
|
||||
# Complete list of categories to track
|
||||
METADATA_CATEGORIES = [MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES]
|
||||
|
||||
@@ -4,33 +4,109 @@ import sys
|
||||
# Check if running in standalone mode
|
||||
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:
|
||||
"""Process and format collected metadata"""
|
||||
|
||||
@staticmethod
|
||||
def find_primary_sampler(metadata):
|
||||
"""Find the primary KSampler node (with highest denoise value)"""
|
||||
def find_primary_sampler(metadata, downstream_id=None):
|
||||
"""
|
||||
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_id = None
|
||||
max_denoise = -1 # Track the highest denoise value
|
||||
max_denoise = -1
|
||||
|
||||
# First, check for SamplerCustomAdvanced
|
||||
prompt = metadata.get("current_prompt")
|
||||
if prompt and prompt.original_prompt:
|
||||
for node_id, node_info in prompt.original_prompt.items():
|
||||
if node_info.get("class_type") == "SamplerCustomAdvanced":
|
||||
# Found a SamplerCustomAdvanced node
|
||||
if node_id in metadata.get(SAMPLING, {}):
|
||||
# Check if the node is in SAMPLING and has IS_SAMPLER flag
|
||||
if node_id in metadata.get(SAMPLING, {}) and metadata[SAMPLING][node_id].get(IS_SAMPLER, False):
|
||||
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():
|
||||
# Skip if not marked as a sampler
|
||||
if not sampler_info.get(IS_SAMPLER, False):
|
||||
continue
|
||||
|
||||
parameters = sampler_info.get("parameters", {})
|
||||
add_noise = parameters.get("add_noise")
|
||||
|
||||
# If add_noise is "enable", this is likely the primary sampler for KSamplerAdvanced
|
||||
if add_noise == "enable":
|
||||
primary_sampler = sampler_info
|
||||
primary_sampler_id = node_id
|
||||
@@ -39,10 +115,12 @@ class MetadataProcessor:
|
||||
# If no specialized sampler found, find the sampler with highest denoise value
|
||||
if primary_sampler is None:
|
||||
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", {})
|
||||
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:
|
||||
max_denoise = denoise
|
||||
primary_sampler = sampler_info
|
||||
@@ -74,13 +152,18 @@ class MetadataProcessor:
|
||||
current_node_id = node_id
|
||||
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:
|
||||
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", {})
|
||||
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 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:
|
||||
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:
|
||||
return found_node_id
|
||||
last_valid_node = found_node_id
|
||||
|
||||
# Continue tracing through intermediate nodes
|
||||
current_node_id = found_node_id
|
||||
@@ -101,16 +184,17 @@ class MetadataProcessor:
|
||||
if "conditioning" in prompt.original_prompt[current_node_id].get("inputs", {}):
|
||||
current_input = "conditioning"
|
||||
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
|
||||
else:
|
||||
# We've reached a node with no further connections
|
||||
return None
|
||||
return last_valid_node if not target_class else None
|
||||
|
||||
current_depth += 1
|
||||
|
||||
# If we've reached max depth without finding target_class
|
||||
return None
|
||||
return last_valid_node if not target_class else None
|
||||
|
||||
@staticmethod
|
||||
def find_primary_checkpoint(metadata):
|
||||
@@ -126,8 +210,14 @@ class MetadataProcessor:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def extract_generation_params(metadata):
|
||||
"""Extract generation parameters from metadata using node relationships"""
|
||||
def extract_generation_params(metadata, id=None):
|
||||
"""
|
||||
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 = {
|
||||
"prompt": "",
|
||||
"negative_prompt": "",
|
||||
@@ -147,13 +237,20 @@ class MetadataProcessor:
|
||||
prompt = metadata.get("current_prompt")
|
||||
|
||||
# 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
|
||||
checkpoint = MetadataProcessor.find_primary_checkpoint(metadata)
|
||||
if 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:
|
||||
# Extract sampling parameters
|
||||
sampling_params = primary_sampler.get("parameters", {})
|
||||
@@ -187,24 +284,34 @@ class MetadataProcessor:
|
||||
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
|
||||
params["sampler"] = sampler_params.get("sampler_name")
|
||||
|
||||
# 3. Trace guider input for 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)
|
||||
if guider_node_id:
|
||||
# Look for FluxGuidance along the guider path
|
||||
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, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
if guider_node_id and guider_node_id in prompt.original_prompt:
|
||||
# Check if the guider node is a CFGGuider
|
||||
if prompt.original_prompt[guider_node_id].get("class_type") == "CFGGuider":
|
||||
# Extract cfg value from the CFGGuider
|
||||
if guider_node_id in metadata.get(SAMPLING, {}):
|
||||
cfg_params = metadata[SAMPLING][guider_node_id].get("parameters", {})
|
||||
params["cfg_scale"] = cfg_params.get("cfg")
|
||||
|
||||
# Find CLIPTextEncode for positive prompt
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", "CLIPTextEncode", max_depth=10)
|
||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
|
||||
# Find CLIPTextEncode for negative prompt
|
||||
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", "CLIPTextEncode", max_depth=10)
|
||||
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
else:
|
||||
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
|
||||
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
|
||||
else:
|
||||
# Original tracing for standard samplers
|
||||
# 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, {}):
|
||||
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
|
||||
else:
|
||||
@@ -212,21 +319,9 @@ class MetadataProcessor:
|
||||
positive_flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncodeFlux", max_depth=10)
|
||||
if positive_flux_node_id and positive_flux_node_id in metadata.get(PROMPTS, {}):
|
||||
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
|
||||
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, {}):
|
||||
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
|
||||
|
||||
@@ -256,13 +351,19 @@ class MetadataProcessor:
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def to_dict(metadata):
|
||||
"""Convert extracted metadata to the ComfyUI output.json format"""
|
||||
def to_dict(metadata, id=None):
|
||||
"""
|
||||
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:
|
||||
# Return empty dictionary in standalone mode
|
||||
return {}
|
||||
|
||||
params = MetadataProcessor.extract_generation_params(metadata)
|
||||
params = MetadataProcessor.extract_generation_params(metadata, id)
|
||||
|
||||
# Convert all values to strings to match output.json format
|
||||
for key in params:
|
||||
@@ -272,7 +373,7 @@ class MetadataProcessor:
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def to_json(metadata):
|
||||
def to_json(metadata, id=None):
|
||||
"""Convert metadata to JSON string"""
|
||||
params = MetadataProcessor.to_dict(metadata)
|
||||
params = MetadataProcessor.to_dict(metadata, id)
|
||||
return json.dumps(params, indent=4)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES
|
||||
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE, IMAGES, IS_SAMPLER
|
||||
|
||||
|
||||
class NodeMetadataExtractor:
|
||||
@@ -61,7 +61,8 @@ class SamplerExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: True # Add sampler flag
|
||||
}
|
||||
|
||||
# Extract latent image dimensions if available
|
||||
@@ -98,7 +99,8 @@ class KSamplerAdvancedExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: True # Add sampler flag
|
||||
}
|
||||
|
||||
# Extract latent image dimensions if available
|
||||
@@ -269,7 +271,8 @@ class KSamplerSelectExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: False # Mark as non-primary sampler
|
||||
}
|
||||
|
||||
class BasicSchedulerExtractor(NodeMetadataExtractor):
|
||||
@@ -285,7 +288,8 @@ class BasicSchedulerExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: False # Mark as non-primary sampler
|
||||
}
|
||||
|
||||
class SamplerCustomAdvancedExtractor(NodeMetadataExtractor):
|
||||
@@ -303,7 +307,8 @@ class SamplerCustomAdvancedExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id] = {
|
||||
"parameters": sampling_params,
|
||||
"node_id": node_id
|
||||
"node_id": node_id,
|
||||
IS_SAMPLER: True # Add sampler flag
|
||||
}
|
||||
|
||||
# Extract latent image dimensions if available
|
||||
@@ -338,11 +343,20 @@ class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
||||
clip_l_text = inputs.get("clip_l", "")
|
||||
t5xxl_text = inputs.get("t5xxl", "")
|
||||
|
||||
# Create JSON string with T5 content first, then CLIP-L
|
||||
combined_text = json.dumps({
|
||||
"T5": t5xxl_text,
|
||||
"CLIP-L": clip_l_text
|
||||
})
|
||||
# If both are empty, use empty string
|
||||
if not clip_l_text and not t5xxl_text:
|
||||
combined_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] = {
|
||||
"text": combined_text,
|
||||
@@ -362,6 +376,23 @@ class CLIPTextEncodeFluxExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id]["parameters"]["guidance"] = guidance_value
|
||||
|
||||
class CFGGuiderExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs or "cfg" not in inputs:
|
||||
return
|
||||
|
||||
cfg_value = inputs.get("cfg")
|
||||
|
||||
# Store the cfg value in SAMPLING category
|
||||
if SAMPLING not in metadata:
|
||||
metadata[SAMPLING] = {}
|
||||
|
||||
if node_id not in metadata[SAMPLING]:
|
||||
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
|
||||
|
||||
metadata[SAMPLING][node_id]["parameters"]["cfg"] = cfg_value
|
||||
|
||||
# Registry of node-specific extractors
|
||||
NODE_EXTRACTORS = {
|
||||
# Sampling
|
||||
@@ -374,15 +405,18 @@ NODE_EXTRACTORS = {
|
||||
# Loaders
|
||||
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
|
||||
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
|
||||
"LoraLoader": LoraLoaderExtractor,
|
||||
"LoraManagerLoader": LoraLoaderManagerExtractor,
|
||||
# Conditioning
|
||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||
# Latent
|
||||
"EmptyLatentImage": ImageSizeExtractor,
|
||||
# Flux
|
||||
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
|
||||
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
||||
# Image
|
||||
"VAEDecode": VAEDecodeExtractor, # Added VAEDecode extractor
|
||||
# Add other nodes as needed
|
||||
|
||||
@@ -14,20 +14,23 @@ class DebugMetadata:
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
},
|
||||
"hidden": {
|
||||
"id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("metadata_json",)
|
||||
FUNCTION = "process_metadata"
|
||||
|
||||
def process_metadata(self, images):
|
||||
def process_metadata(self, images, id):
|
||||
try:
|
||||
# Get the current execution context's metadata
|
||||
from ..metadata_collector import get_metadata
|
||||
metadata = get_metadata()
|
||||
|
||||
# Use the MetadataProcessor to convert it to JSON string
|
||||
metadata_json = MetadataProcessor.to_json(metadata)
|
||||
metadata_json = MetadataProcessor.to_json(metadata, id)
|
||||
|
||||
return (metadata_json,)
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import logging
|
||||
from nodes import LoraLoader
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
from ..services.lora_scanner import LoraScanner
|
||||
from ..config import config
|
||||
import asyncio
|
||||
import os
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_lora_info, extract_lora_name, get_loras_list
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,7 +48,11 @@ class LoraManagerLoader:
|
||||
_, trigger_words = asyncio.run(get_lora_info(lora_name))
|
||||
|
||||
all_trigger_words.extend(trigger_words)
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
# Add clip strength to output if different from model strength
|
||||
if abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
# Then process loras from kwargs with support for both old and new formats
|
||||
loras_list = get_loras_list(kwargs)
|
||||
@@ -60,14 +61,21 @@ class LoraManagerLoader:
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
strength = float(lora['strength'])
|
||||
model_strength = float(lora['strength'])
|
||||
# Get clip strength - use model strength as default if not specified
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = asyncio.run(get_lora_info(lora_name))
|
||||
|
||||
# Apply the LoRA using the resolved path
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, strength, strength)
|
||||
loaded_loras.append(f"{lora_name}: {strength}")
|
||||
# Apply the LoRA using the resolved path with separate strengths
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||
|
||||
# Include clip strength in output if different from model strength
|
||||
if abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
# Add trigger words to collection
|
||||
all_trigger_words.extend(trigger_words)
|
||||
@@ -75,8 +83,23 @@ class LoraManagerLoader:
|
||||
# use ',, ' to separate trigger words for group mode
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Format loaded_loras as <lora:lora_name:strength> separated by spaces
|
||||
formatted_loras = " ".join([f"<lora:{name.split(':')[0].strip()}:{str(strength).strip()}>"
|
||||
for name, strength in [item.split(':') for item in loaded_loras]])
|
||||
# Format loaded_loras with support for both formats
|
||||
formatted_loras = []
|
||||
for item in loaded_loras:
|
||||
parts = item.split(":")
|
||||
lora_name = parts[0].strip()
|
||||
strength_parts = parts[1].strip().split(",")
|
||||
|
||||
if len(strength_parts) > 1:
|
||||
# Different model and clip strengths
|
||||
model_str = strength_parts[0].strip()
|
||||
clip_str = strength_parts[1].strip()
|
||||
formatted_loras.append(f"<lora:{lora_name}:{model_str}:{clip_str}>")
|
||||
else:
|
||||
# Same strength for both
|
||||
model_str = strength_parts[0].strip()
|
||||
formatted_loras.append(f"<lora:{lora_name}:{model_str}>")
|
||||
|
||||
formatted_loras_text = " ".join(formatted_loras)
|
||||
|
||||
return (model, clip, trigger_words_text, formatted_loras)
|
||||
return (model, clip, trigger_words_text, formatted_loras_text)
|
||||
@@ -38,7 +38,7 @@ class LoraStacker:
|
||||
|
||||
# Process existing lora_stack if available
|
||||
lora_stack = kwargs.get('lora_stack', None)
|
||||
if lora_stack:
|
||||
if (lora_stack):
|
||||
stack.extend(lora_stack)
|
||||
# Get trigger words from existing stack entries
|
||||
for lora_path, _, _ in lora_stack:
|
||||
@@ -54,7 +54,8 @@ class LoraStacker:
|
||||
|
||||
lora_name = lora['name']
|
||||
model_strength = float(lora['strength'])
|
||||
clip_strength = model_strength # Using same strength for both as in the original loader
|
||||
# Get clip strength - use model strength as default if not specified
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = asyncio.run(get_lora_info(lora_name))
|
||||
@@ -62,15 +63,24 @@ class LoraStacker:
|
||||
# Add to stack without loading
|
||||
# replace '/' with os.sep to avoid different OS path format
|
||||
stack.append((lora_path.replace('/', os.sep), model_strength, clip_strength))
|
||||
active_loras.append((lora_name, model_strength))
|
||||
active_loras.append((lora_name, model_strength, clip_strength))
|
||||
|
||||
# Add trigger words to collection
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# use ',, ' to separate trigger words for group mode
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
# Format active_loras as <lora:lora_name:strength> separated by spaces
|
||||
active_loras_text = " ".join([f"<lora:{name}:{str(strength).strip()}>"
|
||||
for name, strength in active_loras])
|
||||
|
||||
# Format active_loras with support for both formats
|
||||
formatted_loras = []
|
||||
for name, model_strength, clip_strength in active_loras:
|
||||
if abs(model_strength - clip_strength) > 0.001:
|
||||
# Different model and clip strengths
|
||||
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}:{str(clip_strength).strip()}>")
|
||||
else:
|
||||
# Same strength for both
|
||||
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}>")
|
||||
|
||||
active_loras_text = " ".join(formatted_loras)
|
||||
|
||||
return (stack, trigger_words_text, active_loras_text)
|
||||
|
||||
@@ -41,6 +41,7 @@ class SaveImage:
|
||||
"add_counter_to_filename": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"id": "UNIQUE_ID",
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
@@ -223,7 +224,7 @@ class SaveImage:
|
||||
if lora_hashes:
|
||||
lora_hash_parts = []
|
||||
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:
|
||||
params.append(f"Lora hashes: \"{', '.join(lora_hash_parts)}\"")
|
||||
@@ -300,14 +301,14 @@ class SaveImage:
|
||||
|
||||
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):
|
||||
"""Save images with metadata"""
|
||||
results = []
|
||||
|
||||
|
||||
# Get metadata using the metadata collector
|
||||
raw_metadata = get_metadata()
|
||||
metadata_dict = MetadataProcessor.to_dict(raw_metadata)
|
||||
metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
|
||||
|
||||
# Get or create metadata asynchronously
|
||||
metadata = asyncio.run(self.format_metadata(metadata_dict))
|
||||
@@ -378,14 +379,23 @@ class SaveImage:
|
||||
print(f"Error adding EXIF data: {e}")
|
||||
img.save(file_path, format="JPEG", **save_kwargs)
|
||||
elif file_format == "webp":
|
||||
# For WebP, also use piexif for metadata
|
||||
if metadata:
|
||||
try:
|
||||
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}}
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
save_kwargs["exif"] = exif_bytes
|
||||
except Exception as e:
|
||||
print(f"Error adding EXIF data: {e}")
|
||||
try:
|
||||
# For WebP, use piexif for metadata
|
||||
exif_dict = {}
|
||||
|
||||
if metadata:
|
||||
exif_dict['Exif'] = {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}
|
||||
|
||||
# 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)
|
||||
|
||||
results.append({
|
||||
@@ -399,7 +409,7 @@ class SaveImage:
|
||||
|
||||
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):
|
||||
"""Process and save image with metadata"""
|
||||
# Make sure the output directory exists
|
||||
@@ -416,6 +426,7 @@ class SaveImage:
|
||||
images,
|
||||
filename_prefix,
|
||||
file_format,
|
||||
id,
|
||||
prompt,
|
||||
extra_pnginfo,
|
||||
lossless_webp,
|
||||
|
||||
22
py/recipes/__init__.py
Normal file
22
py/recipes/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Recipe metadata parser package for ComfyUI-Lora-Manager."""
|
||||
|
||||
from .base import RecipeMetadataParser
|
||||
from .factory import RecipeParserFactory
|
||||
from .constants import GEN_PARAM_KEYS, VALID_LORA_TYPES
|
||||
from .parsers import (
|
||||
RecipeFormatParser,
|
||||
ComfyMetadataParser,
|
||||
MetaFormatParser,
|
||||
AutomaticMetadataParser
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'RecipeMetadataParser',
|
||||
'RecipeParserFactory',
|
||||
'GEN_PARAM_KEYS',
|
||||
'VALID_LORA_TYPES',
|
||||
'RecipeFormatParser',
|
||||
'ComfyMetadataParser',
|
||||
'MetaFormatParser',
|
||||
'AutomaticMetadataParser'
|
||||
]
|
||||
181
py/recipes/base.py
Normal file
181
py/recipes/base.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Base classes for recipe parsers."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from abc import ABC, abstractmethod
|
||||
from ..config import config
|
||||
from .constants import VALID_LORA_TYPES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeMetadataParser(ABC):
|
||||
"""Interface for parsing recipe metadata from image user comments"""
|
||||
|
||||
METADATA_MARKER = None
|
||||
|
||||
@abstractmethod
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
"""Check if the user comment matches the metadata format"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse metadata from user comment and return structured recipe data
|
||||
|
||||
Args:
|
||||
user_comment: The EXIF UserComment string from the image
|
||||
recipe_scanner: Optional recipe scanner instance for local LoRA lookup
|
||||
civitai_client: Optional Civitai client for fetching model information
|
||||
|
||||
Returns:
|
||||
Dict containing parsed recipe data with standardized format
|
||||
"""
|
||||
pass
|
||||
|
||||
async def populate_lora_from_civitai(self, lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any], Optional[str]],
|
||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Populate a lora entry with information from Civitai API response
|
||||
|
||||
Args:
|
||||
lora_entry: The lora entry to populate
|
||||
civitai_info_tuple: The response tuple from Civitai API (data, error_msg)
|
||||
recipe_scanner: Optional recipe scanner for local file lookup
|
||||
base_model_counts: Optional dict to track base model counts
|
||||
hash_value: Optional hash value to use if not available in civitai_info
|
||||
|
||||
Returns:
|
||||
The populated lora_entry dict if type is valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
# Unpack the tuple to get the actual data
|
||||
civitai_info, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||
|
||||
if not civitai_info or civitai_info.get("error") == "Model not found":
|
||||
# Model not found or deleted
|
||||
lora_entry['isDeleted'] = True
|
||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||
return lora_entry
|
||||
|
||||
# Get model type and validate
|
||||
model_type = civitai_info.get('model', {}).get('type', '').lower()
|
||||
lora_entry['type'] = model_type
|
||||
if model_type not in VALID_LORA_TYPES:
|
||||
logger.debug(f"Skipping non-LoRA model type: {model_type}")
|
||||
return None
|
||||
|
||||
# Check if this is an early access lora
|
||||
if civitai_info.get('earlyAccessEndsAt'):
|
||||
# Convert earlyAccessEndsAt to a human-readable date
|
||||
early_access_date = civitai_info.get('earlyAccessEndsAt', '')
|
||||
lora_entry['isEarlyAccess'] = True
|
||||
lora_entry['earlyAccessEndsAt'] = early_access_date
|
||||
|
||||
# Update model name if available
|
||||
if 'model' in civitai_info and 'name' in civitai_info['model']:
|
||||
lora_entry['name'] = civitai_info['model']['name']
|
||||
|
||||
# Update version if available
|
||||
if 'name' in civitai_info:
|
||||
lora_entry['version'] = civitai_info.get('name', '')
|
||||
|
||||
# Get thumbnail URL from first image
|
||||
if 'images' in civitai_info and civitai_info['images']:
|
||||
lora_entry['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
||||
|
||||
# Get base model
|
||||
current_base_model = civitai_info.get('baseModel', '')
|
||||
lora_entry['baseModel'] = current_base_model
|
||||
|
||||
# Update base model counts if tracking them
|
||||
if base_model_counts is not None and current_base_model:
|
||||
base_model_counts[current_base_model] = base_model_counts.get(current_base_model, 0) + 1
|
||||
|
||||
# Get download URL
|
||||
lora_entry['downloadUrl'] = civitai_info.get('downloadUrl', '')
|
||||
|
||||
# Process file information if available
|
||||
if 'files' in civitai_info:
|
||||
# Find the primary model file (type="Model" and primary=true) in the files list
|
||||
model_file = next((file for file in civitai_info.get('files', [])
|
||||
if file.get('type') == 'Model' and file.get('primary') == True), None)
|
||||
|
||||
if model_file:
|
||||
# Get size
|
||||
lora_entry['size'] = model_file.get('sizeKB', 0) * 1024
|
||||
|
||||
# Get SHA256 hash
|
||||
sha256 = model_file.get('hashes', {}).get('SHA256', hash_value)
|
||||
if sha256:
|
||||
lora_entry['hash'] = sha256.lower()
|
||||
|
||||
# Check if exists locally
|
||||
if recipe_scanner and lora_entry['hash']:
|
||||
lora_scanner = recipe_scanner._lora_scanner
|
||||
exists_locally = lora_scanner.has_lora_hash(lora_entry['hash'])
|
||||
if exists_locally:
|
||||
try:
|
||||
local_path = lora_scanner.get_lora_path_by_hash(lora_entry['hash'])
|
||||
lora_entry['existsLocally'] = True
|
||||
lora_entry['localPath'] = local_path
|
||||
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
|
||||
|
||||
# Get thumbnail from local preview if available
|
||||
lora_cache = await lora_scanner.get_cached_data()
|
||||
lora_item = next((item for item in lora_cache.raw_data
|
||||
if item['sha256'].lower() == lora_entry['hash'].lower()), None)
|
||||
if lora_item and 'preview_url' in lora_item:
|
||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting local lora path: {e}")
|
||||
else:
|
||||
# For missing LoRAs, get file_name from model_file.name
|
||||
file_name = model_file.get('name', '')
|
||||
lora_entry['file_name'] = os.path.splitext(file_name)[0] if file_name else ''
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error populating lora from Civitai info: {e}")
|
||||
|
||||
return lora_entry
|
||||
|
||||
async def populate_checkpoint_from_civitai(self, checkpoint: Dict[str, Any], civitai_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Populate checkpoint information from Civitai API response
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint entry to populate
|
||||
civitai_info: The response from Civitai API
|
||||
|
||||
Returns:
|
||||
The populated checkpoint dict
|
||||
"""
|
||||
try:
|
||||
if civitai_info and civitai_info.get("error") != "Model not found":
|
||||
# Update model name if available
|
||||
if 'model' in civitai_info and 'name' in civitai_info['model']:
|
||||
checkpoint['name'] = civitai_info['model']['name']
|
||||
|
||||
# Update version if available
|
||||
if 'name' in civitai_info:
|
||||
checkpoint['version'] = civitai_info.get('name', '')
|
||||
|
||||
# Get thumbnail URL from first image
|
||||
if 'images' in civitai_info and civitai_info['images']:
|
||||
checkpoint['thumbnailUrl'] = civitai_info['images'][0].get('url', '')
|
||||
|
||||
# Get base model
|
||||
checkpoint['baseModel'] = civitai_info.get('baseModel', '')
|
||||
|
||||
# Get download URL
|
||||
checkpoint['downloadUrl'] = civitai_info.get('downloadUrl', '')
|
||||
else:
|
||||
# Model not found or deleted
|
||||
checkpoint['isDeleted'] = True
|
||||
except Exception as e:
|
||||
logger.error(f"Error populating checkpoint from Civitai info: {e}")
|
||||
|
||||
return checkpoint
|
||||
16
py/recipes/constants.py
Normal file
16
py/recipes/constants.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants used across recipe parsers."""
|
||||
|
||||
# Constants for generation parameters
|
||||
GEN_PARAM_KEYS = [
|
||||
'prompt',
|
||||
'negative_prompt',
|
||||
'steps',
|
||||
'sampler',
|
||||
'cfg_scale',
|
||||
'seed',
|
||||
'size',
|
||||
'clip_skip',
|
||||
]
|
||||
|
||||
# Valid Lora types
|
||||
VALID_LORA_TYPES = ['lora', 'locon']
|
||||
43
py/recipes/factory.py
Normal file
43
py/recipes/factory.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Factory for creating recipe metadata parsers."""
|
||||
|
||||
import logging
|
||||
from .parsers import (
|
||||
RecipeFormatParser,
|
||||
ComfyMetadataParser,
|
||||
MetaFormatParser,
|
||||
AutomaticMetadataParser
|
||||
)
|
||||
from .base import RecipeMetadataParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeParserFactory:
|
||||
"""Factory for creating recipe metadata parsers"""
|
||||
|
||||
@staticmethod
|
||||
def create_parser(user_comment: str) -> RecipeMetadataParser:
|
||||
"""
|
||||
Create appropriate parser based on the user comment content
|
||||
|
||||
Args:
|
||||
user_comment: The EXIF UserComment string from the image
|
||||
|
||||
Returns:
|
||||
Appropriate RecipeMetadataParser implementation
|
||||
"""
|
||||
# Try ComfyMetadataParser first since it requires valid JSON
|
||||
try:
|
||||
if ComfyMetadataParser().is_metadata_matching(user_comment):
|
||||
return ComfyMetadataParser()
|
||||
except Exception:
|
||||
# If JSON parsing fails, move on to other parsers
|
||||
pass
|
||||
|
||||
if RecipeFormatParser().is_metadata_matching(user_comment):
|
||||
return RecipeFormatParser()
|
||||
elif AutomaticMetadataParser().is_metadata_matching(user_comment):
|
||||
return AutomaticMetadataParser()
|
||||
elif MetaFormatParser().is_metadata_matching(user_comment):
|
||||
return MetaFormatParser()
|
||||
else:
|
||||
return None
|
||||
13
py/recipes/parsers/__init__.py
Normal file
13
py/recipes/parsers/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Recipe parsers package."""
|
||||
|
||||
from .recipe_format import RecipeFormatParser
|
||||
from .comfy import ComfyMetadataParser
|
||||
from .meta_format import MetaFormatParser
|
||||
from .automatic import AutomaticMetadataParser
|
||||
|
||||
__all__ = [
|
||||
'RecipeFormatParser',
|
||||
'ComfyMetadataParser',
|
||||
'MetaFormatParser',
|
||||
'AutomaticMetadataParser',
|
||||
]
|
||||
304
py/recipes/parsers/automatic.py
Normal file
304
py/recipes/parsers/automatic.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""Parser for Automatic1111 metadata format."""
|
||||
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
"""Parser for Automatic1111 metadata format"""
|
||||
|
||||
METADATA_MARKER = r"Steps: \d+"
|
||||
|
||||
# Regular expressions for extracting specific metadata
|
||||
HASHES_REGEX = r', Hashes:\s*({[^}]+})'
|
||||
LORA_HASHES_REGEX = r', Lora hashes:\s*"([^"]+)"'
|
||||
CIVITAI_RESOURCES_REGEX = r', Civitai resources:\s*(\[\{.*?\}\])'
|
||||
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
|
||||
EXTRANETS_REGEX = r'<(lora|hypernet):([a-zA-Z0-9_\.\-]+):([0-9.]+)>'
|
||||
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
|
||||
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
|
||||
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
"""Check if the user comment matches the Automatic1111 format"""
|
||||
return re.search(self.METADATA_MARKER, user_comment) is not None
|
||||
|
||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||
"""Parse metadata from Automatic1111 format"""
|
||||
try:
|
||||
# Split on Negative prompt if it exists
|
||||
if "Negative prompt:" in user_comment:
|
||||
parts = user_comment.split('Negative prompt:', 1)
|
||||
prompt = parts[0].strip()
|
||||
negative_and_params = parts[1] if len(parts) > 1 else ""
|
||||
else:
|
||||
# No negative prompt section
|
||||
param_start = re.search(self.METADATA_MARKER, user_comment)
|
||||
if param_start:
|
||||
prompt = user_comment[:param_start.start()].strip()
|
||||
negative_and_params = user_comment[param_start.start():]
|
||||
else:
|
||||
prompt = user_comment.strip()
|
||||
negative_and_params = ""
|
||||
|
||||
# Initialize metadata
|
||||
metadata = {
|
||||
"prompt": prompt,
|
||||
"loras": []
|
||||
}
|
||||
|
||||
# Extract negative prompt and parameters
|
||||
if negative_and_params:
|
||||
# If we split on "Negative prompt:", check for params section
|
||||
if "Negative prompt:" in user_comment:
|
||||
param_start = re.search(r'Steps: ', negative_and_params)
|
||||
if param_start:
|
||||
neg_prompt = negative_and_params[:param_start.start()].strip()
|
||||
metadata["negative_prompt"] = neg_prompt
|
||||
params_section = negative_and_params[param_start.start():]
|
||||
else:
|
||||
metadata["negative_prompt"] = negative_and_params.strip()
|
||||
params_section = ""
|
||||
else:
|
||||
# No negative prompt, entire section is params
|
||||
params_section = negative_and_params
|
||||
|
||||
# Extract generation parameters
|
||||
if params_section:
|
||||
# Extract Civitai resources
|
||||
civitai_resources_match = re.search(self.CIVITAI_RESOURCES_REGEX, params_section)
|
||||
if civitai_resources_match:
|
||||
try:
|
||||
civitai_resources = json.loads(civitai_resources_match.group(1))
|
||||
metadata["civitai_resources"] = civitai_resources
|
||||
params_section = params_section.replace(civitai_resources_match.group(0), '')
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Error parsing Civitai resources JSON")
|
||||
|
||||
# Extract Hashes
|
||||
hashes_match = re.search(self.HASHES_REGEX, params_section)
|
||||
if hashes_match:
|
||||
try:
|
||||
hashes = json.loads(hashes_match.group(1))
|
||||
# Process hash keys
|
||||
processed_hashes = {}
|
||||
for key, value in hashes.items():
|
||||
# Convert Model: or LORA: prefix to lowercase if present
|
||||
if ':' in key:
|
||||
prefix, name = key.split(':', 1)
|
||||
prefix = prefix.lower()
|
||||
else:
|
||||
prefix = ''
|
||||
name = key
|
||||
|
||||
# Clean up the name part
|
||||
if '/' in name:
|
||||
name = name.split('/')[-1] # Get last part after /
|
||||
if '.safetensors' in name:
|
||||
name = name.split('.safetensors')[0] # Remove .safetensors
|
||||
|
||||
# Reconstruct the key
|
||||
new_key = f"{prefix}:{name}" if prefix else name
|
||||
processed_hashes[new_key] = value
|
||||
|
||||
metadata["hashes"] = processed_hashes
|
||||
# Remove hashes from params section to not interfere with other parsing
|
||||
params_section = params_section.replace(hashes_match.group(0), '')
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Error parsing hashes JSON")
|
||||
|
||||
# Extract Lora hashes in alternative format
|
||||
lora_hashes_match = re.search(self.LORA_HASHES_REGEX, params_section)
|
||||
if not hashes_match and lora_hashes_match:
|
||||
try:
|
||||
lora_hashes_str = lora_hashes_match.group(1)
|
||||
lora_hash_entries = lora_hashes_str.split(', ')
|
||||
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
|
||||
# Parse each lora hash entry (format: "name: hash")
|
||||
for entry in lora_hash_entries:
|
||||
if ': ' in entry:
|
||||
lora_name, lora_hash = entry.split(': ', 1)
|
||||
# Add as lora type in the same format as regular hashes
|
||||
metadata["hashes"][f"lora:{lora_name}"] = lora_hash.strip()
|
||||
|
||||
# Remove lora hashes from params section
|
||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Lora hashes: {e}")
|
||||
|
||||
# Extract basic parameters
|
||||
param_pattern = r'([A-Za-z\s]+): ([^,]+)'
|
||||
params = re.findall(param_pattern, params_section)
|
||||
gen_params = {}
|
||||
|
||||
for key, value in params:
|
||||
clean_key = key.strip().lower().replace(' ', '_')
|
||||
|
||||
# Skip if not in recognized gen param keys
|
||||
if clean_key not in GEN_PARAM_KEYS:
|
||||
continue
|
||||
|
||||
# Convert numeric values
|
||||
if clean_key in ['steps', 'seed']:
|
||||
try:
|
||||
gen_params[clean_key] = int(value.strip())
|
||||
except ValueError:
|
||||
gen_params[clean_key] = value.strip()
|
||||
elif clean_key in ['cfg_scale']:
|
||||
try:
|
||||
gen_params[clean_key] = float(value.strip())
|
||||
except ValueError:
|
||||
gen_params[clean_key] = value.strip()
|
||||
else:
|
||||
gen_params[clean_key] = value.strip()
|
||||
|
||||
# Extract size if available and add to gen_params if a recognized key
|
||||
size_match = re.search(r'Size: (\d+)x(\d+)', params_section)
|
||||
if size_match and 'size' in GEN_PARAM_KEYS:
|
||||
width, height = size_match.groups()
|
||||
gen_params['size'] = f"{width}x{height}"
|
||||
|
||||
# Add prompt and negative_prompt to gen_params if they're in GEN_PARAM_KEYS
|
||||
if 'prompt' in GEN_PARAM_KEYS and 'prompt' in metadata:
|
||||
gen_params['prompt'] = metadata['prompt']
|
||||
if 'negative_prompt' in GEN_PARAM_KEYS and 'negative_prompt' in metadata:
|
||||
gen_params['negative_prompt'] = metadata['negative_prompt']
|
||||
|
||||
metadata["gen_params"] = gen_params
|
||||
|
||||
# Extract LoRA information
|
||||
loras = []
|
||||
base_model_counts = {}
|
||||
|
||||
# First use Civitai resources if available (more reliable source)
|
||||
if metadata.get("civitai_resources"):
|
||||
for resource in metadata.get("civitai_resources", []):
|
||||
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
||||
# Initialize lora entry
|
||||
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
|
||||
}
|
||||
|
||||
# Get additional info from Civitai
|
||||
if civitai_client:
|
||||
try:
|
||||
civitai_info = await civitai_client.get_model_version_info(resource.get("modelVersionId"))
|
||||
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 LoRA {lora_entry['name']}: {e}")
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
||||
if not loras or len(loras) == 0:
|
||||
# Extract lora weights from extranet tags in prompt (for later use)
|
||||
lora_weights = {}
|
||||
lora_matches = re.findall(self.EXTRANETS_REGEX, prompt)
|
||||
for lora_type, lora_name, lora_weight in lora_matches:
|
||||
key = f"{lora_type}:{lora_name}"
|
||||
lora_weights[key] = round(float(lora_weight), 2)
|
||||
|
||||
# Use hashes from metadata as the primary source
|
||||
if metadata.get("hashes"):
|
||||
for hash_key, lora_hash in metadata.get("hashes", {}).items():
|
||||
# Only process lora or hypernet types
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
continue
|
||||
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
|
||||
# Get weight from extranet tags if available, else default to 1.0
|
||||
weight = lora_weights.get(hash_key, 1.0)
|
||||
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
'name': lora_name,
|
||||
'type': lora_type, # 'lora' or 'hypernet'
|
||||
'weight': weight,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Try to get info from Civitai
|
||||
if civitai_client:
|
||||
try:
|
||||
if lora_hash:
|
||||
# If we have hash, use it for lookup
|
||||
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
|
||||
else:
|
||||
civitai_info = None
|
||||
|
||||
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 {lora_name}: {e}")
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Try to get base model from resources or make educated guess
|
||||
base_model = None
|
||||
if base_model_counts:
|
||||
# Use the most common base model from the loras
|
||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||
|
||||
# Prepare final result structure
|
||||
# Make sure gen_params only contains recognized keys
|
||||
filtered_gen_params = {}
|
||||
for key in GEN_PARAM_KEYS:
|
||||
if key in metadata.get("gen_params", {}):
|
||||
filtered_gen_params[key] = metadata["gen_params"][key]
|
||||
|
||||
result = {
|
||||
'base_model': base_model,
|
||||
'loras': loras,
|
||||
'gen_params': filtered_gen_params,
|
||||
'from_automatic_metadata': True
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing Automatic1111 metadata: {e}", exc_info=True)
|
||||
return {"error": str(e), "loras": []}
|
||||
216
py/recipes/parsers/comfy.py
Normal file
216
py/recipes/parsers/comfy.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Parser for ComfyUI metadata format."""
|
||||
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ComfyMetadataParser(RecipeMetadataParser):
|
||||
"""Parser for Civitai ComfyUI metadata JSON format"""
|
||||
|
||||
METADATA_MARKER = r"class_type"
|
||||
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
"""Check if the user comment matches the ComfyUI metadata format"""
|
||||
try:
|
||||
data = json.loads(user_comment)
|
||||
# Check if it contains class_type nodes typical of ComfyUI workflow
|
||||
return isinstance(data, dict) and any(isinstance(v, dict) and 'class_type' in v for v in data.values())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return False
|
||||
|
||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||
"""Parse metadata from Civitai ComfyUI metadata format"""
|
||||
try:
|
||||
data = json.loads(user_comment)
|
||||
loras = []
|
||||
|
||||
# Find all LoraLoader nodes
|
||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
||||
|
||||
if not lora_nodes:
|
||||
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
|
||||
|
||||
# Process each LoraLoader node
|
||||
for node_id, node in lora_nodes.items():
|
||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||
continue
|
||||
|
||||
lora_name = node['inputs'].get('lora_name', '')
|
||||
|
||||
# Parse the URN to extract model ID and version ID
|
||||
# Format: "urn:air:sdxl:lora:civitai:1107767@1253442"
|
||||
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
|
||||
if not lora_id_match:
|
||||
continue
|
||||
|
||||
model_id = lora_id_match.group(1)
|
||||
model_version_id = lora_id_match.group(2)
|
||||
|
||||
# Get strength from node inputs
|
||||
weight = node['inputs'].get('strength_model', 1.0)
|
||||
|
||||
# Initialize lora entry with default values
|
||||
lora_entry = {
|
||||
'id': model_version_id,
|
||||
'modelId': model_id,
|
||||
'name': f"Lora {model_id}", # Default name
|
||||
'version': '',
|
||||
'type': 'lora',
|
||||
'weight': weight,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': '',
|
||||
'hash': '',
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Get additional info from Civitai if client is available
|
||||
if civitai_client:
|
||||
try:
|
||||
civitai_info_tuple = await civitai_client.get_model_version_info(model_version_id)
|
||||
# Populate lora entry with Civitai info
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Find checkpoint info
|
||||
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
|
||||
checkpoint = None
|
||||
checkpoint_id = None
|
||||
checkpoint_version_id = None
|
||||
|
||||
if checkpoint_nodes:
|
||||
# Get the first checkpoint node
|
||||
checkpoint_node = next(iter(checkpoint_nodes.values()))
|
||||
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
|
||||
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
|
||||
# Parse checkpoint URN
|
||||
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
|
||||
if checkpoint_match:
|
||||
checkpoint_id = checkpoint_match.group(1)
|
||||
checkpoint_version_id = checkpoint_match.group(2)
|
||||
checkpoint = {
|
||||
'id': checkpoint_version_id,
|
||||
'modelId': checkpoint_id,
|
||||
'name': f"Checkpoint {checkpoint_id}",
|
||||
'version': '',
|
||||
'type': 'checkpoint'
|
||||
}
|
||||
|
||||
# Get additional checkpoint info from Civitai
|
||||
if civitai_client:
|
||||
try:
|
||||
civitai_info_tuple = await civitai_client.get_model_version_info(checkpoint_version_id)
|
||||
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||
# Populate checkpoint with Civitai info
|
||||
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
|
||||
|
||||
# Extract generation parameters
|
||||
gen_params = {}
|
||||
|
||||
# First try to get from extraMetadata
|
||||
if 'extraMetadata' in data:
|
||||
try:
|
||||
# extraMetadata is a JSON string that needs to be parsed
|
||||
extra_metadata = json.loads(data['extraMetadata'])
|
||||
|
||||
# Map fields from extraMetadata to our standard format
|
||||
mapping = {
|
||||
'prompt': 'prompt',
|
||||
'negativePrompt': 'negative_prompt',
|
||||
'steps': 'steps',
|
||||
'sampler': 'sampler',
|
||||
'cfgScale': 'cfg_scale',
|
||||
'seed': 'seed'
|
||||
}
|
||||
|
||||
for src_key, dest_key in mapping.items():
|
||||
if src_key in extra_metadata:
|
||||
gen_params[dest_key] = extra_metadata[src_key]
|
||||
|
||||
# If size info is available, format as "width x height"
|
||||
if 'width' in extra_metadata and 'height' in extra_metadata:
|
||||
gen_params['size'] = f"{extra_metadata['width']}x{extra_metadata['height']}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing extraMetadata: {e}")
|
||||
|
||||
# If extraMetadata doesn't have all the info, try to get from nodes
|
||||
if not gen_params or len(gen_params) < 3: # At least we want prompt, negative_prompt, and steps
|
||||
# Find positive prompt node
|
||||
positive_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and
|
||||
v.get('class_type', '').endswith('CLIPTextEncode') and
|
||||
v.get('_meta', {}).get('title') == 'Positive'}
|
||||
|
||||
if positive_nodes:
|
||||
positive_node = next(iter(positive_nodes.values()))
|
||||
if 'inputs' in positive_node and 'text' in positive_node['inputs']:
|
||||
gen_params['prompt'] = positive_node['inputs']['text']
|
||||
|
||||
# Find negative prompt node
|
||||
negative_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and
|
||||
v.get('class_type', '').endswith('CLIPTextEncode') and
|
||||
v.get('_meta', {}).get('title') == 'Negative'}
|
||||
|
||||
if negative_nodes:
|
||||
negative_node = next(iter(negative_nodes.values()))
|
||||
if 'inputs' in negative_node and 'text' in negative_node['inputs']:
|
||||
gen_params['negative_prompt'] = negative_node['inputs']['text']
|
||||
|
||||
# Find KSampler node for other parameters
|
||||
ksampler_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'KSampler'}
|
||||
|
||||
if ksampler_nodes:
|
||||
ksampler_node = next(iter(ksampler_nodes.values()))
|
||||
if 'inputs' in ksampler_node:
|
||||
inputs = ksampler_node['inputs']
|
||||
if 'sampler_name' in inputs:
|
||||
gen_params['sampler'] = inputs['sampler_name']
|
||||
if 'steps' in inputs:
|
||||
gen_params['steps'] = inputs['steps']
|
||||
if 'cfg' in inputs:
|
||||
gen_params['cfg_scale'] = inputs['cfg']
|
||||
if 'seed' in inputs:
|
||||
gen_params['seed'] = inputs['seed']
|
||||
|
||||
# Determine base model from loras info
|
||||
base_model = None
|
||||
if loras:
|
||||
# Use the most common base model from loras
|
||||
base_models = [lora['baseModel'] for lora in loras if lora.get('baseModel')]
|
||||
if base_models:
|
||||
from collections import Counter
|
||||
base_model_counts = Counter(base_models)
|
||||
base_model = base_model_counts.most_common(1)[0][0]
|
||||
|
||||
return {
|
||||
'base_model': base_model,
|
||||
'loras': loras,
|
||||
'checkpoint': checkpoint,
|
||||
'gen_params': gen_params,
|
||||
'from_comfy_metadata': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing ComfyUI metadata: {e}", exc_info=True)
|
||||
return {"error": str(e), "loras": []}
|
||||
174
py/recipes/parsers/meta_format.py
Normal file
174
py/recipes/parsers/meta_format.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Parser for meta format (Lora_N Model hash) metadata."""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class MetaFormatParser(RecipeMetadataParser):
|
||||
"""Parser for images with meta format metadata (Lora_N Model hash format)"""
|
||||
|
||||
METADATA_MARKER = r'Lora_\d+ Model hash:'
|
||||
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
"""Check if the user comment matches the metadata format"""
|
||||
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
||||
|
||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||
"""Parse metadata from images with meta format metadata"""
|
||||
try:
|
||||
# Extract prompt and negative prompt
|
||||
parts = user_comment.split('Negative prompt:', 1)
|
||||
prompt = parts[0].strip()
|
||||
|
||||
# Initialize metadata
|
||||
metadata = {"prompt": prompt, "loras": []}
|
||||
|
||||
# Extract negative prompt and parameters if available
|
||||
if len(parts) > 1:
|
||||
negative_and_params = parts[1]
|
||||
|
||||
# Extract negative prompt - everything until the first parameter (usually "Steps:")
|
||||
param_start = re.search(r'([A-Za-z]+): ', negative_and_params)
|
||||
if param_start:
|
||||
neg_prompt = negative_and_params[:param_start.start()].strip()
|
||||
metadata["negative_prompt"] = neg_prompt
|
||||
params_section = negative_and_params[param_start.start():]
|
||||
else:
|
||||
params_section = negative_and_params
|
||||
|
||||
# Extract key-value parameters (Steps, Sampler, Seed, etc.)
|
||||
param_pattern = r'([A-Za-z_0-9 ]+): ([^,]+)'
|
||||
params = re.findall(param_pattern, params_section)
|
||||
for key, value in params:
|
||||
clean_key = key.strip().lower().replace(' ', '_')
|
||||
metadata[clean_key] = value.strip()
|
||||
|
||||
# Extract LoRA information
|
||||
# Pattern to match lora entries: Lora_0 Model name: ArtVador I.safetensors, Lora_0 Model hash: 08f7133a58, etc.
|
||||
lora_pattern = r'Lora_(\d+) Model name: ([^,]+), Lora_\1 Model hash: ([^,]+), Lora_\1 Strength model: ([^,]+), Lora_\1 Strength clip: ([^,]+)'
|
||||
lora_matches = re.findall(lora_pattern, user_comment)
|
||||
|
||||
# If the regular pattern doesn't match, try a more flexible approach
|
||||
if not lora_matches:
|
||||
# First find all Lora indices
|
||||
lora_indices = set(re.findall(r'Lora_(\d+)', user_comment))
|
||||
|
||||
# For each index, extract the information
|
||||
for idx in lora_indices:
|
||||
lora_info = {}
|
||||
|
||||
# Extract model name
|
||||
name_match = re.search(f'Lora_{idx} Model name: ([^,]+)', user_comment)
|
||||
if name_match:
|
||||
lora_info['name'] = name_match.group(1).strip()
|
||||
|
||||
# Extract model hash
|
||||
hash_match = re.search(f'Lora_{idx} Model hash: ([^,]+)', user_comment)
|
||||
if hash_match:
|
||||
lora_info['hash'] = hash_match.group(1).strip()
|
||||
|
||||
# Extract strength model
|
||||
strength_model_match = re.search(f'Lora_{idx} Strength model: ([^,]+)', user_comment)
|
||||
if strength_model_match:
|
||||
lora_info['strength_model'] = float(strength_model_match.group(1).strip())
|
||||
|
||||
# Extract strength clip
|
||||
strength_clip_match = re.search(f'Lora_{idx} Strength clip: ([^,]+)', user_comment)
|
||||
if strength_clip_match:
|
||||
lora_info['strength_clip'] = float(strength_clip_match.group(1).strip())
|
||||
|
||||
# Only add if we have at least name and hash
|
||||
if 'name' in lora_info and 'hash' in lora_info:
|
||||
lora_matches.append((idx, lora_info['name'], lora_info['hash'],
|
||||
str(lora_info.get('strength_model', 1.0)),
|
||||
str(lora_info.get('strength_clip', 1.0))))
|
||||
|
||||
# Process LoRAs
|
||||
base_model_counts = {}
|
||||
loras = []
|
||||
|
||||
for match in lora_matches:
|
||||
if len(match) == 5: # Regular pattern match
|
||||
idx, name, hash_value, strength_model, strength_clip = match
|
||||
else: # Flexible approach match
|
||||
continue # Should not happen now
|
||||
|
||||
# Clean up the values
|
||||
name = name.strip()
|
||||
if name.endswith('.safetensors'):
|
||||
name = name[:-12] # Remove .safetensors extension
|
||||
|
||||
hash_value = hash_value.strip()
|
||||
weight = float(strength_model) # Use model strength as weight
|
||||
|
||||
# Initialize lora entry with default values
|
||||
lora_entry = {
|
||||
'name': name,
|
||||
'type': 'lora',
|
||||
'weight': weight,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': name,
|
||||
'hash': hash_value,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Get info from Civitai by hash if available
|
||||
if civitai_client and hash_value:
|
||||
try:
|
||||
civitai_info = await civitai_client.get_model_by_hash(hash_value)
|
||||
# Populate lora entry with Civitai info
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
hash_value
|
||||
)
|
||||
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 {hash_value}: {e}")
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Extract model information
|
||||
model = None
|
||||
if 'model' in metadata:
|
||||
model = metadata['model']
|
||||
|
||||
# Set base_model to the most common one from civitai_info
|
||||
base_model = None
|
||||
if base_model_counts:
|
||||
base_model = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||
|
||||
# Extract generation parameters for recipe metadata
|
||||
gen_params = {}
|
||||
for key in GEN_PARAM_KEYS:
|
||||
if key in metadata:
|
||||
gen_params[key] = metadata.get(key, '')
|
||||
|
||||
# Try to extract size information if available
|
||||
if 'width' in metadata and 'height' in metadata:
|
||||
gen_params['size'] = f"{metadata['width']}x{metadata['height']}"
|
||||
|
||||
return {
|
||||
'base_model': base_model,
|
||||
'loras': loras,
|
||||
'gen_params': gen_params,
|
||||
'raw_metadata': metadata,
|
||||
'from_meta_format': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing meta format metadata: {e}", exc_info=True)
|
||||
return {"error": str(e), "loras": []}
|
||||
114
py/recipes/parsers/recipe_format.py
Normal file
114
py/recipes/parsers/recipe_format.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Parser for dedicated recipe metadata format."""
|
||||
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from ...config import config
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RecipeFormatParser(RecipeMetadataParser):
|
||||
"""Parser for images with dedicated recipe metadata format"""
|
||||
|
||||
# Regular expression pattern for extracting recipe metadata
|
||||
METADATA_MARKER = r'Recipe metadata: (\{.*\})'
|
||||
|
||||
def is_metadata_matching(self, user_comment: str) -> bool:
|
||||
"""Check if the user comment matches the metadata format"""
|
||||
return re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL) is not None
|
||||
|
||||
async def parse_metadata(self, user_comment: str, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||
"""Parse metadata from images with dedicated recipe metadata format"""
|
||||
try:
|
||||
# Extract recipe metadata from user comment
|
||||
try:
|
||||
# Look for recipe metadata section
|
||||
recipe_match = re.search(self.METADATA_MARKER, user_comment, re.IGNORECASE | re.DOTALL)
|
||||
if not recipe_match:
|
||||
recipe_metadata = None
|
||||
else:
|
||||
recipe_json = recipe_match.group(1)
|
||||
recipe_metadata = json.loads(recipe_json)
|
||||
except Exception as e:
|
||||
logger.error(f"Error extracting recipe metadata: {e}")
|
||||
recipe_metadata = None
|
||||
if not recipe_metadata:
|
||||
return {"error": "No recipe metadata found", "loras": []}
|
||||
|
||||
# Process the recipe metadata
|
||||
loras = []
|
||||
for lora in recipe_metadata.get('loras', []):
|
||||
# Convert recipe lora format to frontend format
|
||||
lora_entry = {
|
||||
'id': lora.get('modelVersionId', ''),
|
||||
'name': lora.get('modelName', ''),
|
||||
'version': lora.get('modelVersionName', ''),
|
||||
'type': 'lora',
|
||||
'weight': lora.get('strength', 1.0),
|
||||
'file_name': lora.get('file_name', ''),
|
||||
'hash': lora.get('hash', '')
|
||||
}
|
||||
|
||||
# Check if this LoRA exists locally by SHA256 hash
|
||||
if lora.get('hash') and recipe_scanner:
|
||||
lora_scanner = recipe_scanner._lora_scanner
|
||||
exists_locally = lora_scanner.has_lora_hash(lora['hash'])
|
||||
if exists_locally:
|
||||
lora_cache = await lora_scanner.get_cached_data()
|
||||
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||
if lora_item:
|
||||
lora_entry['existsLocally'] = True
|
||||
lora_entry['localPath'] = lora_item['file_path']
|
||||
lora_entry['file_name'] = lora_item['file_name']
|
||||
lora_entry['size'] = lora_item['size']
|
||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(lora_item['preview_url'])
|
||||
|
||||
else:
|
||||
lora_entry['existsLocally'] = False
|
||||
lora_entry['localPath'] = None
|
||||
|
||||
# Try to get additional info from Civitai if we have a model version ID
|
||||
if lora.get('modelVersionId') and civitai_client:
|
||||
try:
|
||||
civitai_info_tuple = await civitai_client.get_model_version_info(lora['modelVersionId'])
|
||||
# Populate lora entry with Civitai info
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner,
|
||||
None, # No need to track base model counts
|
||||
lora['hash']
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
lora_entry['thumbnailUrl'] = '/loras_static/images/no-preview.png'
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
logger.info(f"Found {len(loras)} loras in recipe metadata")
|
||||
|
||||
# Filter gen_params to only include recognized keys
|
||||
filtered_gen_params = {}
|
||||
if 'gen_params' in recipe_metadata:
|
||||
for key, value in recipe_metadata['gen_params'].items():
|
||||
if key in GEN_PARAM_KEYS:
|
||||
filtered_gen_params[key] = value
|
||||
|
||||
return {
|
||||
'base_model': recipe_metadata.get('base_model', ''),
|
||||
'loras': loras,
|
||||
'gen_params': filtered_gen_params,
|
||||
'tags': recipe_metadata.get('tags', []),
|
||||
'title': recipe_metadata.get('title', ''),
|
||||
'from_recipe_metadata': True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
|
||||
return {"error": str(e), "loras": []}
|
||||
@@ -72,6 +72,10 @@ class ApiRoutes:
|
||||
|
||||
# Add new endpoint for letter counts
|
||||
app.router.add_get('/api/loras/letter-counts', routes.get_letter_counts)
|
||||
|
||||
# Add new endpoints for copying lora data
|
||||
app.router.add_get('/api/loras/get-notes', routes.get_lora_notes)
|
||||
app.router.add_get('/api/loras/get-trigger-words', routes.get_lora_trigger_words)
|
||||
|
||||
# Add update check routes
|
||||
UpdateRoutes.setup_routes(app)
|
||||
@@ -102,8 +106,11 @@ class ApiRoutes:
|
||||
|
||||
async def scan_loras(self, request: web.Request) -> web.Response:
|
||||
"""Force a rescan of LoRA files"""
|
||||
try:
|
||||
await self.scanner.get_cached_data(force_refresh=True)
|
||||
try:
|
||||
# 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"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan_loras: {e}", exc_info=True)
|
||||
@@ -386,10 +393,10 @@ class ApiRoutes:
|
||||
versions = response.get('modelVersions', [])
|
||||
model_type = response.get('type', '')
|
||||
|
||||
# Check model type - should be LORA
|
||||
if model_type.lower() != 'lora':
|
||||
# Check model type - should be LORA or LoCon
|
||||
if model_type.lower() not in ['lora', 'locon']:
|
||||
return web.json_response({
|
||||
'error': f"Model type mismatch. Expected LORA, got {model_type}"
|
||||
'error': f"Model type mismatch. Expected LORA or LoCon, got {model_type}"
|
||||
}, status=400)
|
||||
|
||||
# Check local availability for each version
|
||||
@@ -508,7 +515,7 @@ class ApiRoutes:
|
||||
logger.warning(f"Early access download failed: {error_message}")
|
||||
return web.Response(
|
||||
status=401, # Use 401 status code to match Civitai's response
|
||||
text=f"Early Access Restriction: {error_message}"
|
||||
text=error_message
|
||||
)
|
||||
|
||||
return web.Response(status=500, text=error_message)
|
||||
@@ -1084,3 +1091,81 @@ class ApiRoutes:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_lora_notes(self, request: web.Request) -> web.Response:
|
||||
"""Get notes for a specific LoRA file"""
|
||||
try:
|
||||
if self.scanner is None:
|
||||
self.scanner = await ServiceRegistry.get_lora_scanner()
|
||||
|
||||
# Get lora file name from query parameters
|
||||
lora_name = request.query.get('name')
|
||||
if not lora_name:
|
||||
return web.Response(text='Lora file name is required', status=400)
|
||||
|
||||
# Get cache data
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
# Search for the lora in cache data
|
||||
for lora in cache.raw_data:
|
||||
file_name = lora['file_name']
|
||||
if file_name == lora_name:
|
||||
notes = lora.get('notes', '')
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'notes': notes
|
||||
})
|
||||
|
||||
# If lora not found
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'LoRA not found in cache'
|
||||
}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora notes: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_lora_trigger_words(self, request: web.Request) -> web.Response:
|
||||
"""Get trigger words for a specific LoRA file"""
|
||||
try:
|
||||
if self.scanner is None:
|
||||
self.scanner = await ServiceRegistry.get_lora_scanner()
|
||||
|
||||
# Get lora file name from query parameters
|
||||
lora_name = request.query.get('name')
|
||||
if not lora_name:
|
||||
return web.Response(text='Lora file name is required', status=400)
|
||||
|
||||
# Get cache data
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
# Search for the lora in cache data
|
||||
for lora in cache.raw_data:
|
||||
file_name = lora['file_name']
|
||||
if file_name == lora_name:
|
||||
# Get trigger words from civitai data
|
||||
civitai_data = lora.get('civitai', {})
|
||||
trigger_words = civitai_data.get('trainedWords', [])
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'trigger_words': trigger_words
|
||||
})
|
||||
|
||||
# If lora not found
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'LoRA not found in cache'
|
||||
}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora trigger words: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@@ -420,7 +420,10 @@ class CheckpointsRoutes:
|
||||
async def scan_checkpoints(self, request):
|
||||
"""Force a rescan of checkpoint files"""
|
||||
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"})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in scan_checkpoints: {e}", exc_info=True)
|
||||
@@ -430,7 +433,7 @@ class CheckpointsRoutes:
|
||||
"""Get detailed information for a specific checkpoint by name"""
|
||||
try:
|
||||
name = request.match_info.get('name', '')
|
||||
checkpoint_info = await self.scanner.get_checkpoint_info_by_name(name)
|
||||
checkpoint_info = await self.scanner.get_model_info_by_name(name)
|
||||
|
||||
if checkpoint_info:
|
||||
return web.json_response(checkpoint_info)
|
||||
|
||||
@@ -4,12 +4,15 @@ import asyncio
|
||||
import json
|
||||
import time
|
||||
import aiohttp
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from server import PromptServer # type: ignore
|
||||
from aiohttp import web
|
||||
from ..services.settings_manager import settings
|
||||
from ..utils.usage_stats import UsageStats
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.constants import EXAMPLE_IMAGE_WIDTH, SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||
from ..services.civitai_client import CivitaiClient
|
||||
from ..utils.routes_common import ModelRouteUtils
|
||||
|
||||
@@ -38,6 +41,9 @@ class MiscRoutes:
|
||||
def setup_routes(app):
|
||||
"""Register miscellaneous routes"""
|
||||
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
|
||||
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
|
||||
@@ -49,6 +55,61 @@ class MiscRoutes:
|
||||
app.router.add_post('/api/pause-example-images', MiscRoutes.pause_example_images)
|
||||
app.router.add_post('/api/resume-example-images', MiscRoutes.resume_example_images)
|
||||
|
||||
# Lora code update endpoint
|
||||
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
|
||||
|
||||
# Add new route for opening example images folder
|
||||
app.router.add_post('/api/open-example-images-folder', MiscRoutes.open_example_images_folder)
|
||||
|
||||
@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
|
||||
async def update_settings(request):
|
||||
"""Update application settings"""
|
||||
@@ -166,7 +227,7 @@ class MiscRoutes:
|
||||
output_dir = data.get('output_dir')
|
||||
optimize = data.get('optimize', True)
|
||||
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
||||
delay = float(data.get('delay', 0.2))
|
||||
delay = float(data.get('delay', 0.1)) # Default to 0.1 seconds
|
||||
|
||||
if not output_dir:
|
||||
return web.json_response({
|
||||
@@ -350,6 +411,28 @@ class MiscRoutes:
|
||||
download_progress['last_error'] = error_msg
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _get_civitai_optimized_url(image_url):
|
||||
"""Convert a Civitai image URL to its optimized WebP version
|
||||
|
||||
Args:
|
||||
image_url: Original Civitai image URL
|
||||
|
||||
Returns:
|
||||
str: URL to optimized WebP version
|
||||
"""
|
||||
# Match the base part of Civitai URLs
|
||||
base_pattern = r'(https://image\.civitai\.com/[^/]+/[^/]+)'
|
||||
match = re.match(base_pattern, image_url)
|
||||
|
||||
if match:
|
||||
base_url = match.group(1)
|
||||
# Create the optimized WebP URL
|
||||
return f"{base_url}/optimized=true/image.webp"
|
||||
|
||||
# Return original URL if it doesn't match the expected format
|
||||
return image_url
|
||||
|
||||
@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
|
||||
@@ -389,6 +472,13 @@ class MiscRoutes:
|
||||
|
||||
save_filename = f"image_{i}{image_ext}"
|
||||
|
||||
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
|
||||
if is_image and optimize and 'civitai.com' in image_url:
|
||||
# Transform URL to use Civitai's optimized WebP version
|
||||
image_url = MiscRoutes._get_civitai_optimized_url(image_url)
|
||||
# Update filename to use .webp extension
|
||||
save_filename = f"image_{i}.webp"
|
||||
|
||||
# Check if already downloaded
|
||||
save_path = os.path.join(model_dir, save_filename)
|
||||
if os.path.exists(save_path):
|
||||
@@ -402,31 +492,10 @@ class MiscRoutes:
|
||||
# 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)
|
||||
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)
|
||||
@@ -502,35 +571,11 @@ class MiscRoutes:
|
||||
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())
|
||||
# For local files, we just copy them without optimization
|
||||
# since we don't want to modify user files
|
||||
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
|
||||
@@ -765,3 +810,144 @@ class MiscRoutes:
|
||||
|
||||
# Set download status to not downloading
|
||||
is_downloading = False
|
||||
|
||||
@staticmethod
|
||||
async def update_lora_code(request):
|
||||
"""
|
||||
Update Lora code in ComfyUI nodes
|
||||
|
||||
Expects a JSON body with:
|
||||
{
|
||||
"node_ids": [123, 456], # Optional - List of node IDs to update (for browser mode)
|
||||
"lora_code": "<lora:modelname:1.0>", # The Lora code to send
|
||||
"mode": "append" # or "replace" - whether to append or replace existing code
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse the request body
|
||||
data = await request.json()
|
||||
node_ids = data.get('node_ids')
|
||||
lora_code = data.get('lora_code', '')
|
||||
mode = data.get('mode', 'append')
|
||||
|
||||
if not lora_code:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Missing lora_code parameter'
|
||||
}, status=400)
|
||||
|
||||
results = []
|
||||
|
||||
# Desktop mode: no specific node_ids provided
|
||||
if node_ids is None:
|
||||
try:
|
||||
# Send broadcast message with id=-1 to all Lora Loader nodes
|
||||
PromptServer.instance.send_sync("lora_code_update", {
|
||||
"id": -1,
|
||||
"lora_code": lora_code,
|
||||
"mode": mode
|
||||
})
|
||||
results.append({
|
||||
'node_id': 'broadcast',
|
||||
'success': True
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error broadcasting lora code: {e}")
|
||||
results.append({
|
||||
'node_id': 'broadcast',
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
else:
|
||||
# Browser mode: send to specific nodes
|
||||
for node_id in node_ids:
|
||||
try:
|
||||
# Send the message to the frontend
|
||||
PromptServer.instance.send_sync("lora_code_update", {
|
||||
"id": node_id,
|
||||
"lora_code": lora_code,
|
||||
"mode": mode
|
||||
})
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'success': True
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending lora code to node {node_id}: {e}")
|
||||
results.append({
|
||||
'node_id': node_id,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'results': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update lora code: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@staticmethod
|
||||
async def open_example_images_folder(request):
|
||||
"""
|
||||
Open the example images folder for a specific model
|
||||
|
||||
Expects a JSON body with:
|
||||
{
|
||||
"model_hash": "sha256_hash" # SHA256 hash of the model
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# Parse the request body
|
||||
data = await request.json()
|
||||
model_hash = data.get('model_hash')
|
||||
|
||||
if not model_hash:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Missing model_hash parameter'
|
||||
}, status=400)
|
||||
|
||||
# Get the example images path from settings
|
||||
example_images_path = settings.get('example_images_path')
|
||||
if not example_images_path:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No example images path configured. Please set it in the settings panel first.'
|
||||
}, status=400)
|
||||
|
||||
# Construct the folder path for this model
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
|
||||
# Check if the folder exists
|
||||
if not os.path.exists(model_folder):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No example images found for this model. Download example images first.'
|
||||
}, status=404)
|
||||
|
||||
# Open the folder in the file explorer
|
||||
if os.name == 'nt': # Windows
|
||||
os.startfile(model_folder)
|
||||
elif os.name == 'posix': # macOS and Linux
|
||||
if sys.platform == 'darwin': # macOS
|
||||
subprocess.Popen(['open', model_folder])
|
||||
else: # Linux
|
||||
subprocess.Popen(['xdg-open', model_folder])
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Opened example images folder for model {model_hash}'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open example images folder: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
import base64
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import torch
|
||||
@@ -12,7 +13,7 @@ import json
|
||||
import asyncio
|
||||
import sys
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.recipe_parsers import RecipeParserFactory
|
||||
from ..recipes import RecipeParserFactory
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
|
||||
from ..config import config
|
||||
@@ -56,6 +57,7 @@ class RecipeRoutes:
|
||||
app.router.add_get('/api/recipes', routes.get_recipes)
|
||||
app.router.add_get('/api/recipe/{recipe_id}', routes.get_recipe_detail)
|
||||
app.router.add_post('/api/recipes/analyze-image', routes.analyze_recipe_image)
|
||||
app.router.add_post('/api/recipes/analyze-local-image', routes.analyze_local_image)
|
||||
app.router.add_post('/api/recipes/save', routes.save_recipe)
|
||||
app.router.add_delete('/api/recipe/{recipe_id}', routes.delete_recipe)
|
||||
|
||||
@@ -70,12 +72,18 @@ class RecipeRoutes:
|
||||
# Add new endpoint for getting recipe syntax
|
||||
app.router.add_get('/api/recipe/{recipe_id}/syntax', routes.get_recipe_syntax)
|
||||
|
||||
# Add new endpoint for updating recipe metadata (name and tags)
|
||||
# Add new endpoint for updating recipe metadata (name, tags and source_path)
|
||||
app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe)
|
||||
|
||||
# Add new endpoint for reconnecting deleted LoRAs
|
||||
app.router.add_post('/api/recipe/lora/reconnect', routes.reconnect_lora)
|
||||
|
||||
# Add new endpoint for finding duplicate recipes
|
||||
app.router.add_get('/api/recipes/find-duplicates', routes.find_duplicates)
|
||||
|
||||
# Add new endpoint for bulk deletion of recipes
|
||||
app.router.add_post('/api/recipes/bulk-delete', routes.bulk_delete)
|
||||
|
||||
# Start cache initialization
|
||||
app.on_startup.append(routes._init_cache)
|
||||
|
||||
@@ -300,7 +308,6 @@ class RecipeRoutes:
|
||||
|
||||
# For URL mode, include the image data as base64
|
||||
if is_url_mode and temp_path:
|
||||
import base64
|
||||
with open(temp_path, "rb") as image_file:
|
||||
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
@@ -317,7 +324,6 @@ class RecipeRoutes:
|
||||
|
||||
# For URL mode, include the image data as base64
|
||||
if is_url_mode and temp_path:
|
||||
import base64
|
||||
with open(temp_path, "rb") as image_file:
|
||||
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
@@ -332,7 +338,6 @@ class RecipeRoutes:
|
||||
|
||||
# For URL mode, include the image data as base64
|
||||
if is_url_mode and temp_path:
|
||||
import base64
|
||||
with open(temp_path, "rb") as image_file:
|
||||
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
@@ -340,6 +345,21 @@ class RecipeRoutes:
|
||||
if "error" in result and not result.get("loras"):
|
||||
return web.json_response(result, status=200)
|
||||
|
||||
# Calculate fingerprint from parsed loras
|
||||
from ..utils.utils import calculate_recipe_fingerprint
|
||||
fingerprint = calculate_recipe_fingerprint(result.get("loras", []))
|
||||
|
||||
# Add fingerprint to result
|
||||
result["fingerprint"] = fingerprint
|
||||
|
||||
# Find matching recipes with the same fingerprint
|
||||
matching_recipes = []
|
||||
if fingerprint:
|
||||
matching_recipes = await self.recipe_scanner.find_recipes_by_fingerprint(fingerprint)
|
||||
|
||||
# Add matching recipes to result
|
||||
result["matching_recipes"] = matching_recipes
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
except Exception as e:
|
||||
@@ -355,7 +375,100 @@ class RecipeRoutes:
|
||||
os.unlink(temp_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting temporary file: {e}")
|
||||
|
||||
async def analyze_local_image(self, request: web.Request) -> web.Response:
|
||||
"""Analyze a local image file for recipe metadata"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
|
||||
# Get JSON data from request
|
||||
data = await request.json()
|
||||
file_path = data.get('path')
|
||||
|
||||
if not file_path:
|
||||
return web.json_response({
|
||||
'error': 'No file path provided',
|
||||
'loras': []
|
||||
}, status=400)
|
||||
|
||||
# Normalize file path for cross-platform compatibility
|
||||
file_path = os.path.normpath(file_path.strip('"').strip("'"))
|
||||
|
||||
# Validate that the file exists
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response({
|
||||
'error': 'File not found',
|
||||
'loras': []
|
||||
}, status=404)
|
||||
|
||||
# Extract metadata from the image using ExifUtils
|
||||
metadata = ExifUtils.extract_image_metadata(file_path)
|
||||
|
||||
# If no metadata found, return error
|
||||
if not metadata:
|
||||
# Get base64 image data
|
||||
with open(file_path, "rb") as image_file:
|
||||
image_base64 = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
return web.json_response({
|
||||
"error": "No metadata found in this image",
|
||||
"loras": [], # Return empty loras array to prevent client-side errors
|
||||
"image_base64": image_base64
|
||||
}, status=200)
|
||||
|
||||
# Use the parser factory to get the appropriate parser
|
||||
parser = RecipeParserFactory.create_parser(metadata)
|
||||
|
||||
if parser is None:
|
||||
# Get base64 image data
|
||||
with open(file_path, "rb") as image_file:
|
||||
image_base64 = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
return web.json_response({
|
||||
"error": "No parser found for this image",
|
||||
"loras": [], # Return empty loras array to prevent client-side errors
|
||||
"image_base64": image_base64
|
||||
}, status=200)
|
||||
|
||||
# Parse the metadata
|
||||
result = await parser.parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=self.recipe_scanner,
|
||||
civitai_client=self.civitai_client
|
||||
)
|
||||
|
||||
# Add base64 image data to result
|
||||
with open(file_path, "rb") as image_file:
|
||||
result["image_base64"] = base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
# Check for errors
|
||||
if "error" in result and not result.get("loras"):
|
||||
return web.json_response(result, status=200)
|
||||
|
||||
# Calculate fingerprint from parsed loras
|
||||
from ..utils.utils import calculate_recipe_fingerprint
|
||||
fingerprint = calculate_recipe_fingerprint(result.get("loras", []))
|
||||
|
||||
# Add fingerprint to result
|
||||
result["fingerprint"] = fingerprint
|
||||
|
||||
# Find matching recipes with the same fingerprint
|
||||
matching_recipes = []
|
||||
if fingerprint:
|
||||
matching_recipes = await self.recipe_scanner.find_recipes_by_fingerprint(fingerprint)
|
||||
|
||||
# Add matching recipes to result
|
||||
result["matching_recipes"] = matching_recipes
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing local image: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'error': str(e),
|
||||
'loras': [] # Return empty loras array to prevent client-side errors
|
||||
}, status=500)
|
||||
|
||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||
"""Save a recipe to the recipes folder"""
|
||||
@@ -425,7 +538,6 @@ class RecipeRoutes:
|
||||
if not image:
|
||||
if image_base64:
|
||||
# Convert base64 to binary
|
||||
import base64
|
||||
try:
|
||||
# Remove potential data URL prefix
|
||||
if ',' in image_base64:
|
||||
@@ -474,7 +586,7 @@ class RecipeRoutes:
|
||||
with open(image_path, 'wb') as f:
|
||||
f.write(optimized_image)
|
||||
|
||||
# Create the recipe JSON
|
||||
# Create the recipe data structure
|
||||
current_time = time.time()
|
||||
|
||||
# Format loras data according to the recipe.json format
|
||||
@@ -514,6 +626,10 @@ class RecipeRoutes:
|
||||
"clip_skip": raw_metadata.get("clip_skip", "")
|
||||
}
|
||||
|
||||
# Calculate recipe fingerprint
|
||||
from ..utils.utils import calculate_recipe_fingerprint
|
||||
fingerprint = calculate_recipe_fingerprint(loras_data)
|
||||
|
||||
# Create the recipe data structure
|
||||
recipe_data = {
|
||||
"id": recipe_id,
|
||||
@@ -523,13 +639,18 @@ class RecipeRoutes:
|
||||
"created_date": current_time,
|
||||
"base_model": metadata.get("base_model", ""),
|
||||
"loras": loras_data,
|
||||
"gen_params": gen_params
|
||||
"gen_params": gen_params,
|
||||
"fingerprint": fingerprint
|
||||
}
|
||||
|
||||
# Add tags if provided
|
||||
if tags:
|
||||
recipe_data["tags"] = tags
|
||||
|
||||
# Add source_path if provided in metadata
|
||||
if metadata.get("source_path"):
|
||||
recipe_data["source_path"] = metadata.get("source_path")
|
||||
|
||||
# Save the recipe JSON
|
||||
json_filename = f"{recipe_id}.recipe.json"
|
||||
json_path = os.path.join(recipes_dir, json_filename)
|
||||
@@ -539,6 +660,14 @@ class RecipeRoutes:
|
||||
# Add recipe metadata to the image
|
||||
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
# Check for duplicates
|
||||
matching_recipes = []
|
||||
if fingerprint:
|
||||
matching_recipes = await self.recipe_scanner.find_recipes_by_fingerprint(fingerprint)
|
||||
# Remove current recipe from matches
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
# Simplified cache update approach
|
||||
# Instead of trying to update the cache directly, just set it to None
|
||||
# to force a refresh on the next get_cached_data call
|
||||
@@ -554,7 +683,8 @@ class RecipeRoutes:
|
||||
'success': True,
|
||||
'recipe_id': recipe_id,
|
||||
'image_path': image_path,
|
||||
'json_path': json_path
|
||||
'json_path': json_path,
|
||||
'matching_recipes': matching_recipes
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -1089,9 +1219,9 @@ class RecipeRoutes:
|
||||
data = await request.json()
|
||||
|
||||
# Validate required fields
|
||||
if 'title' not in data and 'tags' not in data:
|
||||
if 'title' not in data and 'tags' not in data and 'source_path' not in data:
|
||||
return web.json_response({
|
||||
"error": "At least one field to update must be provided (title or tags)"
|
||||
"error": "At least one field to update must be provided (title or tags or source_path)"
|
||||
}, status=400)
|
||||
|
||||
# Use the recipe scanner's update method
|
||||
@@ -1186,6 +1316,10 @@ class RecipeRoutes:
|
||||
|
||||
if not found:
|
||||
return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404)
|
||||
|
||||
# Recalculate recipe fingerprint after updating LoRA
|
||||
from ..utils.utils import calculate_recipe_fingerprint
|
||||
recipe_data['fingerprint'] = calculate_recipe_fingerprint(recipe_data.get('loras', []))
|
||||
|
||||
# Save updated recipe
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
@@ -1201,6 +1335,8 @@ class RecipeRoutes:
|
||||
if cache_item.get('id') == recipe_id:
|
||||
# Replace loras array with updated version
|
||||
cache_item['loras'] = recipe_data['loras']
|
||||
# Update fingerprint in cache
|
||||
cache_item['fingerprint'] = recipe_data['fingerprint']
|
||||
|
||||
# Resort the cache
|
||||
asyncio.create_task(scanner._cache.resort())
|
||||
@@ -1211,11 +1347,20 @@ class RecipeRoutes:
|
||||
if image_path and os.path.exists(image_path):
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
# Find other recipes with the same fingerprint
|
||||
matching_recipes = []
|
||||
if 'fingerprint' in recipe_data:
|
||||
matching_recipes = await scanner.find_recipes_by_fingerprint(recipe_data['fingerprint'])
|
||||
# Remove current recipe from matches
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_lora": updated_lora
|
||||
"updated_lora": updated_lora,
|
||||
"matching_recipes": matching_recipes
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -1291,3 +1436,150 @@ class RecipeRoutes:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def find_duplicates(self, request: web.Request) -> web.Response:
|
||||
"""Find all duplicate recipes based on fingerprints"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
|
||||
# Get all duplicate recipes
|
||||
duplicate_groups = await self.recipe_scanner.find_all_duplicate_recipes()
|
||||
|
||||
# Create response data with additional recipe information
|
||||
response_data = []
|
||||
|
||||
for fingerprint, recipe_ids in duplicate_groups.items():
|
||||
# Skip groups with only one recipe (not duplicates)
|
||||
if len(recipe_ids) <= 1:
|
||||
continue
|
||||
|
||||
# Get recipe details for each recipe in the group
|
||||
recipes = []
|
||||
for recipe_id in recipe_ids:
|
||||
recipe = await self.recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
if recipe:
|
||||
# Add only needed fields to keep response size manageable
|
||||
recipes.append({
|
||||
'id': recipe.get('id'),
|
||||
'title': recipe.get('title'),
|
||||
'file_url': recipe.get('file_url') or self._format_recipe_file_url(recipe.get('file_path', '')),
|
||||
'modified': recipe.get('modified'),
|
||||
'created_date': recipe.get('created_date'),
|
||||
'lora_count': len(recipe.get('loras', [])),
|
||||
})
|
||||
|
||||
# Only include groups with at least 2 valid recipes
|
||||
if len(recipes) >= 2:
|
||||
# Sort recipes by modified date (newest first)
|
||||
recipes.sort(key=lambda x: x.get('modified', 0), reverse=True)
|
||||
|
||||
response_data.append({
|
||||
'fingerprint': fingerprint,
|
||||
'count': len(recipes),
|
||||
'recipes': recipes
|
||||
})
|
||||
|
||||
# Sort groups by count (highest first)
|
||||
response_data.sort(key=lambda x: x['count'], reverse=True)
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'duplicate_groups': response_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding duplicate recipes: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def bulk_delete(self, request: web.Request) -> web.Response:
|
||||
"""Delete multiple recipes by ID"""
|
||||
try:
|
||||
# Ensure services are initialized
|
||||
await self.init_services()
|
||||
|
||||
# Parse request data
|
||||
data = await request.json()
|
||||
recipe_ids = data.get('recipe_ids', [])
|
||||
|
||||
if not recipe_ids:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'No recipe IDs provided'
|
||||
}, status=400)
|
||||
|
||||
# Get recipes directory
|
||||
recipes_dir = self.recipe_scanner.recipes_dir
|
||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Recipes directory not found'
|
||||
}, status=404)
|
||||
|
||||
# Track deleted and failed recipes
|
||||
deleted_recipes = []
|
||||
failed_recipes = []
|
||||
|
||||
# Process each recipe ID
|
||||
for recipe_id in recipe_ids:
|
||||
# Find recipe JSON file
|
||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
|
||||
if not os.path.exists(recipe_json_path):
|
||||
failed_recipes.append({
|
||||
'id': recipe_id,
|
||||
'reason': 'Recipe not found'
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
# Load recipe data to get image path
|
||||
with open(recipe_json_path, 'r', encoding='utf-8') as f:
|
||||
recipe_data = json.load(f)
|
||||
|
||||
# Get image path
|
||||
image_path = recipe_data.get('file_path')
|
||||
|
||||
# Delete recipe JSON file
|
||||
os.remove(recipe_json_path)
|
||||
|
||||
# Delete recipe image if it exists
|
||||
if image_path and os.path.exists(image_path):
|
||||
os.remove(image_path)
|
||||
|
||||
deleted_recipes.append(recipe_id)
|
||||
|
||||
except Exception as e:
|
||||
failed_recipes.append({
|
||||
'id': recipe_id,
|
||||
'reason': str(e)
|
||||
})
|
||||
|
||||
# Update cache if any recipes were deleted
|
||||
if deleted_recipes and self.recipe_scanner._cache is not None:
|
||||
# Remove deleted recipes from raw_data
|
||||
self.recipe_scanner._cache.raw_data = [
|
||||
r for r in self.recipe_scanner._cache.raw_data
|
||||
if r.get('id') not in deleted_recipes
|
||||
]
|
||||
# Resort the cache
|
||||
asyncio.create_task(self.recipe_scanner._cache.resort())
|
||||
logger.info(f"Removed {len(deleted_recipes)} recipes from cache")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'deleted': deleted_recipes,
|
||||
'failed': failed_recipes,
|
||||
'total_deleted': len(deleted_recipes),
|
||||
'total_failed': len(failed_recipes)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error performing bulk delete: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
from .nodes.utils import get_lora_info
|
||||
|
||||
@PromptServer.instance.routes.post("/loramanager/get_trigger_words")
|
||||
async def get_trigger_words(request):
|
||||
json_data = await request.json()
|
||||
lora_names = json_data.get("lora_names", [])
|
||||
node_ids = json_data.get("node_ids", [])
|
||||
|
||||
all_trigger_words = []
|
||||
for lora_name in lora_names:
|
||||
_, trigger_words = await get_lora_info(lora_name)
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# Format the trigger words
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Send update to all connected trigger word toggle nodes
|
||||
for node_id in node_ids:
|
||||
PromptServer.instance.send_sync("trigger_word_update", {
|
||||
"id": node_id,
|
||||
"message": trigger_words_text
|
||||
})
|
||||
|
||||
return web.json_response({"success": True})
|
||||
@@ -2,8 +2,7 @@ import logging
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any
|
||||
from .civitai_client import CivitaiClient
|
||||
from typing import Dict
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
@@ -136,15 +135,9 @@ class DownloadManager:
|
||||
# 3. Prepare download
|
||||
file_name = file_info['name']
|
||||
save_path = os.path.join(save_dir, file_name)
|
||||
file_size = file_info.get('sizeKB', 0) * 1024
|
||||
|
||||
# 4. Notify file monitor - use normalized path and file size
|
||||
file_monitor = await self._get_lora_monitor() if model_type == "lora" else await self._get_checkpoint_monitor()
|
||||
if file_monitor and file_monitor.handler:
|
||||
file_monitor.handler.add_ignore_path(
|
||||
save_path.replace(os.sep, '/'),
|
||||
file_size
|
||||
)
|
||||
# file monitor is despreted, so we don't need to use it
|
||||
|
||||
# 5. Prepare metadata based on model type
|
||||
if model_type == "checkpoint":
|
||||
@@ -287,17 +280,11 @@ class DownloadManager:
|
||||
scanner = await self._get_lora_scanner()
|
||||
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['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
|
||||
scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
|
||||
|
||||
# Add model to cache and save to disk in a single operation
|
||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||
|
||||
# Report 100% completion
|
||||
if progress_callback:
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
from typing import List, Dict
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
@dataclass
|
||||
class LoraCache:
|
||||
@@ -17,7 +18,7 @@ class LoraCache:
|
||||
async def resort(self, name_only: bool = False):
|
||||
"""Resort all cached data views"""
|
||||
async with self._lock:
|
||||
self.sorted_by_name = sorted(
|
||||
self.sorted_by_name = natsorted(
|
||||
self.raw_data,
|
||||
key=lambda x: x['model_name'].lower() # Case-insensitive sort
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
from typing import List, Dict
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
@dataclass
|
||||
class ModelCache:
|
||||
@@ -17,7 +18,7 @@ class ModelCache:
|
||||
async def resort(self, name_only: bool = False):
|
||||
"""Resort all cached data views"""
|
||||
async with self._lock:
|
||||
self.sorted_by_name = sorted(
|
||||
self.sorted_by_name = natsorted(
|
||||
self.raw_data,
|
||||
key=lambda x: x['model_name'].lower() # Case-insensitive sort
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import asyncio
|
||||
import time
|
||||
import shutil
|
||||
from typing import List, Dict, Optional, Type, Set
|
||||
import msgpack # Add MessagePack import for efficient serialization
|
||||
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..config import config
|
||||
@@ -17,6 +18,9 @@ from .websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Define cache version to handle future format changes
|
||||
CACHE_VERSION = 1
|
||||
|
||||
class ModelScanner:
|
||||
"""Base service for scanning and managing model files"""
|
||||
|
||||
@@ -39,6 +43,7 @@ class ModelScanner:
|
||||
self._tags_count = {} # Dictionary to store tag counts
|
||||
self._is_initializing = False # Flag to track initialization state
|
||||
self._excluded_models = [] # List to track excluded models
|
||||
self._dirs_last_modified = {} # Track directory modification times
|
||||
|
||||
# Register this service
|
||||
asyncio.create_task(self._register_service())
|
||||
@@ -48,6 +53,171 @@ class ModelScanner:
|
||||
service_name = f"{self.model_type}_scanner"
|
||||
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
|
||||
},
|
||||
"tags_count": self._tags_count,
|
||||
"dirs_last_modified": self._get_dirs_last_modified()
|
||||
}
|
||||
|
||||
# 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}")
|
||||
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:
|
||||
return False
|
||||
|
||||
if cache_data.get("model_type") != 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()):
|
||||
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
|
||||
|
||||
# Load tags count
|
||||
self._tags_count = cache_data.get("tags_count", {})
|
||||
|
||||
# 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:
|
||||
"""Initialize cache in background using thread pool"""
|
||||
try:
|
||||
@@ -66,7 +236,31 @@ class ModelScanner:
|
||||
# Determine the page type based on model type
|
||||
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({
|
||||
'stage': 'scan_folders',
|
||||
'progress': 0,
|
||||
@@ -111,6 +305,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")
|
||||
|
||||
# Save the cache to disk after initialization
|
||||
await self._save_cache_to_disk()
|
||||
|
||||
# Send completion message
|
||||
await asyncio.sleep(0.5) # Small delay to ensure final progress message is sent
|
||||
await ws_manager.broadcast_init_progress({
|
||||
@@ -280,8 +477,13 @@ class ModelScanner:
|
||||
# Clean up the event loop
|
||||
loop.close()
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> ModelCache:
|
||||
"""Get cached model data, refresh if needed"""
|
||||
async def get_cached_data(self, force_refresh: bool = False, rebuild_cache: bool = False) -> ModelCache:
|
||||
"""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
|
||||
# Actual initialization should be done via initialize_in_background
|
||||
if self._cache is None and not force_refresh:
|
||||
@@ -293,10 +495,25 @@ class ModelScanner:
|
||||
)
|
||||
|
||||
# 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:
|
||||
# For initial creation, do a full initialization
|
||||
await self._initialize_cache()
|
||||
# Save the newly built cache
|
||||
await self._save_cache_to_disk()
|
||||
else:
|
||||
# For subsequent refreshes, use fast reconciliation
|
||||
await self._reconcile_cache()
|
||||
@@ -484,6 +701,9 @@ class ModelScanner:
|
||||
# Resort cache
|
||||
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.")
|
||||
except Exception as e:
|
||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
|
||||
@@ -698,6 +918,44 @@ class ModelScanner:
|
||||
models_list.append(result)
|
||||
except Exception as 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:
|
||||
"""Move a model and its associated files to a new location"""
|
||||
@@ -743,25 +1001,40 @@ class ModelScanner:
|
||||
|
||||
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
|
||||
if os.path.exists(source_metadata):
|
||||
target_metadata = os.path.join(target_path, f"{base_name}.metadata.json")
|
||||
shutil.move(source_metadata, target_metadata)
|
||||
metadata = await self._update_metadata_paths(target_metadata, target_file)
|
||||
for source_file, target_file_path in files_to_move:
|
||||
try:
|
||||
shutil.move(source_file, target_file_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving associated file {source_file}: {e}")
|
||||
|
||||
# Move civitai.info file if exists
|
||||
source_civitai = os.path.join(source_dir, f"{base_name}.civitai.info")
|
||||
if os.path.exists(source_civitai):
|
||||
target_civitai = os.path.join(target_path, f"{base_name}.civitai.info")
|
||||
shutil.move(source_civitai, target_civitai)
|
||||
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
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
|
||||
# Handle metadata file specially to update paths
|
||||
if source_metadata and os.path.exists(source_metadata):
|
||||
try:
|
||||
shutil.move(source_metadata, moved_metadata_path)
|
||||
metadata = await self._update_metadata_paths(moved_metadata_path, target_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving metadata file: {e}")
|
||||
|
||||
await self.update_single_model_cache(source_path, target_file, metadata)
|
||||
|
||||
@@ -839,6 +1112,9 @@ class ModelScanner:
|
||||
|
||||
await cache.resort()
|
||||
|
||||
# Save the updated cache
|
||||
await self._save_cache_to_disk()
|
||||
|
||||
return True
|
||||
|
||||
def has_hash(self, sha256: str) -> bool:
|
||||
@@ -931,4 +1207,8 @@ class ModelScanner:
|
||||
if self._cache is None:
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
from typing import List, Dict
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from natsort import natsorted
|
||||
|
||||
@dataclass
|
||||
class RecipeCache:
|
||||
@@ -16,7 +17,7 @@ class RecipeCache:
|
||||
async def resort(self, name_only: bool = False):
|
||||
"""Resort all cached data views"""
|
||||
async with self._lock:
|
||||
self.sorted_by_name = sorted(
|
||||
self.sorted_by_name = natsorted(
|
||||
self.raw_data,
|
||||
key=lambda x: x.get('title', '').lower() # Case-insensitive sort
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from .recipe_cache import RecipeCache
|
||||
from .service_registry import ServiceRegistry
|
||||
from .lora_scanner import LoraScanner
|
||||
from ..utils.utils import fuzzy_match
|
||||
from natsort import natsorted
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -164,7 +165,7 @@ class RecipeScanner:
|
||||
if hasattr(self._cache, "resort"):
|
||||
try:
|
||||
# Sort by name
|
||||
self._cache.sorted_by_name = sorted(
|
||||
self._cache.sorted_by_name = natsorted(
|
||||
self._cache.raw_data,
|
||||
key=lambda x: x.get('title', '').lower()
|
||||
)
|
||||
@@ -321,6 +322,20 @@ class RecipeScanner:
|
||||
|
||||
# Update lora information with local paths and availability
|
||||
await self._update_lora_information(recipe_data)
|
||||
|
||||
# Calculate and update fingerprint if missing
|
||||
if 'loras' in recipe_data and 'fingerprint' not in recipe_data:
|
||||
from ..utils.utils import calculate_recipe_fingerprint
|
||||
fingerprint = calculate_recipe_fingerprint(recipe_data['loras'])
|
||||
recipe_data['fingerprint'] = fingerprint
|
||||
|
||||
# Write updated recipe data back to file
|
||||
try:
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
logger.info(f"Added fingerprint to recipe: {recipe_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing updated recipe with fingerprint: {e}")
|
||||
|
||||
return recipe_data
|
||||
except Exception as e:
|
||||
@@ -801,3 +816,60 @@ class RecipeScanner:
|
||||
logger.info(f"Resorted recipe cache after updating {cache_updated_count} items")
|
||||
|
||||
return file_updated_count, cache_updated_count
|
||||
|
||||
async def find_recipes_by_fingerprint(self, fingerprint: str) -> list:
|
||||
"""Find recipes with a matching fingerprint
|
||||
|
||||
Args:
|
||||
fingerprint: The recipe fingerprint to search for
|
||||
|
||||
Returns:
|
||||
List of recipe details that match the fingerprint
|
||||
"""
|
||||
if not fingerprint:
|
||||
return []
|
||||
|
||||
# Get all recipes from cache
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Find recipes with matching fingerprint
|
||||
matching_recipes = []
|
||||
for recipe in cache.raw_data:
|
||||
if recipe.get('fingerprint') == fingerprint:
|
||||
recipe_details = {
|
||||
'id': recipe.get('id'),
|
||||
'title': recipe.get('title'),
|
||||
'file_url': self._format_file_url(recipe.get('file_path')),
|
||||
'modified': recipe.get('modified'),
|
||||
'created_date': recipe.get('created_date'),
|
||||
'lora_count': len(recipe.get('loras', []))
|
||||
}
|
||||
matching_recipes.append(recipe_details)
|
||||
|
||||
return matching_recipes
|
||||
|
||||
async def find_all_duplicate_recipes(self) -> dict:
|
||||
"""Find all recipe duplicates based on fingerprints
|
||||
|
||||
Returns:
|
||||
Dictionary where keys are fingerprints and values are lists of recipe IDs
|
||||
"""
|
||||
# Get all recipes from cache
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Group recipes by fingerprint
|
||||
fingerprint_groups = {}
|
||||
for recipe in cache.raw_data:
|
||||
fingerprint = recipe.get('fingerprint')
|
||||
if not fingerprint:
|
||||
continue
|
||||
|
||||
if fingerprint not in fingerprint_groups:
|
||||
fingerprint_groups[fingerprint] = []
|
||||
|
||||
fingerprint_groups[fingerprint].append(recipe.get('id'))
|
||||
|
||||
# Filter to only include groups with more than one recipe
|
||||
duplicate_groups = {k: v for k, v in fingerprint_groups.items() if len(v) > 1}
|
||||
|
||||
return duplicate_groups
|
||||
|
||||
@@ -233,6 +233,17 @@ async def load_metadata(file_path: str, model_class: Type[BaseModelMetadata] = L
|
||||
data['usage_tips'] = "{}"
|
||||
needs_update = True
|
||||
|
||||
# Update preview_nsfw_level if needed
|
||||
civitai_data = data.get('civitai', {})
|
||||
civitai_images = civitai_data.get('images', []) if civitai_data else []
|
||||
if (data.get('preview_url') and
|
||||
data.get('preview_nsfw_level', 0) == 0 and
|
||||
civitai_images and
|
||||
civitai_images[0].get('nsfwLevel', 0) != 0):
|
||||
data['preview_nsfw_level'] = civitai_images[0]['nsfwLevel']
|
||||
# TODO: write to metadata file
|
||||
# needs_update = True
|
||||
|
||||
if needs_update:
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
@@ -2,6 +2,9 @@ from safetensors import safe_open
|
||||
from typing import Dict
|
||||
from .model_utils import determine_base_model
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def extract_lora_metadata(file_path: str) -> Dict:
|
||||
"""Extract essential metadata from safetensors file"""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ class ModelRouteUtils:
|
||||
civitai_metadata: Dict, client: CivitaiClient) -> None:
|
||||
"""Update local metadata with CivitAI data"""
|
||||
local_metadata['civitai'] = civitai_metadata
|
||||
local_metadata['from_civitai'] = True
|
||||
|
||||
# Update model name if available
|
||||
if 'model' in civitai_metadata:
|
||||
@@ -50,7 +51,7 @@ class ModelRouteUtils:
|
||||
model_id = civitai_metadata['modelId']
|
||||
if 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['tags'] = model_metadata.get('tags', [])
|
||||
local_metadata['civitai']['creator'] = model_metadata['creator']
|
||||
@@ -143,6 +144,11 @@ class ModelRouteUtils:
|
||||
"""
|
||||
client = CivitaiClient()
|
||||
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'
|
||||
|
||||
# Check if model metadata exists
|
||||
@@ -166,21 +172,25 @@ class ModelRouteUtils:
|
||||
client
|
||||
)
|
||||
|
||||
# Update cache object directly
|
||||
model_data.update({
|
||||
# Update cache object directly using safe .get() method
|
||||
update_dict = {
|
||||
'model_name': local_metadata.get('model_name'),
|
||||
'preview_url': local_metadata.get('preview_url'),
|
||||
'from_civitai': True,
|
||||
'civitai': civitai_metadata
|
||||
})
|
||||
}
|
||||
model_data.update(update_dict)
|
||||
|
||||
# Update cache using the provided function
|
||||
await update_cache_func(file_path, file_path, local_metadata)
|
||||
|
||||
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:
|
||||
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
|
||||
finally:
|
||||
await client.close()
|
||||
@@ -194,7 +204,7 @@ class ModelRouteUtils:
|
||||
fields = [
|
||||
"id", "modelId", "name", "createdAt", "updatedAt",
|
||||
"publishedAt", "trainedWords", "baseModel", "description",
|
||||
"model", "images"
|
||||
"model", "images", "creator"
|
||||
]
|
||||
return {k: data[k] for k in fields if k in data}
|
||||
|
||||
|
||||
@@ -114,3 +114,49 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool:
|
||||
|
||||
# All words found either as substrings or fuzzy matches
|
||||
return True
|
||||
|
||||
def calculate_recipe_fingerprint(loras):
|
||||
"""
|
||||
Calculate a unique fingerprint for a recipe based on its LoRAs.
|
||||
|
||||
The fingerprint is created by sorting LoRA hashes, filtering invalid entries,
|
||||
normalizing strength values to 2 decimal places, and joining in format:
|
||||
hash1:strength1|hash2:strength2|...
|
||||
|
||||
Args:
|
||||
loras (list): List of LoRA dictionaries with hash and strength values
|
||||
|
||||
Returns:
|
||||
str: The calculated fingerprint
|
||||
"""
|
||||
if not loras:
|
||||
return ""
|
||||
|
||||
# Filter valid entries and extract hash and strength
|
||||
valid_loras = []
|
||||
for lora in loras:
|
||||
# Skip excluded loras
|
||||
if lora.get("exclude", False):
|
||||
continue
|
||||
|
||||
# Get the hash - use modelVersionId as fallback if hash is empty
|
||||
hash_value = lora.get("hash", "").lower()
|
||||
if not hash_value and lora.get("isDeleted", False) and lora.get("modelVersionId"):
|
||||
hash_value = lora.get("modelVersionId")
|
||||
|
||||
# Skip entries without a valid hash
|
||||
if not hash_value:
|
||||
continue
|
||||
|
||||
# Normalize strength to 2 decimal places (check both strength and weight fields)
|
||||
strength = round(float(lora.get("strength", lora.get("weight", 1.0))), 2)
|
||||
|
||||
valid_loras.append((hash_value, strength))
|
||||
|
||||
# Sort by hash
|
||||
valid_loras.sort()
|
||||
|
||||
# Join in format hash1:strength1|hash2:strength2|...
|
||||
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
|
||||
|
||||
return fingerprint
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
ComfyUI workflow parsing module to extract generation parameters
|
||||
"""
|
||||
@@ -1,58 +0,0 @@
|
||||
"""
|
||||
Command-line interface for the ComfyUI workflow parser
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
from .parser import parse_workflow
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def main():
|
||||
"""Entry point for the CLI"""
|
||||
parser = argparse.ArgumentParser(description='Parse ComfyUI workflow files')
|
||||
parser.add_argument('input', help='Input workflow JSON file path')
|
||||
parser.add_argument('-o', '--output', help='Output JSON file path')
|
||||
parser.add_argument('-p', '--pretty', action='store_true', help='Pretty print JSON output')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable debug logging')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set logging level
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# Validate input file
|
||||
if not os.path.isfile(args.input):
|
||||
logger.error(f"Input file not found: {args.input}")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse workflow
|
||||
try:
|
||||
result = parse_workflow(args.input, args.output)
|
||||
|
||||
# Print result to console if output file not specified
|
||||
if not args.output:
|
||||
if args.pretty:
|
||||
print(json.dumps(result, indent=4))
|
||||
else:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
logger.info(f"Output saved to: {args.output}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing workflow: {e}")
|
||||
if args.debug:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
Extension directory for custom node mappers
|
||||
"""
|
||||
@@ -1,285 +0,0 @@
|
||||
"""
|
||||
ComfyUI Core nodes mappers extension for workflow parsing
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Transform Functions
|
||||
# =============================================================================
|
||||
|
||||
def transform_random_noise(inputs: Dict) -> Dict:
|
||||
"""Transform function for RandomNoise node"""
|
||||
return {"seed": str(inputs.get("noise_seed", ""))}
|
||||
|
||||
def transform_ksampler_select(inputs: Dict) -> Dict:
|
||||
"""Transform function for KSamplerSelect node"""
|
||||
return {"sampler": inputs.get("sampler_name", "")}
|
||||
|
||||
def transform_basic_scheduler(inputs: Dict) -> Dict:
|
||||
"""Transform function for BasicScheduler node"""
|
||||
result = {
|
||||
"scheduler": inputs.get("scheduler", ""),
|
||||
"denoise": str(inputs.get("denoise", "1.0"))
|
||||
}
|
||||
|
||||
# Get steps from inputs or steps input
|
||||
if "steps" in inputs:
|
||||
if isinstance(inputs["steps"], str):
|
||||
result["steps"] = inputs["steps"]
|
||||
elif isinstance(inputs["steps"], dict) and "value" in inputs["steps"]:
|
||||
result["steps"] = str(inputs["steps"]["value"])
|
||||
else:
|
||||
result["steps"] = str(inputs["steps"])
|
||||
|
||||
return result
|
||||
|
||||
def transform_basic_guider(inputs: Dict) -> Dict:
|
||||
"""Transform function for BasicGuider node"""
|
||||
result = {}
|
||||
|
||||
# Process conditioning
|
||||
if "conditioning" in inputs:
|
||||
if isinstance(inputs["conditioning"], str):
|
||||
result["prompt"] = inputs["conditioning"]
|
||||
elif isinstance(inputs["conditioning"], dict):
|
||||
result["conditioning"] = inputs["conditioning"]
|
||||
|
||||
# Get model information if needed
|
||||
if "model" in inputs and isinstance(inputs["model"], dict):
|
||||
result["model"] = inputs["model"]
|
||||
|
||||
return result
|
||||
|
||||
def transform_model_sampling_flux(inputs: Dict) -> Dict:
|
||||
"""Transform function for ModelSamplingFlux - mostly a pass-through node"""
|
||||
# This node is primarily used for routing, so we mostly pass through values
|
||||
|
||||
return inputs["model"]
|
||||
|
||||
def transform_sampler_custom_advanced(inputs: Dict) -> Dict:
|
||||
"""Transform function for SamplerCustomAdvanced node"""
|
||||
result = {}
|
||||
|
||||
# Extract seed from noise
|
||||
if "noise" in inputs and isinstance(inputs["noise"], dict):
|
||||
result["seed"] = str(inputs["noise"].get("seed", ""))
|
||||
|
||||
# Extract sampler info
|
||||
if "sampler" in inputs and isinstance(inputs["sampler"], dict):
|
||||
sampler = inputs["sampler"].get("sampler", "")
|
||||
if sampler:
|
||||
result["sampler"] = sampler
|
||||
|
||||
# Extract scheduler, steps, denoise from sigmas
|
||||
if "sigmas" in inputs and isinstance(inputs["sigmas"], dict):
|
||||
sigmas = inputs["sigmas"]
|
||||
result["scheduler"] = sigmas.get("scheduler", "")
|
||||
result["steps"] = str(sigmas.get("steps", ""))
|
||||
result["denoise"] = str(sigmas.get("denoise", "1.0"))
|
||||
|
||||
# Extract prompt and guidance from guider
|
||||
if "guider" in inputs and isinstance(inputs["guider"], dict):
|
||||
guider = inputs["guider"]
|
||||
|
||||
# Get prompt from conditioning
|
||||
if "conditioning" in guider and isinstance(guider["conditioning"], str):
|
||||
result["prompt"] = guider["conditioning"]
|
||||
elif "conditioning" in guider and isinstance(guider["conditioning"], dict):
|
||||
result["guidance"] = guider["conditioning"].get("guidance", "")
|
||||
result["prompt"] = guider["conditioning"].get("prompt", "")
|
||||
|
||||
if "model" in guider and isinstance(guider["model"], dict):
|
||||
result["checkpoint"] = guider["model"].get("checkpoint", "")
|
||||
result["loras"] = guider["model"].get("loras", "")
|
||||
result["clip_skip"] = str(int(guider["model"].get("clip_skip", "-1")) * -1)
|
||||
|
||||
# Extract dimensions from latent_image
|
||||
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
|
||||
latent = inputs["latent_image"]
|
||||
width = latent.get("width", 0)
|
||||
height = latent.get("height", 0)
|
||||
if width and height:
|
||||
result["width"] = width
|
||||
result["height"] = height
|
||||
result["size"] = f"{width}x{height}"
|
||||
|
||||
return result
|
||||
|
||||
def transform_ksampler(inputs: Dict) -> Dict:
|
||||
"""Transform function for KSampler nodes"""
|
||||
result = {
|
||||
"seed": str(inputs.get("seed", "")),
|
||||
"steps": str(inputs.get("steps", "")),
|
||||
"cfg": str(inputs.get("cfg", "")),
|
||||
"sampler": inputs.get("sampler_name", ""),
|
||||
"scheduler": inputs.get("scheduler", ""),
|
||||
}
|
||||
|
||||
# Process positive prompt
|
||||
if "positive" in inputs:
|
||||
result["prompt"] = inputs["positive"]
|
||||
|
||||
# Process negative prompt
|
||||
if "negative" in inputs:
|
||||
result["negative_prompt"] = inputs["negative"]
|
||||
|
||||
# Get dimensions from latent image
|
||||
if "latent_image" in inputs and isinstance(inputs["latent_image"], dict):
|
||||
width = inputs["latent_image"].get("width", 0)
|
||||
height = inputs["latent_image"].get("height", 0)
|
||||
if width and height:
|
||||
result["size"] = f"{width}x{height}"
|
||||
|
||||
# Add clip_skip if present
|
||||
if "clip_skip" in inputs:
|
||||
result["clip_skip"] = str(inputs.get("clip_skip", ""))
|
||||
|
||||
# Add guidance if present
|
||||
if "guidance" in inputs:
|
||||
result["guidance"] = str(inputs.get("guidance", ""))
|
||||
|
||||
# Add model if present
|
||||
if "model" in inputs:
|
||||
result["checkpoint"] = inputs.get("model", {}).get("checkpoint", "")
|
||||
result["loras"] = inputs.get("model", {}).get("loras", "")
|
||||
result["clip_skip"] = str(inputs.get("model", {}).get("clip_skip", -1) * -1)
|
||||
|
||||
return result
|
||||
|
||||
def transform_empty_latent(inputs: Dict) -> Dict:
|
||||
"""Transform function for EmptyLatentImage nodes"""
|
||||
width = inputs.get("width", 0)
|
||||
height = inputs.get("height", 0)
|
||||
return {"width": width, "height": height, "size": f"{width}x{height}"}
|
||||
|
||||
def transform_clip_text(inputs: Dict) -> Any:
|
||||
"""Transform function for CLIPTextEncode nodes"""
|
||||
return inputs.get("text", "")
|
||||
|
||||
def transform_flux_guidance(inputs: Dict) -> Dict:
|
||||
"""Transform function for FluxGuidance nodes"""
|
||||
result = {}
|
||||
|
||||
if "guidance" in inputs:
|
||||
result["guidance"] = inputs["guidance"]
|
||||
|
||||
if "conditioning" in inputs:
|
||||
conditioning = inputs["conditioning"]
|
||||
if isinstance(conditioning, str):
|
||||
result["prompt"] = conditioning
|
||||
else:
|
||||
result["prompt"] = "Unknown prompt"
|
||||
|
||||
return result
|
||||
|
||||
def transform_unet_loader(inputs: Dict) -> Dict:
|
||||
"""Transform function for UNETLoader node"""
|
||||
unet_name = inputs.get("unet_name", "")
|
||||
return {"checkpoint": unet_name} if unet_name else {}
|
||||
|
||||
def transform_checkpoint_loader(inputs: Dict) -> Dict:
|
||||
"""Transform function for CheckpointLoaderSimple node"""
|
||||
ckpt_name = inputs.get("ckpt_name", "")
|
||||
return {"checkpoint": ckpt_name} if ckpt_name else {}
|
||||
|
||||
def transform_latent_upscale_by(inputs: Dict) -> Dict:
|
||||
"""Transform function for LatentUpscaleBy node"""
|
||||
result = {}
|
||||
|
||||
width = inputs["samples"].get("width", 0) * inputs["scale_by"]
|
||||
height = inputs["samples"].get("height", 0) * inputs["scale_by"]
|
||||
result["width"] = width
|
||||
result["height"] = height
|
||||
result["size"] = f"{width}x{height}"
|
||||
|
||||
return result
|
||||
|
||||
def transform_clip_set_last_layer(inputs: Dict) -> Dict:
|
||||
"""Transform function for CLIPSetLastLayer node"""
|
||||
result = {}
|
||||
|
||||
if "stop_at_clip_layer" in inputs:
|
||||
result["clip_skip"] = inputs["stop_at_clip_layer"]
|
||||
|
||||
return result
|
||||
|
||||
# =============================================================================
|
||||
# Node Mapper Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Define the mappers for ComfyUI core nodes not in main mapper
|
||||
NODE_MAPPERS_EXT = {
|
||||
# KSamplers
|
||||
"SamplerCustomAdvanced": {
|
||||
"inputs_to_track": ["noise", "guider", "sampler", "sigmas", "latent_image"],
|
||||
"transform_func": transform_sampler_custom_advanced
|
||||
},
|
||||
"KSampler": {
|
||||
"inputs_to_track": [
|
||||
"seed", "steps", "cfg", "sampler_name", "scheduler",
|
||||
"denoise", "positive", "negative", "latent_image",
|
||||
"model", "clip_skip"
|
||||
],
|
||||
"transform_func": transform_ksampler
|
||||
},
|
||||
# ComfyUI core nodes
|
||||
"EmptyLatentImage": {
|
||||
"inputs_to_track": ["width", "height", "batch_size"],
|
||||
"transform_func": transform_empty_latent
|
||||
},
|
||||
"EmptySD3LatentImage": {
|
||||
"inputs_to_track": ["width", "height", "batch_size"],
|
||||
"transform_func": transform_empty_latent
|
||||
},
|
||||
"CLIPTextEncode": {
|
||||
"inputs_to_track": ["text", "clip"],
|
||||
"transform_func": transform_clip_text
|
||||
},
|
||||
"FluxGuidance": {
|
||||
"inputs_to_track": ["guidance", "conditioning"],
|
||||
"transform_func": transform_flux_guidance
|
||||
},
|
||||
"RandomNoise": {
|
||||
"inputs_to_track": ["noise_seed"],
|
||||
"transform_func": transform_random_noise
|
||||
},
|
||||
"KSamplerSelect": {
|
||||
"inputs_to_track": ["sampler_name"],
|
||||
"transform_func": transform_ksampler_select
|
||||
},
|
||||
"BasicScheduler": {
|
||||
"inputs_to_track": ["scheduler", "steps", "denoise", "model"],
|
||||
"transform_func": transform_basic_scheduler
|
||||
},
|
||||
"BasicGuider": {
|
||||
"inputs_to_track": ["model", "conditioning"],
|
||||
"transform_func": transform_basic_guider
|
||||
},
|
||||
"ModelSamplingFlux": {
|
||||
"inputs_to_track": ["max_shift", "base_shift", "width", "height", "model"],
|
||||
"transform_func": transform_model_sampling_flux
|
||||
},
|
||||
"UNETLoader": {
|
||||
"inputs_to_track": ["unet_name"],
|
||||
"transform_func": transform_unet_loader
|
||||
},
|
||||
"CheckpointLoaderSimple": {
|
||||
"inputs_to_track": ["ckpt_name"],
|
||||
"transform_func": transform_checkpoint_loader
|
||||
},
|
||||
"LatentUpscale": {
|
||||
"inputs_to_track": ["width", "height"],
|
||||
"transform_func": transform_empty_latent
|
||||
},
|
||||
"LatentUpscaleBy": {
|
||||
"inputs_to_track": ["samples", "scale_by"],
|
||||
"transform_func": transform_latent_upscale_by
|
||||
},
|
||||
"CLIPSetLastLayer": {
|
||||
"inputs_to_track": ["clip", "stop_at_clip_layer"],
|
||||
"transform_func": transform_clip_set_last_layer
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
KJNodes mappers extension for ComfyUI workflow parsing
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Transform Functions
|
||||
# =============================================================================
|
||||
|
||||
def transform_join_strings(inputs: Dict) -> str:
|
||||
"""Transform function for JoinStrings nodes"""
|
||||
string1 = inputs.get("string1", "")
|
||||
string2 = inputs.get("string2", "")
|
||||
delimiter = inputs.get("delimiter", "")
|
||||
return f"{string1}{delimiter}{string2}"
|
||||
|
||||
def transform_string_constant(inputs: Dict) -> str:
|
||||
"""Transform function for StringConstant nodes"""
|
||||
return inputs.get("string", "")
|
||||
|
||||
def transform_empty_latent_presets(inputs: Dict) -> Dict:
|
||||
"""Transform function for EmptyLatentImagePresets nodes"""
|
||||
dimensions = inputs.get("dimensions", "")
|
||||
invert = inputs.get("invert", False)
|
||||
|
||||
# Extract width and height from dimensions string
|
||||
# Expected format: "width x height (ratio)" or similar
|
||||
width = 0
|
||||
height = 0
|
||||
|
||||
if dimensions:
|
||||
# Try to extract dimensions using regex
|
||||
match = re.search(r'(\d+)\s*x\s*(\d+)', dimensions)
|
||||
if match:
|
||||
width = int(match.group(1))
|
||||
height = int(match.group(2))
|
||||
|
||||
# If invert is True, swap width and height
|
||||
if invert and width and height:
|
||||
width, height = height, width
|
||||
|
||||
return {"width": width, "height": height, "size": f"{width}x{height}"}
|
||||
|
||||
def transform_int_constant(inputs: Dict) -> int:
|
||||
"""Transform function for INTConstant nodes"""
|
||||
return inputs.get("value", 0)
|
||||
|
||||
# =============================================================================
|
||||
# Node Mapper Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Define the mappers for KJNodes
|
||||
NODE_MAPPERS_EXT = {
|
||||
"JoinStrings": {
|
||||
"inputs_to_track": ["string1", "string2", "delimiter"],
|
||||
"transform_func": transform_join_strings
|
||||
},
|
||||
"StringConstantMultiline": {
|
||||
"inputs_to_track": ["string"],
|
||||
"transform_func": transform_string_constant
|
||||
},
|
||||
"EmptyLatentImagePresets": {
|
||||
"inputs_to_track": ["dimensions", "invert", "batch_size"],
|
||||
"transform_func": transform_empty_latent_presets
|
||||
},
|
||||
"INTConstant": {
|
||||
"inputs_to_track": ["value"],
|
||||
"transform_func": transform_int_constant
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Main entry point for the workflow parser module
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import Dict, Optional, Union
|
||||
|
||||
# Add the parent directory to sys.path to enable imports
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..'))
|
||||
sys.path.insert(0, os.path.dirname(SCRIPT_DIR))
|
||||
|
||||
from .parser import parse_workflow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_comfyui_workflow(
|
||||
workflow_path: str,
|
||||
output_path: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Parse a ComfyUI workflow file and extract generation parameters
|
||||
|
||||
Args:
|
||||
workflow_path: Path to the workflow JSON file
|
||||
output_path: Optional path to save the output JSON
|
||||
|
||||
Returns:
|
||||
Dictionary containing extracted parameters
|
||||
"""
|
||||
return parse_workflow(workflow_path, output_path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# If run directly, use the CLI
|
||||
from .cli import main
|
||||
main()
|
||||
@@ -1,282 +0,0 @@
|
||||
"""
|
||||
Node mappers for ComfyUI workflow parsing
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import importlib.util
|
||||
import inspect
|
||||
from typing import Dict, List, Any, Optional, Union, Type, Callable, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global mapper registry
|
||||
_MAPPER_REGISTRY: Dict[str, Dict] = {}
|
||||
|
||||
# =============================================================================
|
||||
# Mapper Definition Functions
|
||||
# =============================================================================
|
||||
|
||||
def create_mapper(
|
||||
node_type: str,
|
||||
inputs_to_track: List[str],
|
||||
transform_func: Callable[[Dict], Any] = None
|
||||
) -> Dict:
|
||||
"""Create a mapper definition for a node type"""
|
||||
mapper = {
|
||||
"node_type": node_type,
|
||||
"inputs_to_track": inputs_to_track,
|
||||
"transform": transform_func or (lambda inputs: inputs)
|
||||
}
|
||||
return mapper
|
||||
|
||||
def register_mapper(mapper: Dict) -> None:
|
||||
"""Register a node mapper in the global registry"""
|
||||
_MAPPER_REGISTRY[mapper["node_type"]] = mapper
|
||||
logger.debug(f"Registered mapper for node type: {mapper['node_type']}")
|
||||
|
||||
def get_mapper(node_type: str) -> Optional[Dict]:
|
||||
"""Get a mapper for the specified node type"""
|
||||
return _MAPPER_REGISTRY.get(node_type)
|
||||
|
||||
def get_all_mappers() -> Dict[str, Dict]:
|
||||
"""Get all registered mappers"""
|
||||
return _MAPPER_REGISTRY.copy()
|
||||
|
||||
# =============================================================================
|
||||
# Node Processing Function
|
||||
# =============================================================================
|
||||
|
||||
def process_node(node_id: str, node_data: Dict, workflow: Dict, parser: 'WorkflowParser') -> Any: # type: ignore
|
||||
"""Process a node using its mapper and extract relevant information"""
|
||||
node_type = node_data.get("class_type")
|
||||
mapper = get_mapper(node_type)
|
||||
|
||||
if not mapper:
|
||||
logger.warning(f"No mapper found for node type: {node_type}")
|
||||
return None
|
||||
|
||||
result = {}
|
||||
|
||||
# Extract inputs based on the mapper's tracked inputs
|
||||
for input_name in mapper["inputs_to_track"]:
|
||||
if input_name in node_data.get("inputs", {}):
|
||||
input_value = node_data["inputs"][input_name]
|
||||
|
||||
# Check if input is a reference to another node's output
|
||||
if isinstance(input_value, list) and len(input_value) == 2:
|
||||
try:
|
||||
# Format is [node_id, output_slot]
|
||||
ref_node_id, output_slot = input_value
|
||||
# Convert node_id to string if it's an integer
|
||||
if isinstance(ref_node_id, int):
|
||||
ref_node_id = str(ref_node_id)
|
||||
|
||||
# Recursively process the referenced node
|
||||
ref_value = parser.process_node(ref_node_id, workflow)
|
||||
|
||||
if ref_value is not None:
|
||||
result[input_name] = ref_value
|
||||
else:
|
||||
# If we couldn't get a value from the reference, store the raw value
|
||||
result[input_name] = input_value
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing reference in node {node_id}, input {input_name}: {e}")
|
||||
result[input_name] = input_value
|
||||
else:
|
||||
# Direct value
|
||||
result[input_name] = input_value
|
||||
|
||||
# Apply the transform function
|
||||
try:
|
||||
return mapper["transform"](result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in transform function for node {node_id} of type {node_type}: {e}")
|
||||
return result
|
||||
|
||||
# =============================================================================
|
||||
# Transform Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
|
||||
def transform_lora_loader(inputs: Dict) -> Dict:
|
||||
"""Transform function for LoraLoader nodes"""
|
||||
loras_data = inputs.get("loras", [])
|
||||
lora_stack = inputs.get("lora_stack", {}).get("lora_stack", [])
|
||||
|
||||
lora_texts = []
|
||||
|
||||
# Process loras array
|
||||
if isinstance(loras_data, dict) and "__value__" in loras_data:
|
||||
loras_list = loras_data["__value__"]
|
||||
elif isinstance(loras_data, list):
|
||||
loras_list = loras_data
|
||||
else:
|
||||
loras_list = []
|
||||
|
||||
# Process each active lora entry
|
||||
for lora in loras_list:
|
||||
if isinstance(lora, dict) and lora.get("active", False):
|
||||
lora_name = lora.get("name", "")
|
||||
strength = lora.get("strength", 1.0)
|
||||
lora_texts.append(f"<lora:{lora_name}:{strength}>")
|
||||
|
||||
# Process lora_stack if valid
|
||||
if lora_stack and isinstance(lora_stack, list):
|
||||
if not (len(lora_stack) == 2 and isinstance(lora_stack[0], (str, int)) and isinstance(lora_stack[1], int)):
|
||||
for stack_entry in lora_stack:
|
||||
lora_name = stack_entry[0]
|
||||
strength = stack_entry[1]
|
||||
lora_texts.append(f"<lora:{lora_name}:{strength}>")
|
||||
|
||||
result = {
|
||||
"checkpoint": inputs.get("model", {}).get("checkpoint", ""),
|
||||
"loras": " ".join(lora_texts)
|
||||
}
|
||||
|
||||
if "clip" in inputs and isinstance(inputs["clip"], dict):
|
||||
result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
|
||||
|
||||
return result
|
||||
|
||||
def transform_lora_stacker(inputs: Dict) -> Dict:
|
||||
"""Transform function for LoraStacker nodes"""
|
||||
loras_data = inputs.get("loras", [])
|
||||
result_stack = []
|
||||
|
||||
# Handle existing stack entries
|
||||
existing_stack = []
|
||||
lora_stack_input = inputs.get("lora_stack", [])
|
||||
|
||||
if isinstance(lora_stack_input, dict) and "lora_stack" in lora_stack_input:
|
||||
existing_stack = lora_stack_input["lora_stack"]
|
||||
elif isinstance(lora_stack_input, list):
|
||||
if not (len(lora_stack_input) == 2 and isinstance(lora_stack_input[0], (str, int)) and
|
||||
isinstance(lora_stack_input[1], int)):
|
||||
existing_stack = lora_stack_input
|
||||
|
||||
# Add existing entries
|
||||
if existing_stack:
|
||||
result_stack.extend(existing_stack)
|
||||
|
||||
# Process new loras
|
||||
if isinstance(loras_data, dict) and "__value__" in loras_data:
|
||||
loras_list = loras_data["__value__"]
|
||||
elif isinstance(loras_data, list):
|
||||
loras_list = loras_data
|
||||
else:
|
||||
loras_list = []
|
||||
|
||||
for lora in loras_list:
|
||||
if isinstance(lora, dict) and lora.get("active", False):
|
||||
lora_name = lora.get("name", "")
|
||||
strength = float(lora.get("strength", 1.0))
|
||||
result_stack.append((lora_name, strength))
|
||||
|
||||
return {"lora_stack": result_stack}
|
||||
|
||||
def transform_trigger_word_toggle(inputs: Dict) -> str:
|
||||
"""Transform function for TriggerWordToggle nodes"""
|
||||
toggle_data = inputs.get("toggle_trigger_words", [])
|
||||
|
||||
if isinstance(toggle_data, dict) and "__value__" in toggle_data:
|
||||
toggle_words = toggle_data["__value__"]
|
||||
elif isinstance(toggle_data, list):
|
||||
toggle_words = toggle_data
|
||||
else:
|
||||
toggle_words = []
|
||||
|
||||
# Filter active trigger words
|
||||
active_words = []
|
||||
for item in toggle_words:
|
||||
if isinstance(item, dict) and item.get("active", False):
|
||||
word = item.get("text", "")
|
||||
if word and not word.startswith("__dummy"):
|
||||
active_words.append(word)
|
||||
|
||||
return ", ".join(active_words)
|
||||
|
||||
# =============================================================================
|
||||
# Node Mapper Definitions
|
||||
# =============================================================================
|
||||
|
||||
# Central definition of all supported node types and their configurations
|
||||
NODE_MAPPERS = {
|
||||
|
||||
# LoraManager nodes
|
||||
"Lora Loader (LoraManager)": {
|
||||
"inputs_to_track": ["model", "clip", "loras", "lora_stack"],
|
||||
"transform_func": transform_lora_loader
|
||||
},
|
||||
"Lora Stacker (LoraManager)": {
|
||||
"inputs_to_track": ["loras", "lora_stack"],
|
||||
"transform_func": transform_lora_stacker
|
||||
},
|
||||
"TriggerWord Toggle (LoraManager)": {
|
||||
"inputs_to_track": ["toggle_trigger_words"],
|
||||
"transform_func": transform_trigger_word_toggle
|
||||
}
|
||||
}
|
||||
|
||||
def register_all_mappers() -> None:
|
||||
"""Register all mappers from the NODE_MAPPERS dictionary"""
|
||||
for node_type, config in NODE_MAPPERS.items():
|
||||
mapper = create_mapper(
|
||||
node_type=node_type,
|
||||
inputs_to_track=config["inputs_to_track"],
|
||||
transform_func=config["transform_func"]
|
||||
)
|
||||
register_mapper(mapper)
|
||||
logger.info(f"Registered {len(NODE_MAPPERS)} node mappers")
|
||||
|
||||
# =============================================================================
|
||||
# Extension Loading
|
||||
# =============================================================================
|
||||
|
||||
def load_extensions(ext_dir: str = None) -> None:
|
||||
"""
|
||||
Load mapper extensions from the specified directory
|
||||
|
||||
Extension files should define a NODE_MAPPERS_EXT dictionary containing mapper configurations.
|
||||
These will be added to the global NODE_MAPPERS dictionary and registered automatically.
|
||||
"""
|
||||
# Use default path if none provided
|
||||
if ext_dir is None:
|
||||
# Get the directory of this file
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
ext_dir = os.path.join(current_dir, 'ext')
|
||||
|
||||
# Ensure the extension directory exists
|
||||
if not os.path.exists(ext_dir):
|
||||
os.makedirs(ext_dir, exist_ok=True)
|
||||
logger.info(f"Created extension directory: {ext_dir}")
|
||||
return
|
||||
|
||||
# Load each Python file in the extension directory
|
||||
for filename in os.listdir(ext_dir):
|
||||
if filename.endswith('.py') and not filename.startswith('_'):
|
||||
module_path = os.path.join(ext_dir, filename)
|
||||
module_name = f"workflow.ext.{filename[:-3]}" # Remove .py
|
||||
|
||||
try:
|
||||
# Load the module
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
if spec and spec.loader:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Check if the module defines NODE_MAPPERS_EXT
|
||||
if hasattr(module, 'NODE_MAPPERS_EXT'):
|
||||
# Add the extension mappers to the global NODE_MAPPERS dictionary
|
||||
NODE_MAPPERS.update(module.NODE_MAPPERS_EXT)
|
||||
logger.info(f"Added {len(module.NODE_MAPPERS_EXT)} mappers from extension: {filename}")
|
||||
else:
|
||||
logger.warning(f"Extension {filename} does not define NODE_MAPPERS_EXT dictionary")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading extension {filename}: {e}")
|
||||
|
||||
# Re-register all mappers after loading extensions
|
||||
register_all_mappers()
|
||||
|
||||
# Initialize the registry with default mappers
|
||||
# register_default_mappers()
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Main workflow parser implementation for ComfyUI
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Union, Set
|
||||
from .mappers import get_mapper, get_all_mappers, load_extensions, process_node
|
||||
from .utils import (
|
||||
load_workflow, save_output, find_node_by_type,
|
||||
trace_model_path
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WorkflowParser:
|
||||
"""Parser for ComfyUI workflows"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the parser with mappers"""
|
||||
self.processed_nodes: Set[str] = set() # Track processed nodes to avoid cycles
|
||||
self.node_results_cache: Dict[str, Any] = {} # Cache for processed node results
|
||||
|
||||
# Load extensions
|
||||
load_extensions()
|
||||
|
||||
def process_node(self, node_id: str, workflow: Dict) -> Any:
|
||||
"""Process a single node and extract relevant information"""
|
||||
# Return cached result if available
|
||||
if node_id in self.node_results_cache:
|
||||
return self.node_results_cache[node_id]
|
||||
|
||||
# Check if we're in a cycle
|
||||
if node_id in self.processed_nodes:
|
||||
return None
|
||||
|
||||
# Mark this node as being processed (to detect cycles)
|
||||
self.processed_nodes.add(node_id)
|
||||
|
||||
if node_id not in workflow:
|
||||
self.processed_nodes.remove(node_id)
|
||||
return None
|
||||
|
||||
node_data = workflow[node_id]
|
||||
node_type = node_data.get("class_type")
|
||||
|
||||
result = None
|
||||
if get_mapper(node_type):
|
||||
try:
|
||||
result = process_node(node_id, node_data, workflow, self)
|
||||
# Cache the result
|
||||
self.node_results_cache[node_id] = result
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing node {node_id} of type {node_type}: {e}", exc_info=True)
|
||||
# Return a partial result or None depending on how we want to handle errors
|
||||
result = {}
|
||||
|
||||
# Remove node from processed set to allow it to be processed again in a different context
|
||||
self.processed_nodes.remove(node_id)
|
||||
return result
|
||||
|
||||
def find_primary_sampler_node(self, workflow: Dict) -> Optional[str]:
|
||||
"""
|
||||
Find the primary sampler node in the workflow.
|
||||
|
||||
Priority:
|
||||
1. First try to find a SamplerCustomAdvanced node
|
||||
2. If not found, look for KSampler nodes with denoise=1.0
|
||||
3. If still not found, use the first KSampler node
|
||||
|
||||
Args:
|
||||
workflow: The workflow data as a dictionary
|
||||
|
||||
Returns:
|
||||
The node ID of the primary sampler node, or None if not found
|
||||
"""
|
||||
# First check for SamplerCustomAdvanced nodes
|
||||
sampler_advanced_nodes = []
|
||||
ksampler_nodes = []
|
||||
|
||||
# Scan workflow for sampler nodes
|
||||
for node_id, node_data in workflow.items():
|
||||
node_type = node_data.get("class_type")
|
||||
|
||||
if node_type == "SamplerCustomAdvanced":
|
||||
sampler_advanced_nodes.append(node_id)
|
||||
elif node_type == "KSampler":
|
||||
ksampler_nodes.append(node_id)
|
||||
|
||||
# If we found SamplerCustomAdvanced nodes, return the first one
|
||||
if sampler_advanced_nodes:
|
||||
logger.debug(f"Found SamplerCustomAdvanced node: {sampler_advanced_nodes[0]}")
|
||||
return sampler_advanced_nodes[0]
|
||||
|
||||
# If we have KSampler nodes, look for one with denoise=1.0
|
||||
if ksampler_nodes:
|
||||
for node_id in ksampler_nodes:
|
||||
node_data = workflow[node_id]
|
||||
inputs = node_data.get("inputs", {})
|
||||
denoise = inputs.get("denoise", 0)
|
||||
|
||||
# Check if denoise is 1.0 (allowing for small floating point differences)
|
||||
if abs(float(denoise) - 1.0) < 0.001:
|
||||
logger.debug(f"Found KSampler node with denoise=1.0: {node_id}")
|
||||
return node_id
|
||||
|
||||
# If no KSampler with denoise=1.0 found, use the first one
|
||||
logger.debug(f"No KSampler with denoise=1.0 found, using first KSampler: {ksampler_nodes[0]}")
|
||||
return ksampler_nodes[0]
|
||||
|
||||
# No sampler nodes found
|
||||
logger.warning("No sampler nodes found in workflow")
|
||||
return None
|
||||
|
||||
def parse_workflow(self, workflow_data: Union[str, Dict], output_path: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Parse the workflow and extract generation parameters
|
||||
|
||||
Args:
|
||||
workflow_data: The workflow data as a dictionary or a file path
|
||||
output_path: Optional path to save the output JSON
|
||||
|
||||
Returns:
|
||||
Dictionary containing extracted parameters
|
||||
"""
|
||||
# Load workflow from file if needed
|
||||
if isinstance(workflow_data, str):
|
||||
workflow = load_workflow(workflow_data)
|
||||
else:
|
||||
workflow = workflow_data
|
||||
|
||||
# Reset the processed nodes tracker and cache
|
||||
self.processed_nodes = set()
|
||||
self.node_results_cache = {}
|
||||
|
||||
# Find the primary sampler node
|
||||
sampler_node_id = self.find_primary_sampler_node(workflow)
|
||||
if not sampler_node_id:
|
||||
logger.warning("No suitable sampler node found in workflow")
|
||||
return {}
|
||||
|
||||
# Process sampler node to extract parameters
|
||||
sampler_result = self.process_node(sampler_node_id, workflow)
|
||||
if not sampler_result:
|
||||
return {}
|
||||
|
||||
# Return the sampler result directly - it's already in the format we need
|
||||
# This simplifies the structure and makes it easier to use in recipe_routes.py
|
||||
|
||||
# Handle standard ComfyUI names vs our output format
|
||||
if "cfg" in sampler_result:
|
||||
sampler_result["cfg_scale"] = sampler_result.pop("cfg")
|
||||
|
||||
# Add clip_skip = 1 to match reference output if not already present
|
||||
if "clip_skip" not in sampler_result:
|
||||
sampler_result["clip_skip"] = "1"
|
||||
|
||||
# Ensure the prompt is a string and not a nested dictionary
|
||||
if "prompt" in sampler_result and isinstance(sampler_result["prompt"], dict):
|
||||
if "prompt" in sampler_result["prompt"]:
|
||||
sampler_result["prompt"] = sampler_result["prompt"]["prompt"]
|
||||
|
||||
# Save the result if requested
|
||||
if output_path:
|
||||
save_output(sampler_result, output_path)
|
||||
|
||||
return sampler_result
|
||||
|
||||
|
||||
def parse_workflow(workflow_path: str, output_path: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Parse a ComfyUI workflow file and extract generation parameters
|
||||
|
||||
Args:
|
||||
workflow_path: Path to the workflow JSON file
|
||||
output_path: Optional path to save the output JSON
|
||||
|
||||
Returns:
|
||||
Dictionary containing extracted parameters
|
||||
"""
|
||||
parser = WorkflowParser()
|
||||
return parser.parse_workflow(workflow_path, output_path)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
Test script for the ComfyUI workflow parser
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from .parser import parse_workflow
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configure paths
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, '..', '..'))
|
||||
REFS_DIR = os.path.join(ROOT_DIR, 'refs')
|
||||
OUTPUT_DIR = os.path.join(ROOT_DIR, 'output')
|
||||
|
||||
def test_parse_flux_workflow():
|
||||
"""Test parsing the flux example workflow"""
|
||||
# Ensure output directory exists
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Define input and output paths
|
||||
input_path = os.path.join(REFS_DIR, 'flux_prompt.json')
|
||||
output_path = os.path.join(OUTPUT_DIR, 'parsed_flux_output.json')
|
||||
|
||||
# Parse workflow
|
||||
logger.info(f"Parsing workflow: {input_path}")
|
||||
result = parse_workflow(input_path, output_path)
|
||||
|
||||
# Print result summary
|
||||
logger.info(f"Output saved to: {output_path}")
|
||||
logger.info(f"Parsing completed. Result summary:")
|
||||
logger.info(f" LoRAs: {result.get('loras', '')}")
|
||||
|
||||
gen_params = result.get('gen_params', {})
|
||||
logger.info(f" Prompt: {gen_params.get('prompt', '')[:50]}...")
|
||||
logger.info(f" Steps: {gen_params.get('steps', '')}")
|
||||
logger.info(f" Sampler: {gen_params.get('sampler', '')}")
|
||||
logger.info(f" Size: {gen_params.get('size', '')}")
|
||||
|
||||
# Compare with reference output
|
||||
ref_output_path = os.path.join(REFS_DIR, 'flux_output.json')
|
||||
try:
|
||||
with open(ref_output_path, 'r') as f:
|
||||
ref_output = json.load(f)
|
||||
|
||||
# Simple validation
|
||||
loras_match = result.get('loras', '') == ref_output.get('loras', '')
|
||||
prompt_match = gen_params.get('prompt', '') == ref_output.get('gen_params', {}).get('prompt', '')
|
||||
|
||||
logger.info(f"Validation against reference:")
|
||||
logger.info(f" LoRAs match: {loras_match}")
|
||||
logger.info(f" Prompt match: {prompt_match}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to compare with reference output: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_parse_flux_workflow()
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
Utility functions for ComfyUI workflow parsing
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Any, Optional, Union, Set, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def load_workflow(workflow_path: str) -> Dict:
|
||||
"""Load a workflow from a JSON file"""
|
||||
try:
|
||||
with open(workflow_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading workflow from {workflow_path}: {e}")
|
||||
raise
|
||||
|
||||
def save_output(output: Dict, output_path: str) -> None:
|
||||
"""Save the parsed output to a JSON file"""
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
try:
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output, f, indent=4)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving output to {output_path}: {e}")
|
||||
raise
|
||||
|
||||
def find_node_by_type(workflow: Dict, node_type: str) -> Optional[str]:
|
||||
"""Find a node of the specified type in the workflow"""
|
||||
for node_id, node_data in workflow.items():
|
||||
if node_data.get("class_type") == node_type:
|
||||
return node_id
|
||||
return None
|
||||
|
||||
def find_nodes_by_type(workflow: Dict, node_type: str) -> List[str]:
|
||||
"""Find all nodes of the specified type in the workflow"""
|
||||
return [node_id for node_id, node_data in workflow.items()
|
||||
if node_data.get("class_type") == node_type]
|
||||
|
||||
def get_input_node_ids(workflow: Dict, node_id: str) -> Dict[str, Tuple[str, int]]:
|
||||
"""
|
||||
Get the node IDs for all inputs of the given node
|
||||
|
||||
Returns a dictionary mapping input names to (node_id, output_slot) tuples
|
||||
"""
|
||||
result = {}
|
||||
if node_id not in workflow:
|
||||
return result
|
||||
|
||||
node_data = workflow[node_id]
|
||||
for input_name, input_value in node_data.get("inputs", {}).items():
|
||||
# Check if this input is connected to another node
|
||||
if isinstance(input_value, list) and len(input_value) == 2:
|
||||
# Input is connected to another node's output
|
||||
# Format: [node_id, output_slot]
|
||||
ref_node_id, output_slot = input_value
|
||||
result[input_name] = (str(ref_node_id), output_slot)
|
||||
|
||||
return result
|
||||
|
||||
def trace_model_path(workflow: Dict, start_node_id: str) -> List[str]:
|
||||
"""
|
||||
Trace the model path backward from KSampler to find all LoRA nodes
|
||||
|
||||
Args:
|
||||
workflow: The workflow data
|
||||
start_node_id: The starting node ID (usually KSampler)
|
||||
|
||||
Returns:
|
||||
List of node IDs in the model path
|
||||
"""
|
||||
model_path_nodes = []
|
||||
|
||||
# Get the model input from the start node
|
||||
if start_node_id not in workflow:
|
||||
return model_path_nodes
|
||||
|
||||
# Track visited nodes to avoid cycles
|
||||
visited = set()
|
||||
|
||||
# Stack for depth-first search
|
||||
stack = []
|
||||
|
||||
# Get model input reference if available
|
||||
start_node = workflow[start_node_id]
|
||||
if "inputs" in start_node and "model" in start_node["inputs"] and isinstance(start_node["inputs"]["model"], list):
|
||||
model_ref = start_node["inputs"]["model"]
|
||||
stack.append(str(model_ref[0]))
|
||||
|
||||
# Perform depth-first search
|
||||
while stack:
|
||||
node_id = stack.pop()
|
||||
|
||||
# Skip if already visited
|
||||
if node_id in visited:
|
||||
continue
|
||||
|
||||
# Mark as visited
|
||||
visited.add(node_id)
|
||||
|
||||
# Skip if node doesn't exist
|
||||
if node_id not in workflow:
|
||||
continue
|
||||
|
||||
node = workflow[node_id]
|
||||
node_type = node.get("class_type", "")
|
||||
|
||||
# Add current node to result list if it's a LoRA node
|
||||
if "Lora" in node_type:
|
||||
model_path_nodes.append(node_id)
|
||||
|
||||
# Add all input nodes that have a "model" or "lora_stack" output to the stack
|
||||
if "inputs" in node:
|
||||
for input_name, input_value in node["inputs"].items():
|
||||
if input_name in ["model", "lora_stack"] and isinstance(input_value, list) and len(input_value) == 2:
|
||||
stack.append(str(input_value[0]))
|
||||
|
||||
return model_path_nodes
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||
version = "0.8.12"
|
||||
version = "0.8.16"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
@@ -13,7 +13,9 @@ dependencies = [
|
||||
"Pillow",
|
||||
"olefile", # for getting rid of warning message
|
||||
"requests",
|
||||
"toml"
|
||||
"toml",
|
||||
"natsort",
|
||||
"msgpack"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -9,4 +9,6 @@ olefile
|
||||
requests
|
||||
toml
|
||||
numpy
|
||||
torch
|
||||
torch
|
||||
natsort
|
||||
msgpack
|
||||
@@ -38,7 +38,7 @@ html, body {
|
||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||
--lora-text: oklch(95% 0.02 256);
|
||||
--lora-error: oklch(75% 0.32 29);
|
||||
--lora-warning: oklch(75% 0.25 80); /* Add warning color for deleted LoRAs */
|
||||
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: calc(8px * 1);
|
||||
@@ -79,7 +79,7 @@ html[data-theme="light"] {
|
||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||
--lora-text: oklch(98% 0.02 256);
|
||||
--lora-warning: oklch(75% 0.25 80); /* Add warning color for dark theme too */
|
||||
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
/* 卡片网格布局 */
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */
|
||||
gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */
|
||||
gap: 12px; /* Consistent gap for both row and column spacing */
|
||||
row-gap: 20px; /* Increase vertical spacing between rows */
|
||||
margin-top: var(--space-2);
|
||||
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
|
||||
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
|
||||
max-width: 1400px; /* Container width control */
|
||||
width: 100%; /* Ensure it takes full width of container */
|
||||
max-width: 1400px; /* Base container width */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
box-sizing: border-box; /* Include padding in width calculation */
|
||||
}
|
||||
|
||||
.lora-card {
|
||||
@@ -17,13 +20,14 @@
|
||||
border-radius: var(--border-radius-base);
|
||||
backdrop-filter: blur(16px);
|
||||
transition: transform 160ms ease-out;
|
||||
aspect-ratio: 896/1152;
|
||||
max-width: 260px; /* Adjusted from 320px to fit 5 cards */
|
||||
aspect-ratio: 896/1152; /* Preserve aspect ratio */
|
||||
max-width: 260px; /* Base size */
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
cursor: pointer; /* Added from recipe-card */
|
||||
display: flex; /* Added from recipe-card */
|
||||
flex-direction: column; /* Added from recipe-card */
|
||||
overflow: hidden; /* Add overflow hidden to contain children */
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lora-card:hover {
|
||||
@@ -36,6 +40,30 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for 1440p screens (2K) */
|
||||
@media (min-width: 2000px) {
|
||||
.card-grid {
|
||||
max-width: 1800px; /* Increased for 2K screens */
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
}
|
||||
|
||||
.lora-card {
|
||||
max-width: 270px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments for 4K screens */
|
||||
@media (min-width: 3000px) {
|
||||
.card-grid {
|
||||
max-width: 2400px; /* Increased for 4K screens */
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.lora-card {
|
||||
max-width: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1400px) {
|
||||
.card-grid {
|
||||
@@ -58,6 +86,42 @@
|
||||
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 */
|
||||
.compact-density .model-name {
|
||||
font-size: 0.9em;
|
||||
max-height: 2.4em;
|
||||
}
|
||||
|
||||
.compact-density .base-model-label {
|
||||
font-size: 0.8em;
|
||||
max-width: 110px;
|
||||
}
|
||||
|
||||
.compact-density .card-actions i {
|
||||
font-size: 0.95em;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.compact-density .model-info {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.card-preview img,
|
||||
.card-preview video {
|
||||
width: 100%;
|
||||
@@ -313,6 +377,25 @@
|
||||
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-indicator {
|
||||
position: absolute;
|
||||
@@ -362,4 +445,44 @@
|
||||
padding: 2rem;
|
||||
background: var(--lora-surface-alt);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
/* Virtual scrolling specific styles - updated */
|
||||
.virtual-scroll-item {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
transition: transform 160ms ease-out;
|
||||
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */
|
||||
width: 100%; /* Allow width to be set by the VirtualScroller */
|
||||
}
|
||||
|
||||
.virtual-scroll-item:hover {
|
||||
transform: translateY(-2px); /* Keep hover effect */
|
||||
z-index: 1; /* Ensure hovered items appear above others */
|
||||
}
|
||||
|
||||
/* When using virtual scroll, adjust container */
|
||||
.card-grid.virtual-scroll {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
|
||||
height: auto;
|
||||
width: 100%;
|
||||
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 */
|
||||
@media (min-width: 2000px) {
|
||||
.card-grid.virtual-scroll {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.card-grid.virtual-scroll {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
272
static/css/components/duplicates.css
Normal file
272
static/css/components/duplicates.css
Normal file
@@ -0,0 +1,272 @@
|
||||
/* Duplicates Management Styles */
|
||||
|
||||
/* Duplicates banner */
|
||||
.duplicates-banner {
|
||||
position: relative; /* Changed from sticky to relative */
|
||||
width: 100%;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: var(--z-overlay);
|
||||
padding: 12px 0; /* Removed horizontal padding */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px; /* Add margin to create space below the banner */
|
||||
}
|
||||
|
||||
.duplicates-banner .banner-content {
|
||||
max-width: 1400px; /* Match the container max-width */
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 16px; /* Move horizontal padding to the content */
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
font-size: 18px;
|
||||
color: oklch(var(--lora-warning));
|
||||
}
|
||||
|
||||
.duplicates-banner .banner-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duplicates-banner button {
|
||||
min-width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.duplicates-banner button:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.duplicates-banner button.btn-exit {
|
||||
min-width: unset;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.duplicates-banner button.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Duplicate groups */
|
||||
.duplicate-group {
|
||||
position: relative;
|
||||
border: 2px solid oklch(var(--lora-warning));
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.duplicate-group-header {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duplicate-group-header span:last-child {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.duplicate-group-header button {
|
||||
min-width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.duplicate-group-header button:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-group-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Make cards in duplicate groups have consistent width */
|
||||
.card-group-container .lora-card {
|
||||
flex: 0 0 auto;
|
||||
width: 240px;
|
||||
margin: 0;
|
||||
cursor: pointer; /* Indicate the card is clickable */
|
||||
}
|
||||
|
||||
/* Ensure the grid layout is only applied to the main recipe grid, not duplicate groups */
|
||||
.duplicate-mode .card-grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Scrollable container for large duplicate groups */
|
||||
.card-group-container.scrollable {
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
/* Add a toggle button to expand/collapse large duplicate groups */
|
||||
.group-toggle-btn {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: -12px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.group-toggle-btn:hover {
|
||||
border-color: var(--lora-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Duplicate card styling */
|
||||
.lora-card.duplicate {
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.lora-card.duplicate:hover {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.lora-card.duplicate.latest {
|
||||
border-style: solid;
|
||||
border-color: oklch(var(--lora-warning));
|
||||
}
|
||||
|
||||
.lora-card.duplicate-selected {
|
||||
border: 2px solid oklch(var(--lora-accent));
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.lora-card .selector-checkbox {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Latest indicator */
|
||||
.lora-card.duplicate.latest::after {
|
||||
content: "Latest";
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: oklch(var(--lora-accent));
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.duplicates-banner .banner-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.duplicates-banner .banner-actions {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.duplicate-group-header {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.duplicate-group-header span:last-child {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.duplicate-group-header button {
|
||||
margin-left: 0;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,7 @@
|
||||
gap: 8px;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
border-radius: var (--border-radius-sm);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
@@ -733,3 +733,150 @@
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Duplicate Recipes Styles */
|
||||
.duplicate-recipes-container {
|
||||
margin-bottom: var(--space-3);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.duplicate-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: oklch(var(--lora-warning) / 0.1);
|
||||
border: 1px solid var(--lora-warning);
|
||||
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-icon {
|
||||
color: var(--lora-warning);
|
||||
font-size: 1.2em;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.duplicate-warning .warning-text {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggle-duplicates-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--lora-warning);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.toggle-duplicates-btn:hover {
|
||||
background: oklch(var(--lora-warning) / 0.1);
|
||||
}
|
||||
|
||||
.duplicate-recipes-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
|
||||
background: var(--bg-color);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||
}
|
||||
|
||||
.duplicate-recipes-list.collapsed {
|
||||
max-height: 0;
|
||||
padding: 0 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.duplicate-recipe-card {
|
||||
position: relative;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.duplicate-recipe-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.duplicate-recipe-preview {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
aspect-ratio: 2/3;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.duplicate-recipe-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.duplicate-recipe-title {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.3;
|
||||
max-height: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.duplicate-recipe-details {
|
||||
padding: 8px;
|
||||
background: var(--bg-color);
|
||||
font-size: 0.75em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.duplicate-recipe-date,
|
||||
.duplicate-recipe-lora-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
96
static/css/components/keyboard-nav.css
Normal file
96
static/css/components/keyboard-nav.css
Normal 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);
|
||||
}
|
||||
@@ -673,11 +673,6 @@
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Model name field styles - complete replacement */
|
||||
.model-name-field {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* New Model Name Header Styles */
|
||||
.model-name-header {
|
||||
display: flex;
|
||||
@@ -1323,4 +1318,61 @@
|
||||
|
||||
.recipes-error i {
|
||||
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) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
@@ -151,7 +151,7 @@ body.modal-open {
|
||||
|
||||
.modal-content h2 {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-2);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
@@ -672,4 +672,25 @@ input:checked + .toggle-slider:before {
|
||||
|
||||
.changelog-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Add warning text style for settings */
|
||||
.warning-text {
|
||||
color: var(--lora-warning, #e67e22);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .warning-text {
|
||||
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;
|
||||
}
|
||||
@@ -12,11 +12,13 @@
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
pointer-events: none; /* Ignore mouse events when invisible */
|
||||
}
|
||||
|
||||
.progress-panel.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto; /* Capture mouse events when visible */
|
||||
}
|
||||
|
||||
.progress-panel.collapsed .progress-panel-content {
|
||||
|
||||
@@ -229,8 +229,10 @@
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recipe-preview-container img,
|
||||
@@ -246,6 +248,133 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Source URL container */
|
||||
.source-url-container {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: transform 0.3s ease;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.recipe-preview-container:hover .source-url-container {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.source-url-container.active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.source-url-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.source-url-icon {
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-url-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-url-edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin-left: 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.source-url-edit-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Source URL editor */
|
||||
.source-url-editor {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-color);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.source-url-editor.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.source-url-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.source-url-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-url-cancel-btn,
|
||||
.source-url-save-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.source-url-cancel-btn {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.source-url-save-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.source-url-cancel-btn:hover {
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.source-url-save-btn:hover {
|
||||
background: color-mix(in oklch, var(--lora-accent), black 10%);
|
||||
}
|
||||
|
||||
/* Generation Parameters */
|
||||
.recipe-gen-params {
|
||||
height: 360px;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
.page-content {
|
||||
height: calc(100vh - 48px); /* Full height minus header */
|
||||
margin-top: 48px; /* Push down below header */
|
||||
overflow-y: auto; /* Enable scrolling here */
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden; /* Prevent horizontal scrolling */
|
||||
overflow-y: auto; /* Enable vertical scrolling */
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -15,6 +15,19 @@
|
||||
z-index: var(--z-base);
|
||||
}
|
||||
|
||||
/* Responsive container for larger screens */
|
||||
@media (min-width: 2000px) {
|
||||
.container {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.container {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -22,6 +35,13 @@
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto; /* Push to the right */
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -293,6 +313,86 @@
|
||||
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) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
@@ -305,11 +405,14 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.controls-right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.toggle-folders-container {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.folder-tags-container {
|
||||
@@ -335,4 +438,9 @@
|
||||
.back-to-top {
|
||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
left: auto;
|
||||
right: 0; /* Align to right on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
@import 'components/initialization.css';
|
||||
@import 'components/progress-panel.css';
|
||||
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
|
||||
@import 'components/duplicates.css'; /* Add duplicates component */
|
||||
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
BIN
static/images/one-click-send.jpg
Normal file
BIN
static/images/one-click-send.jpg
Normal file
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 |
@@ -1,7 +1,6 @@
|
||||
// filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { showDeleteModal, confirmDelete } from '../utils/modalUtils.js';
|
||||
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
@@ -160,6 +159,231 @@ export async function loadMoreModels(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// New method for virtual scrolling fetch
|
||||
export async function fetchModelsPage(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
endpoint = '/api/loras'
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page,
|
||||
page_size: pageSize || pageState.pageSize || 20,
|
||||
sort_by: pageState.sortBy
|
||||
});
|
||||
|
||||
if (pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
|
||||
// Add favorites filter parameter if enabled
|
||||
if (pageState.showFavoritesOnly) {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
// Add active letter filter if set
|
||||
if (pageState.activeLetterFilter) {
|
||||
params.append('first_letter', pageState.activeLetterFilter);
|
||||
}
|
||||
|
||||
// Add search parameters if there's a search term
|
||||
if (pageState.filters?.search) {
|
||||
params.append('search', pageState.filters.search);
|
||||
params.append('fuzzy', 'true');
|
||||
|
||||
// Add search option parameters if available
|
||||
if (pageState.searchOptions) {
|
||||
params.append('search_filename', pageState.searchOptions.filename.toString());
|
||||
params.append('search_modelname', pageState.searchOptions.modelname.toString());
|
||||
if (pageState.searchOptions.tags !== undefined) {
|
||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||
}
|
||||
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Add filter parameters if active
|
||||
if (pageState.filters) {
|
||||
// Handle tags filters
|
||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||
// Checkpoints API expects individual 'tag' parameters, Loras API expects comma-separated 'tags'
|
||||
if (modelType === 'checkpoint') {
|
||||
pageState.filters.tags.forEach(tag => {
|
||||
params.append('tag', tag);
|
||||
});
|
||||
} else {
|
||||
params.append('tags', pageState.filters.tags.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle base model filters
|
||||
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
|
||||
if (modelType === 'checkpoint') {
|
||||
pageState.filters.baseModel.forEach(model => {
|
||||
params.append('base_model', model);
|
||||
});
|
||||
} else {
|
||||
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add model-specific parameters
|
||||
if (modelType === 'lora') {
|
||||
// Check for recipe-based filtering parameters from session storage
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
// Add hash filter parameter if present
|
||||
if (filterLoraHash) {
|
||||
params.append('lora_hash', filterLoraHash);
|
||||
}
|
||||
// Add multiple hashes filter if present
|
||||
else if (filterLoraHashes) {
|
||||
try {
|
||||
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
|
||||
params.append('lora_hashes', filterLoraHashes.join(','));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing lora hashes from session storage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${endpoint}?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
items: data.items,
|
||||
totalItems: data.total,
|
||||
totalPages: data.total_pages,
|
||||
currentPage: page,
|
||||
hasMore: page < data.total_pages,
|
||||
folders: data.folders
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${modelType}s:`, error);
|
||||
showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset and reload models using virtual scrolling
|
||||
* @param {Object} options - Operation options
|
||||
* @returns {Promise<Object>} The fetch result
|
||||
*/
|
||||
export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
try {
|
||||
pageState.isLoading = true;
|
||||
document.body.classList.add('loading');
|
||||
|
||||
// Reset page counter
|
||||
pageState.currentPage = 1;
|
||||
|
||||
// Fetch the first page
|
||||
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
||||
|
||||
// Update the virtual scroller
|
||||
state.virtualScroller.refreshWithData(
|
||||
result.items,
|
||||
result.totalItems,
|
||||
result.hasMore
|
||||
);
|
||||
|
||||
// Update state
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page will be 2
|
||||
|
||||
// Update folders if needed
|
||||
if (updateFolders && result.folders) {
|
||||
updateFolderTags(result.folders);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${modelType}s:`, error);
|
||||
showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
pageState.isLoading = false;
|
||||
document.body.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more models using virtual scrolling
|
||||
* @param {Object} options - Operation options
|
||||
* @returns {Promise<Object>} The fetch result
|
||||
*/
|
||||
export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
resetPage = false,
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
} = options;
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
try {
|
||||
// Start loading state
|
||||
pageState.isLoading = true;
|
||||
document.body.classList.add('loading');
|
||||
|
||||
// Reset to first page if requested
|
||||
if (resetPage) {
|
||||
pageState.currentPage = 1;
|
||||
}
|
||||
|
||||
// Fetch the first page of data
|
||||
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
||||
|
||||
// Update virtual scroller with the new data
|
||||
state.virtualScroller.refreshWithData(
|
||||
result.items,
|
||||
result.totalItems,
|
||||
result.hasMore
|
||||
);
|
||||
|
||||
// Update state
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
|
||||
// Update folders if needed
|
||||
if (updateFolders && result.folders) {
|
||||
updateFolderTags(result.folders);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${modelType}s:`, error);
|
||||
showToast(`Failed to load ${modelType}s: ${error.message}`, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
pageState.isLoading = false;
|
||||
document.body.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// Update folder tags in the UI
|
||||
export function updateFolderTags(folders) {
|
||||
const folderTagsContainer = document.querySelector('.folder-tags');
|
||||
@@ -231,10 +455,15 @@ export async function deleteModel(filePath, modelType = 'lora') {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Remove the card from UI
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (card) {
|
||||
card.remove();
|
||||
// If virtual scroller exists, update its data
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(filePath);
|
||||
} else {
|
||||
// Legacy approach: remove the card from UI directly
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (card) {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`${modelType} deleted successfully`, 'success');
|
||||
@@ -270,26 +499,31 @@ export async function refreshModels(options = {}) {
|
||||
const {
|
||||
modelType = 'lora',
|
||||
scanEndpoint = '/api/loras/scan',
|
||||
resetAndReloadFunction
|
||||
resetAndReloadFunction,
|
||||
fullRebuild = false // New parameter with default value false
|
||||
} = options;
|
||||
|
||||
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) {
|
||||
throw new Error(`Failed to refresh ${modelType}s: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (typeof resetAndReloadFunction === 'function') {
|
||||
await resetAndReloadFunction();
|
||||
await resetAndReloadFunction(true); // update folders
|
||||
}
|
||||
|
||||
showToast(`Refresh complete`, 'success');
|
||||
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
|
||||
} catch (error) {
|
||||
console.error(`Refresh failed:`, error);
|
||||
showToast(`Failed to refresh ${modelType}s`, 'error');
|
||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${modelType}s`, 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
@@ -449,10 +683,15 @@ export async function excludeModel(filePath, modelType = 'lora') {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Remove the card from UI
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (card) {
|
||||
card.remove();
|
||||
// If virtual scroller exists, update its data
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.removeItemByFilePath(filePath);
|
||||
} else {
|
||||
// Legacy approach: remove the card from UI directly
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (card) {
|
||||
card.remove();
|
||||
}
|
||||
}
|
||||
|
||||
showToast(`${modelType} excluded successfully`, 'success');
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createCheckpointCard } from '../components/CheckpointCard.js';
|
||||
import {
|
||||
loadMoreModels,
|
||||
fetchModelsPage,
|
||||
resetAndReload as baseResetAndReload,
|
||||
resetAndReloadWithVirtualScroll,
|
||||
loadMoreWithVirtualScroll,
|
||||
refreshModels as baseRefreshModels,
|
||||
deleteModel as baseDeleteModel,
|
||||
replaceModelPreview,
|
||||
@@ -9,33 +12,76 @@ import {
|
||||
refreshSingleModelMetadata,
|
||||
excludeModel as baseExcludeModel
|
||||
} from './baseModelApi.js';
|
||||
import { state } from '../state/index.js';
|
||||
|
||||
// Load more checkpoints with pagination
|
||||
export async function loadMoreCheckpoints(resetPagination = true) {
|
||||
return loadMoreModels({
|
||||
resetPage: resetPagination,
|
||||
updateFolders: true,
|
||||
/**
|
||||
* Fetch checkpoints with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
* @param {number} pageSize - Number of items per page
|
||||
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||
*/
|
||||
export async function fetchCheckpointsPage(page = 1, pageSize = 100) {
|
||||
return fetchModelsPage({
|
||||
modelType: 'checkpoint',
|
||||
createCardFunction: createCheckpointCard,
|
||||
page,
|
||||
pageSize,
|
||||
endpoint: '/api/checkpoints'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more checkpoints with pagination - updated to work with VirtualScroller
|
||||
* @param {boolean} resetPage - Whether to reset to the first page
|
||||
* @param {boolean} updateFolders - Whether to update folder tags
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) {
|
||||
// Check if virtual scroller is available
|
||||
if (state.virtualScroller) {
|
||||
return loadMoreWithVirtualScroll({
|
||||
modelType: 'checkpoint',
|
||||
resetPage,
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchCheckpointsPage
|
||||
});
|
||||
} else {
|
||||
// Fall back to the original implementation if virtual scroller isn't available
|
||||
return loadMoreModels({
|
||||
resetPage,
|
||||
updateFolders,
|
||||
modelType: 'checkpoint',
|
||||
createCardFunction: createCheckpointCard,
|
||||
endpoint: '/api/checkpoints'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset and reload checkpoints
|
||||
export async function resetAndReload() {
|
||||
return baseResetAndReload({
|
||||
updateFolders: true,
|
||||
modelType: 'checkpoint',
|
||||
loadMoreFunction: loadMoreCheckpoints
|
||||
});
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
// Check if virtual scroller is available
|
||||
if (state.virtualScroller) {
|
||||
return resetAndReloadWithVirtualScroll({
|
||||
modelType: 'checkpoint',
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchCheckpointsPage
|
||||
});
|
||||
} else {
|
||||
// Fall back to original implementation
|
||||
return baseResetAndReload({
|
||||
updateFolders,
|
||||
modelType: 'checkpoint',
|
||||
loadMoreFunction: loadMoreCheckpoints
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh checkpoints
|
||||
export async function refreshCheckpoints() {
|
||||
export async function refreshCheckpoints(fullRebuild = false) {
|
||||
return baseRefreshModels({
|
||||
modelType: 'checkpoint',
|
||||
scanEndpoint: '/api/checkpoints/scan',
|
||||
resetAndReloadFunction: resetAndReload
|
||||
resetAndReloadFunction: resetAndReload,
|
||||
fullRebuild: fullRebuild
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,7 +106,11 @@ export async function fetchCivitai() {
|
||||
|
||||
// Refresh single checkpoint metadata
|
||||
export async function refreshSingleCheckpointMetadata(filePath) {
|
||||
return refreshSingleModelMetadata(filePath, 'checkpoint');
|
||||
const success = await refreshSingleModelMetadata(filePath, 'checkpoint');
|
||||
if (success) {
|
||||
// Reload the current view to show updated data
|
||||
await resetAndReload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,22 +120,30 @@ export async function refreshSingleCheckpointMetadata(filePath) {
|
||||
* @returns {Promise} - Promise that resolves with the server response
|
||||
*/
|
||||
export async function saveModelMetadata(filePath, data) {
|
||||
const response = await fetch('/api/checkpoints/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
try {
|
||||
// Show loading indicator
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
|
||||
const response = await fetch('/api/checkpoints/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');
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,4 +153,40 @@ export async function saveModelMetadata(filePath, data) {
|
||||
*/
|
||||
export function excludeCheckpoint(filePath) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createLoraCard } from '../components/LoraCard.js';
|
||||
import {
|
||||
loadMoreModels,
|
||||
fetchModelsPage,
|
||||
resetAndReload as baseResetAndReload,
|
||||
resetAndReloadWithVirtualScroll,
|
||||
loadMoreWithVirtualScroll,
|
||||
refreshModels as baseRefreshModels,
|
||||
deleteModel as baseDeleteModel,
|
||||
replaceModelPreview,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
refreshSingleModelMetadata,
|
||||
excludeModel as baseExcludeModel
|
||||
} from './baseModelApi.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
|
||||
/**
|
||||
* Save model metadata to the server
|
||||
@@ -17,22 +21,30 @@ import {
|
||||
* @returns {Promise} Promise of the save operation
|
||||
*/
|
||||
export async function 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
|
||||
})
|
||||
});
|
||||
try {
|
||||
// Show loading indicator
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
|
||||
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');
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,12 +56,46 @@ export async function excludeLora(filePath) {
|
||||
return baseExcludeModel(filePath, 'lora');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more loras with pagination - updated to work with VirtualScroller
|
||||
* @param {boolean} resetPage - Whether to reset to the first page
|
||||
* @param {boolean} updateFolders - Whether to update folder tags
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||
return loadMoreModels({
|
||||
resetPage,
|
||||
updateFolders,
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
// Check if virtual scroller is available
|
||||
if (state.virtualScroller) {
|
||||
return loadMoreWithVirtualScroll({
|
||||
modelType: 'lora',
|
||||
resetPage,
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchLorasPage
|
||||
});
|
||||
} else {
|
||||
// Fall back to the original implementation if virtual scroller isn't available
|
||||
return loadMoreModels({
|
||||
resetPage,
|
||||
updateFolders,
|
||||
modelType: 'lora',
|
||||
createCardFunction: createLoraCard,
|
||||
endpoint: '/api/loras'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch loras with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
* @param {number} pageSize - Number of items per page
|
||||
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||
*/
|
||||
export async function fetchLorasPage(page = 1, pageSize = 100) {
|
||||
return fetchModelsPage({
|
||||
modelType: 'lora',
|
||||
createCardFunction: createLoraCard,
|
||||
page,
|
||||
pageSize,
|
||||
endpoint: '/api/loras'
|
||||
});
|
||||
}
|
||||
@@ -71,28 +117,46 @@ export async function replacePreview(filePath) {
|
||||
}
|
||||
|
||||
export function appendLoraCards(loras) {
|
||||
const grid = document.getElementById('loraGrid');
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
|
||||
loras.forEach(lora => {
|
||||
const card = createLoraCard(lora);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
// This function is no longer needed with virtual scrolling
|
||||
// but kept for compatibility
|
||||
if (state.virtualScroller) {
|
||||
console.warn('appendLoraCards is deprecated when using virtual scrolling');
|
||||
} else {
|
||||
const grid = document.getElementById('loraGrid');
|
||||
|
||||
loras.forEach(lora => {
|
||||
const card = createLoraCard(lora);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
return baseResetAndReload({
|
||||
updateFolders,
|
||||
modelType: 'lora',
|
||||
loadMoreFunction: loadMoreLoras
|
||||
});
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
// Check if virtual scroller is available
|
||||
if (state.virtualScroller) {
|
||||
return resetAndReloadWithVirtualScroll({
|
||||
modelType: 'lora',
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchLorasPage
|
||||
});
|
||||
} else {
|
||||
// Fall back to original implementation
|
||||
return baseResetAndReload({
|
||||
updateFolders,
|
||||
modelType: 'lora',
|
||||
loadMoreFunction: loadMoreLoras
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshLoras() {
|
||||
export async function refreshLoras(fullRebuild = false) {
|
||||
return baseRefreshModels({
|
||||
modelType: 'lora',
|
||||
scanEndpoint: '/api/loras/scan',
|
||||
resetAndReloadFunction: resetAndReload
|
||||
resetAndReloadFunction: resetAndReload,
|
||||
fullRebuild: fullRebuild
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,4 +181,40 @@ export async function fetchModelDescription(modelId, filePath) {
|
||||
console.error('Error fetching model description:', 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();
|
||||
}
|
||||
}
|
||||
174
static/js/api/recipeApi.js
Normal file
174
static/js/api/recipeApi.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { RecipeCard } from '../components/RecipeCard.js';
|
||||
import {
|
||||
fetchModelsPage,
|
||||
resetAndReloadWithVirtualScroll,
|
||||
loadMoreWithVirtualScroll
|
||||
} from './baseModelApi.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Fetch recipes with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
* @param {number} pageSize - Number of items per page
|
||||
* @returns {Promise<Object>} Object containing items, total count, and pagination info
|
||||
*/
|
||||
export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page,
|
||||
page_size: pageSize || pageState.pageSize || 20,
|
||||
sort_by: pageState.sortBy
|
||||
});
|
||||
|
||||
// If we have a specific recipe ID to load
|
||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||
// Special case: load specific recipe
|
||||
const response = await fetch(`/api/recipe/${pageState.customFilter.recipeId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const recipe = await response.json();
|
||||
|
||||
// Return in expected format
|
||||
return {
|
||||
items: [recipe],
|
||||
totalItems: 1,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
hasMore: false
|
||||
};
|
||||
}
|
||||
|
||||
// Add custom filter for Lora if present
|
||||
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
|
||||
params.append('lora_hash', pageState.customFilter.loraHash);
|
||||
params.append('bypass_filters', 'true');
|
||||
} else {
|
||||
// Normal filtering logic
|
||||
|
||||
// Add search filter if present
|
||||
if (pageState.filters?.search) {
|
||||
params.append('search', pageState.filters.search);
|
||||
|
||||
// Add search option parameters
|
||||
if (pageState.searchOptions) {
|
||||
params.append('search_title', pageState.searchOptions.title.toString());
|
||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
|
||||
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
|
||||
params.append('fuzzy', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// Add base model filters
|
||||
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
|
||||
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||
}
|
||||
|
||||
// Add tag filters
|
||||
if (pageState.filters?.tags && pageState.filters.tags.length) {
|
||||
params.append('tags', pageState.filters.tags.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
const response = await fetch(`/api/recipes?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
items: data.items,
|
||||
totalItems: data.total,
|
||||
totalPages: data.total_pages,
|
||||
currentPage: page,
|
||||
hasMore: page < data.total_pages
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipes:', error);
|
||||
showToast(`Failed to fetch recipes: ${error.message}`, 'error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset and reload recipes using virtual scrolling
|
||||
* @param {boolean} updateFolders - Whether to update folder tags
|
||||
* @returns {Promise<Object>} The fetch result
|
||||
*/
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
return resetAndReloadWithVirtualScroll({
|
||||
modelType: 'recipe',
|
||||
updateFolders,
|
||||
fetchPageFunction: fetchRecipesPage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||
*/
|
||||
export async function refreshRecipes() {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
||||
|
||||
// Call the API endpoint to rebuild the recipe cache
|
||||
const response = await fetch('/api/recipes/scan');
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
||||
}
|
||||
|
||||
// After successful cache rebuild, reload the recipes
|
||||
await resetAndReload();
|
||||
|
||||
showToast('Refresh complete', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error refreshing recipes:', error);
|
||||
showToast(error.message || 'Failed to refresh recipes', 'error');
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load more recipes with pagination - updated to work with VirtualScroller
|
||||
* @param {boolean} resetPage - Whether to reset to the first page
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function loadMoreRecipes(resetPage = false) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
// Use virtual scroller if available
|
||||
if (state.virtualScroller) {
|
||||
return loadMoreWithVirtualScroll({
|
||||
modelType: 'recipe',
|
||||
resetPage,
|
||||
updateFolders: false,
|
||||
fetchPageFunction: fetchRecipesPage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a recipe card instance from recipe data
|
||||
* @param {Object} recipe - Recipe data
|
||||
* @returns {HTMLElement} Recipe card DOM element
|
||||
*/
|
||||
export function createRecipeCard(recipe) {
|
||||
const recipeCard = new RecipeCard(recipe, (recipe) => {
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.showRecipeDetails(recipe);
|
||||
}
|
||||
});
|
||||
return recipeCard.element;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { appCore } from './core.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||
import { createPageControls } from './components/controls/index.js';
|
||||
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
||||
@@ -40,9 +39,6 @@ class CheckpointsPageManager {
|
||||
// Initialize context menu
|
||||
new CheckpointContextMenu();
|
||||
|
||||
// Initialize infinite scroll
|
||||
initializeInfiniteScroll('checkpoints');
|
||||
|
||||
// Initialize common page features
|
||||
appCore.initializePageFeatures();
|
||||
|
||||
|
||||
@@ -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 { showCheckpointModal } from './checkpointModal/index.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
@@ -115,8 +115,8 @@ export function createCheckpointCard(checkpoint) {
|
||||
<span class="model-name">${checkpoint.model_name}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-image"
|
||||
title="Replace Preview Image">
|
||||
<i class="fas fa-folder-open"
|
||||
title="Open Example Images Folder">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,6 +272,12 @@ export function createCheckpointCard(checkpoint) {
|
||||
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
|
||||
const videoElement = card.querySelector('video');
|
||||
if (videoElement && autoplayOnHover) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { refreshSingleCheckpointMetadata, saveModelMetadata } from '../../api/checkpointApi.js';
|
||||
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||
import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview } from '../../api/checkpointApi.js';
|
||||
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
import { showExcludeModal } from '../../utils/modalUtils.js';
|
||||
@@ -23,10 +23,12 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
this.currentCard.click();
|
||||
break;
|
||||
case 'preview':
|
||||
// Replace checkpoint preview
|
||||
if (this.currentCard.querySelector('.fa-image')) {
|
||||
this.currentCard.querySelector('.fa-image').click();
|
||||
}
|
||||
// Open example images folder instead of replacing preview
|
||||
openExampleImagesFolder(this.currentCard.dataset.sha256);
|
||||
break;
|
||||
case 'replace-preview':
|
||||
// Add new action for replacing preview images
|
||||
replaceCheckpointPreview(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'civitai':
|
||||
// Open civitai page
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.js';
|
||||
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview } from '../../api/loraApi.js';
|
||||
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
import { showExcludeModal } from '../../utils/modalUtils.js';
|
||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||
|
||||
export class LoraContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -35,13 +35,28 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
}
|
||||
break;
|
||||
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;
|
||||
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;
|
||||
case 'delete':
|
||||
this.currentCard.querySelector('.fa-trash')?.click();
|
||||
// Call showDeleteModal directly instead of clicking the trash button
|
||||
showDeleteModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'move':
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||
@@ -58,6 +73,26 @@ 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');
|
||||
}
|
||||
|
||||
// NSFW Selector methods from the original context menu
|
||||
initNSFWSelector() {
|
||||
// Close button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { state } from '../../state/index.js';
|
||||
|
||||
@@ -39,8 +39,16 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
this.currentCard.click();
|
||||
break;
|
||||
case 'copy':
|
||||
// Copy recipe to clipboard
|
||||
this.currentCard.querySelector('.fa-copy')?.click();
|
||||
// Copy recipe syntax to clipboard
|
||||
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;
|
||||
case 'share':
|
||||
// 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
|
||||
viewRecipeLoRAs(recipeId) {
|
||||
if (!recipeId) {
|
||||
|
||||
402
static/js/components/DuplicatesManager.js
Normal file
402
static/js/components/DuplicatesManager.js
Normal file
@@ -0,0 +1,402 @@
|
||||
// Duplicates Manager Component
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { RecipeCard } from './RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||
|
||||
export class DuplicatesManager {
|
||||
constructor(recipeManager) {
|
||||
this.recipeManager = recipeManager;
|
||||
this.duplicateGroups = [];
|
||||
this.inDuplicateMode = false;
|
||||
this.selectedForDeletion = new Set();
|
||||
}
|
||||
|
||||
async findDuplicates() {
|
||||
try {
|
||||
document.body.classList.add('loading');
|
||||
|
||||
const response = await fetch('/api/recipes/find-duplicates');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to find duplicates');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Unknown error finding duplicates');
|
||||
}
|
||||
|
||||
this.duplicateGroups = data.duplicate_groups || [];
|
||||
|
||||
if (this.duplicateGroups.length === 0) {
|
||||
showToast('No duplicate recipes 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;
|
||||
} finally {
|
||||
document.body.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
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 recipe grid first
|
||||
const recipeGrid = document.getElementById('recipeGrid');
|
||||
if (recipeGrid) {
|
||||
recipeGrid.innerHTML = '';
|
||||
}
|
||||
|
||||
// Re-enable virtual scrolling
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.enable();
|
||||
} else {
|
||||
// If virtual scroller doesn't exist, reinitialize it
|
||||
setTimeout(() => {
|
||||
initializeInfiniteScroll('recipes');
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
renderDuplicateGroups() {
|
||||
const recipeGrid = document.getElementById('recipeGrid');
|
||||
if (!recipeGrid) return;
|
||||
|
||||
// Clear existing content
|
||||
recipeGrid.innerHTML = '';
|
||||
|
||||
// Render each duplicate group
|
||||
this.duplicateGroups.forEach((group, groupIndex) => {
|
||||
const groupDiv = document.createElement('div');
|
||||
groupDiv.className = 'duplicate-group';
|
||||
groupDiv.dataset.fingerprint = group.fingerprint;
|
||||
|
||||
// Create group header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'duplicate-group-header';
|
||||
header.innerHTML = `
|
||||
<span>Duplicate Group #${groupIndex + 1} (${group.recipes.length} recipes)</span>
|
||||
<span>
|
||||
<button class="btn-select-all" onclick="recipeManager.duplicatesManager.toggleSelectAllInGroup('${group.fingerprint}')">
|
||||
Select All
|
||||
</button>
|
||||
<button class="btn-select-latest" onclick="recipeManager.duplicatesManager.selectLatestInGroup('${group.fingerprint}')">
|
||||
Keep Latest
|
||||
</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 recipes in the group
|
||||
if (group.recipes.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);
|
||||
}
|
||||
|
||||
// Sort recipes by date (newest first)
|
||||
const sortedRecipes = [...group.recipes].sort((a, b) => b.modified - a.modified);
|
||||
|
||||
// Add all recipe cards in this group
|
||||
sortedRecipes.forEach((recipe, index) => {
|
||||
// Create recipe card
|
||||
const recipeCard = new RecipeCard(recipe, (recipe) => {
|
||||
this.recipeManager.showRecipeDetails(recipe);
|
||||
});
|
||||
const card = recipeCard.element;
|
||||
|
||||
// Add duplicate class
|
||||
card.classList.add('duplicate');
|
||||
|
||||
// Mark the latest one
|
||||
if (index === 0) {
|
||||
card.classList.add('latest');
|
||||
}
|
||||
|
||||
// Add selection checkbox
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'selector-checkbox';
|
||||
checkbox.dataset.recipeId = recipe.id;
|
||||
checkbox.dataset.groupFingerprint = group.fingerprint;
|
||||
|
||||
// Check if already selected
|
||||
if (this.selectedForDeletion.has(recipe.id)) {
|
||||
checkbox.checked = true;
|
||||
card.classList.add('duplicate-selected');
|
||||
}
|
||||
|
||||
// Add change event to checkbox
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleCardSelection(recipe.id, 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(recipe.id, card, checkbox);
|
||||
});
|
||||
|
||||
card.appendChild(checkbox);
|
||||
cardsDiv.appendChild(card);
|
||||
});
|
||||
|
||||
groupDiv.appendChild(cardsDiv);
|
||||
recipeGrid.appendChild(groupDiv);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper method to toggle card selection state
|
||||
toggleCardSelection(recipeId, card, checkbox) {
|
||||
if (checkbox.checked) {
|
||||
this.selectedForDeletion.add(recipeId);
|
||||
card.classList.add('duplicate-selected');
|
||||
} else {
|
||||
this.selectedForDeletion.delete(recipeId);
|
||||
card.classList.remove('duplicate-selected');
|
||||
}
|
||||
|
||||
this.updateSelectedCount();
|
||||
}
|
||||
|
||||
updateSelectedCount() {
|
||||
const selectedCountEl = document.getElementById('selectedCount');
|
||||
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(fingerprint) {
|
||||
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`);
|
||||
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 recipeId = checkbox.dataset.recipeId;
|
||||
const card = checkbox.closest('.lora-card');
|
||||
|
||||
if (!allSelected) {
|
||||
this.selectedForDeletion.add(recipeId);
|
||||
card.classList.add('duplicate-selected');
|
||||
} else {
|
||||
this.selectedForDeletion.delete(recipeId);
|
||||
card.classList.remove('duplicate-selected');
|
||||
}
|
||||
});
|
||||
|
||||
// Update the button text
|
||||
const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`);
|
||||
if (button) {
|
||||
button.textContent = !allSelected ? "Deselect All" : "Select All";
|
||||
}
|
||||
|
||||
this.updateSelectedCount();
|
||||
}
|
||||
|
||||
selectAllInGroup(fingerprint) {
|
||||
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`);
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.checked = true;
|
||||
this.selectedForDeletion.add(checkbox.dataset.recipeId);
|
||||
checkbox.closest('.lora-card').classList.add('duplicate-selected');
|
||||
});
|
||||
|
||||
// Update the button text
|
||||
const button = document.querySelector(`.duplicate-group[data-fingerprint="${fingerprint}"] .btn-select-all`);
|
||||
if (button) {
|
||||
button.textContent = "Deselect All";
|
||||
}
|
||||
|
||||
this.updateSelectedCount();
|
||||
}
|
||||
|
||||
selectLatestInGroup(fingerprint) {
|
||||
// Find all checkboxes in this group
|
||||
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-fingerprint="${fingerprint}"]`);
|
||||
|
||||
// Get all the recipes in this group
|
||||
const group = this.duplicateGroups.find(g => g.fingerprint === fingerprint);
|
||||
if (!group) return;
|
||||
|
||||
// Sort recipes by date (newest first)
|
||||
const sortedRecipes = [...group.recipes].sort((a, b) => b.modified - a.modified);
|
||||
|
||||
// Skip the first (latest) one and select the rest for deletion
|
||||
for (let i = 1; i < sortedRecipes.length; i++) {
|
||||
const recipeId = sortedRecipes[i].id;
|
||||
const checkbox = document.querySelector(`.selector-checkbox[data-recipe-id="${recipeId}"]`);
|
||||
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
this.selectedForDeletion.add(recipeId);
|
||||
checkbox.closest('.lora-card').classList.add('duplicate-selected');
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the latest one is not selected
|
||||
const latestId = sortedRecipes[0].id;
|
||||
const latestCheckbox = document.querySelector(`.selector-checkbox[data-recipe-id="${latestId}"]`);
|
||||
|
||||
if (latestCheckbox) {
|
||||
latestCheckbox.checked = false;
|
||||
this.selectedForDeletion.delete(latestId);
|
||||
latestCheckbox.closest('.lora-card').classList.remove('duplicate-selected');
|
||||
}
|
||||
|
||||
this.updateSelectedCount();
|
||||
}
|
||||
|
||||
selectLatestDuplicates() {
|
||||
// For each duplicate group, select all but the latest recipe
|
||||
this.duplicateGroups.forEach(group => {
|
||||
this.selectLatestInGroup(group.fingerprint);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSelectedDuplicates() {
|
||||
if (this.selectedForDeletion.size === 0) {
|
||||
showToast('No recipes selected for deletion', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show the delete confirmation modal instead of a simple confirm
|
||||
const duplicateDeleteCount = document.getElementById('duplicateDeleteCount');
|
||||
if (duplicateDeleteCount) {
|
||||
duplicateDeleteCount.textContent = this.selectedForDeletion.size;
|
||||
}
|
||||
|
||||
// Use the modal manager to show the confirmation modal
|
||||
modalManager.showModal('duplicateDeleteModal');
|
||||
} catch (error) {
|
||||
console.error('Error preparing delete:', error);
|
||||
showToast('Error: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Add new method to execute deletion after confirmation
|
||||
async confirmDeleteDuplicates() {
|
||||
try {
|
||||
document.body.classList.add('loading');
|
||||
|
||||
// Close the modal
|
||||
modalManager.closeModal('duplicateDeleteModal');
|
||||
|
||||
// Prepare recipe IDs for deletion
|
||||
const recipeIds = Array.from(this.selectedForDeletion);
|
||||
|
||||
// Call API to bulk delete
|
||||
const response = await fetch('/api/recipes/bulk-delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ recipe_ids: recipeIds })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete selected recipes');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Unknown error deleting recipes');
|
||||
}
|
||||
|
||||
showToast(`Successfully deleted ${data.total_deleted} recipes`, 'success');
|
||||
|
||||
// Exit duplicate mode if deletions were successful
|
||||
if (data.total_deleted > 0) {
|
||||
this.exitDuplicateMode();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting recipes:', error);
|
||||
showToast('Failed to delete recipes: ' + error.message, 'error');
|
||||
} finally {
|
||||
document.body.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
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 { showLoraModal } from './loraModal/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
@@ -6,6 +6,197 @@ import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
|
||||
import { showDeleteModal } from '../utils/modalUtils.js';
|
||||
|
||||
// Add a global event delegation handler
|
||||
export function setupLoraCardEventDelegation() {
|
||||
const gridElement = document.getElementById('loraGrid');
|
||||
if (!gridElement) return;
|
||||
|
||||
// Remove any existing event listener to prevent duplication
|
||||
gridElement.removeEventListener('click', handleLoraCardEvent);
|
||||
|
||||
// Add the event delegation handler
|
||||
gridElement.addEventListener('click', handleLoraCardEvent);
|
||||
}
|
||||
|
||||
// Event delegation handler for all lora card events
|
||||
function handleLoraCardEvent(event) {
|
||||
// Find the closest card element
|
||||
const card = event.target.closest('.lora-card');
|
||||
if (!card) return;
|
||||
|
||||
// Handle specific elements within the card
|
||||
if (event.target.closest('.toggle-blur-btn')) {
|
||||
event.stopPropagation();
|
||||
toggleBlurContent(card);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.show-content-btn')) {
|
||||
event.stopPropagation();
|
||||
showBlurredContent(card);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-star')) {
|
||||
event.stopPropagation();
|
||||
toggleFavorite(card);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-globe')) {
|
||||
event.stopPropagation();
|
||||
if (card.dataset.from_civitai === 'true') {
|
||||
openCivitai(card.dataset.name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-paper-plane')) {
|
||||
event.stopPropagation();
|
||||
sendLoraToComfyUI(card, event.shiftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-copy')) {
|
||||
event.stopPropagation();
|
||||
copyLoraSyntax(card);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.fa-image')) {
|
||||
event.stopPropagation();
|
||||
replacePreview(card.dataset.filepath);
|
||||
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 (state.bulkMode) {
|
||||
// Toggle selection using the bulk manager
|
||||
bulkManager.toggleCardSelection(card);
|
||||
} else {
|
||||
// Normal behavior - show modal
|
||||
const loraMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
folder: card.dataset.folder,
|
||||
modified: card.dataset.modified,
|
||||
file_size: card.dataset.file_size,
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
base_model: card.dataset.base_model,
|
||||
usage_tips: card.dataset.usage_tips,
|
||||
notes: card.dataset.notes,
|
||||
favorite: card.dataset.favorite === 'true',
|
||||
// Parse civitai metadata from the card's dataset
|
||||
civitai: (() => {
|
||||
try {
|
||||
// Attempt to parse the JSON string
|
||||
return JSON.parse(card.dataset.meta || '{}');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse civitai metadata:', e);
|
||||
return {}; // Return empty object on error
|
||||
}
|
||||
})(),
|
||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||
modelDescription: card.dataset.modelDescription || ''
|
||||
};
|
||||
showLoraModal(loraMeta);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for event handling
|
||||
function toggleBlurContent(card) {
|
||||
const preview = card.querySelector('.card-preview');
|
||||
const isBlurred = preview.classList.toggle('blurred');
|
||||
const icon = card.querySelector('.toggle-blur-btn 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 = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function showBlurredContent(card) {
|
||||
const preview = card.querySelector('.card-preview');
|
||||
preview.classList.remove('blurred');
|
||||
|
||||
// Update the toggle button icon
|
||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Hide the overlay
|
||||
const overlay = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite(card) {
|
||||
const starIcon = card.querySelector('.fa-star');
|
||||
const isFavorite = starIcon.classList.contains('fas');
|
||||
const newFavoriteState = !isFavorite;
|
||||
|
||||
try {
|
||||
// Save the new favorite state to the server
|
||||
await saveModelMetadata(card.dataset.filepath, {
|
||||
favorite: newFavoriteState
|
||||
});
|
||||
|
||||
// Update the UI
|
||||
if (newFavoriteState) {
|
||||
starIcon.classList.remove('far');
|
||||
starIcon.classList.add('fas', 'favorite-active');
|
||||
starIcon.title = 'Remove from favorites';
|
||||
card.dataset.favorite = 'true';
|
||||
showToast('Added to favorites', 'success');
|
||||
} else {
|
||||
starIcon.classList.remove('fas', 'favorite-active');
|
||||
starIcon.classList.add('far');
|
||||
starIcon.title = 'Add to favorites';
|
||||
card.dataset.favorite = 'false';
|
||||
showToast('Removed from favorites', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update favorite status:', error);
|
||||
showToast('Failed to update favorite status', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Function to send LoRA to ComfyUI workflow
|
||||
async function sendLoraToComfyUI(card, replaceMode) {
|
||||
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');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'lora-card';
|
||||
@@ -94,12 +285,12 @@ export function createLoraCard(lora) {
|
||||
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
|
||||
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
|
||||
</i>
|
||||
<i class="fas fa-paper-plane"
|
||||
title="Send to ComfyUI (Click: Append, Shift+Click: Replace)">
|
||||
</i>
|
||||
<i class="fas fa-copy"
|
||||
title="Copy LoRA Syntax">
|
||||
</i>
|
||||
<i class="fas fa-trash"
|
||||
title="Delete Model">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
${shouldBlur ? `
|
||||
@@ -115,170 +306,20 @@ export function createLoraCard(lora) {
|
||||
<span class="model-name">${lora.model_name}</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-image"
|
||||
title="Replace Preview Image">
|
||||
<i class="fas fa-folder-open"
|
||||
title="Open Example Images Folder">
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Main card click event - modified to handle bulk mode
|
||||
card.addEventListener('click', () => {
|
||||
// Check if we're in bulk mode
|
||||
if (state.bulkMode) {
|
||||
// Toggle selection using the bulk manager
|
||||
bulkManager.toggleCardSelection(card);
|
||||
} else {
|
||||
// Normal behavior - show modal
|
||||
const loraMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
folder: card.dataset.folder,
|
||||
modified: card.dataset.modified,
|
||||
file_size: card.dataset.file_size,
|
||||
from_civitai: card.dataset.from_civitai === 'true',
|
||||
base_model: card.dataset.base_model,
|
||||
usage_tips: card.dataset.usage_tips,
|
||||
notes: card.dataset.notes,
|
||||
favorite: card.dataset.favorite === 'true',
|
||||
// Parse civitai metadata from the card's dataset
|
||||
civitai: (() => {
|
||||
try {
|
||||
// Attempt to parse the JSON string
|
||||
return JSON.parse(card.dataset.meta || '{}');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse civitai metadata:', e);
|
||||
return {}; // Return empty object on error
|
||||
}
|
||||
})(),
|
||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||
modelDescription: card.dataset.modelDescription || ''
|
||||
};
|
||||
showLoraModal(loraMeta);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle blur button functionality
|
||||
const toggleBlurBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBlurBtn) {
|
||||
toggleBlurBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const preview = card.querySelector('.card-preview');
|
||||
const isBlurred = preview.classList.toggle('blurred');
|
||||
const icon = toggleBlurBtn.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 = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Show content button functionality
|
||||
const showContentBtn = card.querySelector('.show-content-btn');
|
||||
if (showContentBtn) {
|
||||
showContentBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const preview = card.querySelector('.card-preview');
|
||||
preview.classList.remove('blurred');
|
||||
|
||||
// Update the toggle button icon
|
||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Hide the overlay
|
||||
const overlay = card.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Favorite button click event
|
||||
card.querySelector('.fa-star')?.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const starIcon = e.currentTarget;
|
||||
const isFavorite = starIcon.classList.contains('fas');
|
||||
const newFavoriteState = !isFavorite;
|
||||
|
||||
try {
|
||||
// Save the new favorite state to the server
|
||||
await saveModelMetadata(card.dataset.filepath, {
|
||||
favorite: newFavoriteState
|
||||
});
|
||||
|
||||
// Update the UI
|
||||
if (newFavoriteState) {
|
||||
starIcon.classList.remove('far');
|
||||
starIcon.classList.add('fas', 'favorite-active');
|
||||
starIcon.title = 'Remove from favorites';
|
||||
card.dataset.favorite = 'true';
|
||||
showToast('Added to favorites', 'success');
|
||||
} else {
|
||||
starIcon.classList.remove('fas', 'favorite-active');
|
||||
starIcon.classList.add('far');
|
||||
starIcon.title = 'Add to favorites';
|
||||
card.dataset.favorite = 'false';
|
||||
showToast('Removed from favorites', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update favorite status:', error);
|
||||
showToast('Failed to update favorite status', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Copy button click event
|
||||
card.querySelector('.fa-copy')?.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
|
||||
await copyToClipboard(loraSyntax, 'LoRA syntax copied');
|
||||
});
|
||||
|
||||
// Civitai button click event
|
||||
if (lora.from_civitai) {
|
||||
card.querySelector('.fa-globe')?.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
openCivitai(lora.model_name);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete button click event
|
||||
card.querySelector('.fa-trash')?.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
showDeleteModal(lora.file_path);
|
||||
});
|
||||
|
||||
// Replace preview button click event
|
||||
card.querySelector('.fa-image')?.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
replacePreview(lora.file_path);
|
||||
});
|
||||
|
||||
// Apply bulk mode styling if currently in bulk mode
|
||||
if (state.bulkMode) {
|
||||
const actions = card.querySelectorAll('.card-actions');
|
||||
actions.forEach(actionGroup => {
|
||||
actionGroup.style.display = 'none';
|
||||
});
|
||||
// Add a special class for virtual scroll positioning if needed
|
||||
if (state.virtualScroller) {
|
||||
card.classList.add('virtual-scroll-item');
|
||||
}
|
||||
|
||||
// Add autoplayOnHover handlers for video elements if needed
|
||||
// Add video auto-play on hover functionality if needed
|
||||
const videoElement = card.querySelector('video');
|
||||
if (videoElement && autoplayOnHover) {
|
||||
const cardPreview = card.querySelector('.card-preview');
|
||||
@@ -287,15 +328,10 @@ export function createLoraCard(lora) {
|
||||
videoElement.removeAttribute('autoplay');
|
||||
videoElement.pause();
|
||||
|
||||
// Add mouse events to trigger play/pause
|
||||
cardPreview.addEventListener('mouseenter', () => {
|
||||
videoElement.play();
|
||||
});
|
||||
|
||||
cardPreview.addEventListener('mouseleave', () => {
|
||||
videoElement.pause();
|
||||
videoElement.currentTime = 0;
|
||||
});
|
||||
// Add mouse events to trigger play/pause using event attributes
|
||||
// This approach reduces the number of event listeners created
|
||||
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
|
||||
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
|
||||
}
|
||||
|
||||
return card;
|
||||
@@ -308,7 +344,7 @@ export function updateCardsForBulkMode(isBulkMode) {
|
||||
|
||||
document.body.classList.toggle('bulk-mode', isBulkMode);
|
||||
|
||||
// Get all lora cards
|
||||
// Get all lora cards - this can now be from the DOM or through the virtual scroller
|
||||
const loraCards = document.querySelectorAll('.lora-card');
|
||||
|
||||
loraCards.forEach(card => {
|
||||
@@ -330,6 +366,11 @@ export function updateCardsForBulkMode(isBulkMode) {
|
||||
}
|
||||
});
|
||||
|
||||
// If using virtual scroller, we need to rerender after toggling bulk mode
|
||||
if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') {
|
||||
state.virtualScroller.scheduleRender();
|
||||
}
|
||||
|
||||
// Apply selection state to cards if entering bulk mode
|
||||
if (isBulkMode) {
|
||||
bulkManager.applySelectionState();
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// 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 { getCurrentPageState } from '../state/index.js';
|
||||
|
||||
class RecipeCard {
|
||||
constructor(recipe, clickHandler) {
|
||||
this.recipe = recipe;
|
||||
this.clickHandler = clickHandler;
|
||||
this.element = this.createCardElement();
|
||||
|
||||
// Store reference to this instance on the DOM element for updates
|
||||
this.element._recipeCardInstance = this;
|
||||
}
|
||||
|
||||
createCardElement() {
|
||||
@@ -33,33 +37,41 @@ class RecipeCard {
|
||||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
|
||||
// Check if in duplicates mode
|
||||
const pageState = getCurrentPageState();
|
||||
const isDuplicatesMode = pageState.duplicatesMode;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="recipe-indicator" title="Recipe">R</div>
|
||||
${!isDuplicatesMode ? `<div class="recipe-indicator" title="Recipe">R</div>` : ''}
|
||||
<div class="card-preview">
|
||||
<img src="${imageUrl}" alt="${this.recipe.title}">
|
||||
${!isDuplicatesMode ? `
|
||||
<div class="card-header">
|
||||
<div class="base-model-wrapper">
|
||||
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
||||
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
|
||||
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${this.recipe.title}</span>
|
||||
</div>
|
||||
${!isDuplicatesMode ? `
|
||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachEventListeners(card);
|
||||
this.attachEventListeners(card, isDuplicatesMode);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -69,58 +81,59 @@ class RecipeCard {
|
||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||
}
|
||||
|
||||
attachEventListeners(card) {
|
||||
// Recipe card click event
|
||||
card.addEventListener('click', () => {
|
||||
this.clickHandler(this.recipe);
|
||||
});
|
||||
|
||||
// Share button click event - prevent propagation to card
|
||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.shareRecipe();
|
||||
});
|
||||
|
||||
// Copy button click event - prevent propagation to card
|
||||
card.querySelector('.fa-copy')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.copyRecipeSyntax();
|
||||
});
|
||||
|
||||
// Delete button click event - prevent propagation to card
|
||||
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.showDeleteConfirmation();
|
||||
});
|
||||
attachEventListeners(card, isDuplicatesMode) {
|
||||
// Recipe card click event - only attach if not in duplicates mode
|
||||
if (!isDuplicatesMode) {
|
||||
card.addEventListener('click', () => {
|
||||
this.clickHandler(this.recipe);
|
||||
});
|
||||
|
||||
// Share button click event - prevent propagation to card
|
||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.shareRecipe();
|
||||
});
|
||||
|
||||
// Send button click event - prevent propagation to card
|
||||
card.querySelector('.fa-paper-plane')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.sendRecipeToWorkflow(e.shiftKey);
|
||||
});
|
||||
|
||||
// Delete button click event - prevent propagation to card
|
||||
card.querySelector('.fa-trash')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.showDeleteConfirmation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
copyRecipeSyntax() {
|
||||
// Replace copyRecipeSyntax with sendRecipeToWorkflow
|
||||
sendRecipeToWorkflow(replaceMode = false) {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
||||
showToast('Cannot send recipe: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fallback if button not found
|
||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.syntax) {
|
||||
return copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
return sendLoraToWorkflow(data.syntax, replaceMode, 'recipe');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
console.error('Failed to send recipe to workflow: ', err);
|
||||
showToast('Failed to send recipe to workflow', 'error');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error copying recipe syntax:', error);
|
||||
showToast('Error copying recipe syntax', 'error');
|
||||
console.error('Error sending recipe to workflow:', error);
|
||||
showToast('Error sending recipe to workflow', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||
import { updateRecipeCard } from '../utils/cardUpdater.js';
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
@@ -82,7 +83,7 @@ class RecipeModal {
|
||||
|
||||
showRecipeDetails(recipe) {
|
||||
// Store the full recipe for editing
|
||||
this.currentRecipe = JSON.parse(JSON.stringify(recipe)); // 深拷贝以避免对原始对象的修改
|
||||
this.currentRecipe = recipe;
|
||||
|
||||
// Set modal title with edit icon
|
||||
const modalTitle = document.getElementById('recipeModalTitle');
|
||||
@@ -245,6 +246,45 @@ class RecipeModal {
|
||||
imgElement.alt = recipe.title || 'Recipe Preview';
|
||||
mediaContainer.appendChild(imgElement);
|
||||
}
|
||||
|
||||
// Add source URL container if the recipe has a source_path
|
||||
const sourceUrlContainer = document.createElement('div');
|
||||
sourceUrlContainer.className = 'source-url-container';
|
||||
const hasSourceUrl = recipe.source_path && recipe.source_path.trim().length > 0;
|
||||
const sourceUrl = hasSourceUrl ? recipe.source_path : '';
|
||||
const isValidUrl = hasSourceUrl && (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://'));
|
||||
|
||||
sourceUrlContainer.innerHTML = `
|
||||
<div class="source-url-content">
|
||||
<span class="source-url-icon"><i class="fas fa-link"></i></span>
|
||||
<span class="source-url-text" title="${isValidUrl ? 'Click to open source URL' : 'No valid URL'}">${
|
||||
hasSourceUrl ? sourceUrl : 'No source URL'
|
||||
}</span>
|
||||
</div>
|
||||
<button class="source-url-edit-btn" title="Edit source URL">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add source URL editor
|
||||
const sourceUrlEditor = document.createElement('div');
|
||||
sourceUrlEditor.className = 'source-url-editor';
|
||||
sourceUrlEditor.innerHTML = `
|
||||
<input type="text" class="source-url-input" placeholder="Enter source URL (e.g., https://civitai.com/...)" value="${sourceUrl}">
|
||||
<div class="source-url-actions">
|
||||
<button class="source-url-cancel-btn">Cancel</button>
|
||||
<button class="source-url-save-btn">Save</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Append both containers to the media container
|
||||
mediaContainer.appendChild(sourceUrlContainer);
|
||||
mediaContainer.appendChild(sourceUrlEditor);
|
||||
|
||||
// Set up event listeners for source URL functionality
|
||||
setTimeout(() => {
|
||||
this.setupSourceUrlHandlers();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Set generation parameters
|
||||
@@ -451,8 +491,6 @@ class RecipeModal {
|
||||
lorasListElement.innerHTML = '<div class="no-loras">No LoRAs associated with this recipe</div>';
|
||||
this.recipeLorasSyntax = '';
|
||||
}
|
||||
|
||||
console.log(this.currentRecipe.loras);
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('recipeModal');
|
||||
@@ -648,50 +686,8 @@ class RecipeModal {
|
||||
// 更新当前recipe对象的属性
|
||||
Object.assign(this.currentRecipe, updates);
|
||||
|
||||
// 确保这个更新也传播到卡片视图
|
||||
// 尝试找到可能显示这个recipe的卡片并更新它
|
||||
try {
|
||||
const recipeCards = document.querySelectorAll('.recipe-card');
|
||||
recipeCards.forEach(card => {
|
||||
if (card.dataset.recipeId === this.recipeId) {
|
||||
// 更新卡片标题
|
||||
if (updates.title) {
|
||||
const titleElement = card.querySelector('.recipe-title');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = updates.title;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新卡片标签
|
||||
if (updates.tags) {
|
||||
const tagsElement = card.querySelector('.recipe-tags');
|
||||
if (tagsElement) {
|
||||
if (updates.tags.length > 0) {
|
||||
tagsElement.innerHTML = updates.tags.map(
|
||||
tag => `<div class="recipe-tag">${tag}</div>`
|
||||
).join('');
|
||||
} else {
|
||||
tagsElement.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.log("Non-critical error updating recipe cards:", err);
|
||||
}
|
||||
|
||||
// 重要:强制刷新recipes列表,确保从服务器获取最新数据
|
||||
try {
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
// 异步刷新recipes列表,不阻塞用户界面
|
||||
setTimeout(() => {
|
||||
window.recipeManager.loadRecipes(true);
|
||||
}, 500);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Error refreshing recipes list:", err);
|
||||
}
|
||||
// Update the recipe card in the UI
|
||||
updateRecipeCard(this.recipeId, updates);
|
||||
} else {
|
||||
showToast(`Failed to update recipe: ${data.error}`, 'error');
|
||||
}
|
||||
@@ -951,8 +947,8 @@ class RecipeModal {
|
||||
let loraSyntaxMatch = inputValue.match(/<lora:([^:>]+)(?::[^>]+)?>/);
|
||||
let fileName = loraSyntaxMatch ? loraSyntaxMatch[1] : inputValue.trim();
|
||||
|
||||
// Remove any file extension if present
|
||||
fileName = fileName.replace(/\.\w+$/, '');
|
||||
// Remove .safetensors extension if present
|
||||
fileName = fileName.replace(/\.safetensors$/, '');
|
||||
|
||||
// Get the deleted lora data
|
||||
const deletedLora = this.currentRecipe.loras[loraIndex];
|
||||
@@ -1069,6 +1065,56 @@ class RecipeModal {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// New method to set up source URL handlers
|
||||
setupSourceUrlHandlers() {
|
||||
const sourceUrlContainer = document.querySelector('.source-url-container');
|
||||
const sourceUrlEditor = document.querySelector('.source-url-editor');
|
||||
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
|
||||
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
|
||||
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
|
||||
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
|
||||
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
|
||||
|
||||
// Show editor on edit button click
|
||||
sourceUrlEditBtn.addEventListener('click', () => {
|
||||
sourceUrlContainer.classList.add('hide');
|
||||
sourceUrlEditor.classList.add('active');
|
||||
sourceUrlInput.focus();
|
||||
});
|
||||
|
||||
// Cancel editing
|
||||
sourceUrlCancelBtn.addEventListener('click', () => {
|
||||
sourceUrlEditor.classList.remove('active');
|
||||
sourceUrlContainer.classList.remove('hide');
|
||||
sourceUrlInput.value = this.currentRecipe.source_path || '';
|
||||
});
|
||||
|
||||
// Save new source URL
|
||||
sourceUrlSaveBtn.addEventListener('click', () => {
|
||||
const newSourceUrl = sourceUrlInput.value.trim();
|
||||
if (newSourceUrl && newSourceUrl !== this.currentRecipe.source_path) {
|
||||
// Update source URL in the UI
|
||||
sourceUrlText.textContent = newSourceUrl;
|
||||
sourceUrlText.title = newSourceUrl.startsWith('http://') || newSourceUrl.startsWith('https://') ? 'Click to open source URL' : 'No valid URL';
|
||||
|
||||
// Update the recipe on the server
|
||||
this.updateRecipeMetadata({ source_path: newSourceUrl });
|
||||
}
|
||||
|
||||
// Hide editor
|
||||
sourceUrlEditor.classList.remove('active');
|
||||
sourceUrlContainer.classList.remove('hide');
|
||||
});
|
||||
|
||||
// Open source URL in a new tab if it's valid
|
||||
sourceUrlText.addEventListener('click', () => {
|
||||
const url = sourceUrlText.textContent.trim();
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { RecipeModal };
|
||||
@@ -5,7 +5,7 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.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
|
||||
@@ -17,6 +17,9 @@ export function setupModelNameEditing(filePath) {
|
||||
|
||||
if (!modelNameContent || !editBtn) return;
|
||||
|
||||
// Store the file path in a data attribute for later use
|
||||
modelNameContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const modelNameHeader = document.querySelector('.model-name-header');
|
||||
modelNameHeader.addEventListener('mouseenter', () => {
|
||||
@@ -24,14 +27,17 @@ export function setupModelNameEditing(filePath) {
|
||||
});
|
||||
|
||||
modelNameHeader.addEventListener('mouseleave', () => {
|
||||
if (!modelNameContent.getAttribute('data-editing')) {
|
||||
if (!modelNameHeader.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button 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();
|
||||
|
||||
// Place cursor at the end
|
||||
@@ -47,33 +53,25 @@ export function setupModelNameEditing(filePath) {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle focus out
|
||||
modelNameContent.addEventListener('blur', function() {
|
||||
this.removeAttribute('data-editing');
|
||||
editBtn.classList.remove('visible');
|
||||
|
||||
if (this.textContent.trim() === '') {
|
||||
// Restore original model name if empty
|
||||
// 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
|
||||
// Handle keyboard events in edit mode
|
||||
modelNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Use the passed filePath
|
||||
saveModelName(filePath);
|
||||
this.blur();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Limit model name length
|
||||
modelNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (this.textContent.length > 100) {
|
||||
this.textContent = this.textContent.substring(0, 100);
|
||||
// Place cursor at the end
|
||||
@@ -87,44 +85,59 @@ export function setupModelNameEditing(filePath) {
|
||||
showToast('Model name is limited to 100 characters', 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save model name
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
async function saveModelName(filePath) {
|
||||
const modelNameElement = document.querySelector('.model-name-content');
|
||||
const newModelName = modelNameElement.textContent.trim();
|
||||
|
||||
// Validate model name
|
||||
if (!newModelName) {
|
||||
showToast('Model name cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
// 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 checkpoint card's dataset and display
|
||||
updateCheckpointCard(filePath, { model_name: newModelName });
|
||||
|
||||
// BUGFIX: Directly update the card's dataset.name attribute to ensure
|
||||
// it's correctly read when reopening the modal
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
// Check if model name is too long
|
||||
if (newModelName.length > 100) {
|
||||
showToast('Model name is too long (maximum 100 characters)', 'error');
|
||||
// Truncate the displayed text
|
||||
modelNameElement.textContent = newModelName.substring(0, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveModelMetadata(filePath, { model_name: newModelName });
|
||||
|
||||
// Update the card with the new model name
|
||||
updateCheckpointCard(filePath, { name: newModelName });
|
||||
|
||||
showToast('Model name updated successfully', 'success');
|
||||
|
||||
// No need to reload the entire page
|
||||
// setTimeout(() => {
|
||||
// window.location.reload();
|
||||
// }, 1500);
|
||||
} catch (error) {
|
||||
showToast('Failed to update model name', 'error');
|
||||
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;
|
||||
|
||||
// Store the file path in a data attribute for later use
|
||||
baseModelContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const baseModelDisplay = document.querySelector('.base-model-display');
|
||||
baseModelDisplay.addEventListener('mouseenter', () => {
|
||||
@@ -303,6 +319,9 @@ export function setupFileNameEditing(filePath) {
|
||||
|
||||
if (!fileNameContent || !editBtn) return;
|
||||
|
||||
// Store the original file path
|
||||
fileNameContent.dataset.filePath = filePath;
|
||||
|
||||
// Show edit button on hover
|
||||
const fileNameWrapper = document.querySelector('.file-name-wrapper');
|
||||
fileNameWrapper.addEventListener('mouseenter', () => {
|
||||
@@ -400,19 +419,8 @@ export function setupFileNameEditing(filePath) {
|
||||
|
||||
try {
|
||||
// Use the passed filePath (which includes the original filename)
|
||||
// Call API to rename the file
|
||||
const response = await fetch('/api/rename_checkpoint', {
|
||||
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();
|
||||
// Call API to rename the file using the new function from checkpointApi.js
|
||||
const result = await renameCheckpointFile(filePath, newFileName);
|
||||
|
||||
if (result.success) {
|
||||
showToast('File name updated successfully', 'success');
|
||||
|
||||
@@ -27,11 +27,25 @@ export function showCheckpointModal(checkpoint) {
|
||||
<button class="close" onclick="modalManager.closeModal('checkpointModal')">×</button>
|
||||
<header class="modal-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">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</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 || [])}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ export class CheckpointsControls extends PageControls {
|
||||
return await resetAndReload(updateFolders);
|
||||
},
|
||||
|
||||
refreshModels: async () => {
|
||||
return await refreshCheckpoints();
|
||||
refreshModels: async (fullRebuild = false) => {
|
||||
return await refreshCheckpoints(fullRebuild);
|
||||
},
|
||||
|
||||
// Add fetch from Civitai functionality for checkpoints
|
||||
|
||||
@@ -36,8 +36,8 @@ export class LorasControls extends PageControls {
|
||||
return await resetAndReload(updateFolders);
|
||||
},
|
||||
|
||||
refreshModels: async () => {
|
||||
return await refreshLoras();
|
||||
refreshModels: async (fullRebuild = false) => {
|
||||
return await refreshLoras(fullRebuild);
|
||||
},
|
||||
|
||||
// LoRA-specific API functions
|
||||
|
||||
@@ -37,7 +37,7 @@ export class PageControls {
|
||||
*/
|
||||
initializeState() {
|
||||
// Set default values
|
||||
this.pageState.pageSize = 20;
|
||||
this.pageState.pageSize = 100;
|
||||
this.pageState.isLoading = false;
|
||||
this.pageState.hasMore = true;
|
||||
|
||||
@@ -83,9 +83,12 @@ export class PageControls {
|
||||
// Refresh button handler
|
||||
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => this.refreshModels());
|
||||
refreshBtn.addEventListener('click', () => this.refreshModels(false)); // Regular refresh (incremental)
|
||||
}
|
||||
|
||||
// Initialize dropdown functionality
|
||||
this.initDropdowns();
|
||||
|
||||
// Toggle folders button
|
||||
const toggleFoldersBtn = document.querySelector('.toggle-folders-btn');
|
||||
if (toggleFoldersBtn) {
|
||||
@@ -102,6 +105,61 @@ export class PageControls {
|
||||
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
|
||||
*/
|
||||
@@ -327,18 +385,19 @@ export class PageControls {
|
||||
|
||||
/**
|
||||
* Refresh models list
|
||||
* @param {boolean} fullRebuild - Whether to perform a full rebuild
|
||||
*/
|
||||
async refreshModels() {
|
||||
async refreshModels(fullRebuild = false) {
|
||||
if (!this.api) {
|
||||
console.error('API methods not registered');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.refreshModels();
|
||||
await this.api.refreshModels(fullRebuild);
|
||||
} catch (error) {
|
||||
console.error(`Error refreshing ${this.pageType}:`, error);
|
||||
showToast(`Failed to refresh ${this.pageType}: ${error.message}`, 'error');
|
||||
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { PageControls } from './PageControls.js';
|
||||
import { LorasControls } from './LorasControls.js';
|
||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
|
||||
|
||||
// Export the classes
|
||||
export { PageControls, LorasControls, CheckpointsControls };
|
||||
@@ -20,4 +21,17 @@ export function createPageControls(pageType) {
|
||||
console.error(`Unknown page type: ${pageType}`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.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', () => {
|
||||
if (!modelNameContent.getAttribute('data-editing')) {
|
||||
if (!modelNameHeader.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button 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();
|
||||
|
||||
// Place cursor at the end
|
||||
@@ -50,33 +53,25 @@ export function setupModelNameEditing(filePath) {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle focus out
|
||||
modelNameContent.addEventListener('blur', function() {
|
||||
this.removeAttribute('data-editing');
|
||||
editBtn.classList.remove('visible');
|
||||
|
||||
if (this.textContent.trim() === '') {
|
||||
// Restore original model name if empty
|
||||
const filePath = this.dataset.filePath;
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
this.textContent = loraCard.dataset.model_name;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle enter key
|
||||
// Handle keyboard events in edit mode
|
||||
modelNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const filePath = this.dataset.filePath;
|
||||
saveModelName(filePath);
|
||||
this.blur();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Limit model name length
|
||||
modelNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
// Limit model name length
|
||||
if (this.textContent.length > 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');
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
const filePath = this.dataset.filePath;
|
||||
|
||||
// Call API to rename the file
|
||||
const response = await fetch('/api/rename_lora', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
new_file_name: newFileName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
// Call API to rename the file using the new function from loraApi.js
|
||||
const result = await renameLoraFile(filePath, newFileName);
|
||||
|
||||
if (result.success) {
|
||||
showToast('File name updated successfully', 'success');
|
||||
|
||||
@@ -24,6 +24,7 @@ import { updateLoraCard } from '../../utils/cardUpdater.js';
|
||||
* @param {Object} lora - LoRA模型数据
|
||||
*/
|
||||
export function showLoraModal(lora) {
|
||||
console.log('Lora data:', lora);
|
||||
const escapedWords = lora.civitai?.trainedWords?.length ?
|
||||
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
@@ -32,11 +33,25 @@ export function showLoraModal(lora) {
|
||||
<button class="close" onclick="modalManager.closeModal('loraModal')">×</button>
|
||||
<header class="modal-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">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</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 || [])}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
|
||||
|
||||
// Core application class
|
||||
export class AppCore {
|
||||
@@ -63,7 +64,12 @@ export class AppCore {
|
||||
// Initialize lazy loading for images on all pages
|
||||
lazyLoadImages();
|
||||
|
||||
// Initialize infinite scroll for pages that need it
|
||||
// Setup event delegation for lora cards if on the loras page
|
||||
if (pageType === 'loras') {
|
||||
setupLoraCardEventDelegation();
|
||||
}
|
||||
|
||||
// Initialize virtual scroll for pages that need it
|
||||
if (['loras', 'recipes', 'checkpoints'].includes(pageType)) {
|
||||
initializeInfiniteScroll(pageType);
|
||||
}
|
||||
|
||||
@@ -63,8 +63,14 @@ class LoraPageManager {
|
||||
// Initialize the bulk manager
|
||||
bulkManager.initialize();
|
||||
|
||||
// Initialize common page features (lazy loading, infinite scroll)
|
||||
// Initialize common page features (virtual scroll)
|
||||
appCore.initializePageFeatures();
|
||||
|
||||
// Add virtual scroll class to grid for CSS adjustments
|
||||
const loraGrid = document.getElementById('loraGrid');
|
||||
if (loraGrid && state.virtualScroller) {
|
||||
loraGrid.classList.add('virtual-scroll');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@ export class CheckpointDownloadManager {
|
||||
this.cleanupFolderBrowser();
|
||||
});
|
||||
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() {
|
||||
@@ -178,6 +186,11 @@ export class CheckpointDownloadManager {
|
||||
`;
|
||||
}).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
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ export class DownloadManager {
|
||||
this.cleanupFolderBrowser();
|
||||
});
|
||||
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() {
|
||||
@@ -182,6 +190,11 @@ export class DownloadManager {
|
||||
`;
|
||||
}).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
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,6 +158,30 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add duplicateDeleteModal registration
|
||||
const duplicateDeleteModal = document.getElementById('duplicateDeleteModal');
|
||||
if (duplicateDeleteModal) {
|
||||
this.registerModal('duplicateDeleteModal', {
|
||||
element: duplicateDeleteModal,
|
||||
onClose: () => {
|
||||
this.getModal('duplicateDeleteModal').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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for modal toggles
|
||||
const supportToggle = document.getElementById('supportToggleBtn');
|
||||
if (supportToggle) {
|
||||
@@ -221,7 +245,7 @@ export class ModalManager {
|
||||
// Store current scroll position before showing modal
|
||||
this.scrollPosition = window.scrollY;
|
||||
|
||||
if (id === 'deleteModal' || id === 'excludeModal') {
|
||||
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal' || id === 'clearCacheModal') {
|
||||
modal.element.classList.add('show');
|
||||
} else {
|
||||
modal.element.style.display = 'block';
|
||||
|
||||
@@ -26,6 +26,21 @@ export class SettingsManager {
|
||||
if (savedSettings) {
|
||||
state.global.settings = { ...state.global.settings, ...savedSettings };
|
||||
}
|
||||
|
||||
// Initialize default values for new settings if they don't exist
|
||||
if (state.global.settings.compactMode === undefined) {
|
||||
state.global.settings.compactMode = false;
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -76,6 +91,12 @@ export class SettingsManager {
|
||||
if (autoplayOnHoverCheckbox) {
|
||||
autoplayOnHoverCheckbox.checked = state.global.settings.autoplayOnHover || false;
|
||||
}
|
||||
|
||||
// Set display density setting
|
||||
const displayDensitySelect = document.getElementById('displayDensity');
|
||||
if (displayDensitySelect) {
|
||||
displayDensitySelect.value = state.global.settings.displayDensity || 'default';
|
||||
}
|
||||
|
||||
// Load default lora root
|
||||
await this.loadLoraRoots();
|
||||
@@ -149,6 +170,8 @@ export class SettingsManager {
|
||||
state.global.settings.autoplayOnHover = value;
|
||||
} else if (settingKey === 'optimize_example_images') {
|
||||
state.global.settings.optimizeExampleImages = value;
|
||||
} else if (settingKey === 'compact_mode') {
|
||||
state.global.settings.compactMode = value;
|
||||
} else {
|
||||
// For any other settings that might be added in the future
|
||||
state.global.settings[settingKey] = value;
|
||||
@@ -185,6 +208,12 @@ export class SettingsManager {
|
||||
this.reloadContent();
|
||||
}
|
||||
|
||||
// Recalculate layout when compact mode changes
|
||||
if (settingKey === 'compact_mode' && state.virtualScroller) {
|
||||
state.virtualScroller.calculateLayout();
|
||||
showToast(`Compact Mode ${value ? 'enabled' : 'disabled'}`, 'success');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
}
|
||||
@@ -200,6 +229,11 @@ export class SettingsManager {
|
||||
// Update frontend state
|
||||
if (settingKey === 'default_lora_root') {
|
||||
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 {
|
||||
// For any other settings that might be added in the future
|
||||
state.global.settings[settingKey] = value;
|
||||
@@ -210,22 +244,38 @@ export class SettingsManager {
|
||||
|
||||
try {
|
||||
// For backend settings, make API call
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (settingKey === 'default_lora_root') {
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save setting');
|
||||
if (!response.ok) {
|
||||
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) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
@@ -291,6 +341,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() {
|
||||
if (this.currentPage === 'loras') {
|
||||
// Reload the loras without updating folders
|
||||
@@ -400,8 +481,17 @@ export class SettingsManager {
|
||||
videoParent.replaceChild(videoClone, video);
|
||||
});
|
||||
|
||||
// For show_only_sfw, there's no immediate action needed as it affects content loading
|
||||
// The setting will take effect on next reload
|
||||
// Apply display density class to grid
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
256
static/js/managers/import/DownloadManager.js
Normal file
256
static/js/managers/import/DownloadManager.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor(importManager) {
|
||||
this.importManager = importManager;
|
||||
}
|
||||
|
||||
async saveRecipe() {
|
||||
// Check if we're in download-only mode (for existing recipe)
|
||||
const isDownloadOnly = !!this.importManager.recipeId;
|
||||
|
||||
if (!isDownloadOnly && !this.importManager.recipeName) {
|
||||
showToast('Please enter a recipe name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Show progress indicator
|
||||
this.importManager.loadingManager.showSimpleLoading(isDownloadOnly ? 'Downloading LoRAs...' : 'Saving recipe...');
|
||||
|
||||
// Only send the complete recipe to save if not in download-only mode
|
||||
if (!isDownloadOnly) {
|
||||
// Create FormData object for saving recipe
|
||||
const formData = new FormData();
|
||||
|
||||
// Add image data - depends on import mode
|
||||
if (this.importManager.recipeImage) {
|
||||
// Direct upload
|
||||
formData.append('image', this.importManager.recipeImage);
|
||||
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
|
||||
// URL mode with base64 data
|
||||
formData.append('image_base64', this.importManager.recipeData.image_base64);
|
||||
} else if (this.importManager.importMode === 'url') {
|
||||
// Fallback for URL mode - tell backend to fetch the image again
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
if (urlInput && urlInput.value) {
|
||||
formData.append('image_url', urlInput.value);
|
||||
} else {
|
||||
throw new Error('No image data available');
|
||||
}
|
||||
} else {
|
||||
throw new Error('No image data available');
|
||||
}
|
||||
|
||||
formData.append('name', this.importManager.recipeName);
|
||||
formData.append('tags', JSON.stringify(this.importManager.recipeTags));
|
||||
|
||||
// Prepare complete metadata including generation parameters
|
||||
const completeMetadata = {
|
||||
base_model: this.importManager.recipeData.base_model || "",
|
||||
loras: this.importManager.recipeData.loras || [],
|
||||
gen_params: this.importManager.recipeData.gen_params || {},
|
||||
raw_metadata: this.importManager.recipeData.raw_metadata || {}
|
||||
};
|
||||
|
||||
// Add source_path to metadata to track where the recipe was imported from
|
||||
if (this.importManager.importMode === 'url') {
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
if (urlInput && urlInput.value) {
|
||||
completeMetadata.source_path = urlInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
formData.append('metadata', JSON.stringify(completeMetadata));
|
||||
|
||||
// Send save request
|
||||
const response = await fetch('/api/recipes/save', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
// Handle save error
|
||||
console.error("Failed to save recipe:", result.error);
|
||||
showToast(result.error, 'error');
|
||||
// Close modal
|
||||
modalManager.closeModal('importModal');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to download LoRAs
|
||||
let failedDownloads = 0;
|
||||
if (this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
|
||||
await this.downloadMissingLoras();
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (isDownloadOnly) {
|
||||
if (failedDownloads === 0) {
|
||||
showToast('LoRAs downloaded successfully', 'success');
|
||||
}
|
||||
} else {
|
||||
showToast(`Recipe "${this.importManager.recipeName}" saved successfully`, 'success');
|
||||
}
|
||||
|
||||
// Close modal
|
||||
modalManager.closeModal('importModal');
|
||||
|
||||
// Refresh the recipe
|
||||
window.recipeManager.loadRecipes();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showToast(error.message, 'error');
|
||||
} finally {
|
||||
this.importManager.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async downloadMissingLoras() {
|
||||
// For download, we need to validate the target path
|
||||
const loraRoot = document.getElementById('importLoraRoot')?.value;
|
||||
if (!loraRoot) {
|
||||
throw new Error('Please select a LoRA root directory');
|
||||
}
|
||||
|
||||
// Build target path
|
||||
let targetPath = loraRoot;
|
||||
if (this.importManager.selectedFolder) {
|
||||
targetPath += '/' + this.importManager.selectedFolder;
|
||||
}
|
||||
|
||||
const newFolder = document.getElementById('importNewFolder')?.value?.trim();
|
||||
if (newFolder) {
|
||||
targetPath += '/' + newFolder;
|
||||
}
|
||||
|
||||
// Set up WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
||||
|
||||
// Show enhanced loading with progress details for multiple items
|
||||
const updateProgress = this.importManager.loadingManager.showDownloadProgress(
|
||||
this.importManager.downloadableLoRAs.length
|
||||
);
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let accessFailures = 0;
|
||||
let currentLoraProgress = 0;
|
||||
|
||||
// Set up progress tracking for current download
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
// Update current LoRA progress
|
||||
currentLoraProgress = data.progress;
|
||||
|
||||
// Get current LoRA name
|
||||
const currentLora = this.importManager.downloadableLoRAs[completedDownloads + failedDownloads];
|
||||
const loraName = currentLora ? currentLora.name : '';
|
||||
|
||||
// Update progress display
|
||||
updateProgress(currentLoraProgress, completedDownloads, loraName);
|
||||
|
||||
// Add more detailed status messages based on progress
|
||||
if (currentLoraProgress < 3) {
|
||||
this.importManager.loadingManager.setStatus(
|
||||
`Preparing download for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
|
||||
);
|
||||
} else if (currentLoraProgress === 3) {
|
||||
this.importManager.loadingManager.setStatus(
|
||||
`Downloaded preview for LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
|
||||
);
|
||||
} else if (currentLoraProgress > 3 && currentLoraProgress < 100) {
|
||||
this.importManager.loadingManager.setStatus(
|
||||
`Downloading LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
|
||||
);
|
||||
} else {
|
||||
this.importManager.loadingManager.setStatus(
|
||||
`Finalizing LoRA ${completedDownloads + failedDownloads + 1}/${this.importManager.downloadableLoRAs.length}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
|
||||
const lora = this.importManager.downloadableLoRAs[i];
|
||||
|
||||
// Reset current LoRA progress for new download
|
||||
currentLoraProgress = 0;
|
||||
|
||||
// Initial status update for new LoRA
|
||||
this.importManager.loadingManager.setStatus(`Starting download for LoRA ${i+1}/${this.importManager.downloadableLoRAs.length}`);
|
||||
updateProgress(0, completedDownloads, lora.name);
|
||||
|
||||
try {
|
||||
// Download the LoRA
|
||||
const response = await fetch('/api/download-lora', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
download_url: lora.downloadUrl,
|
||||
model_version_id: lora.modelVersionId,
|
||||
model_hash: lora.hash,
|
||||
lora_root: loraRoot,
|
||||
relative_path: targetPath.replace(loraRoot + '/', '')
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`Failed to download LoRA ${lora.name}: ${errorText}`);
|
||||
|
||||
// Check if this is an early access error (status 401 is the key indicator)
|
||||
if (response.status === 401) {
|
||||
accessFailures++;
|
||||
this.importManager.loadingManager.setStatus(
|
||||
`Failed to download ${lora.name}: Access restricted`
|
||||
);
|
||||
}
|
||||
|
||||
failedDownloads++;
|
||||
// Continue with next download
|
||||
} else {
|
||||
completedDownloads++;
|
||||
|
||||
// Update progress to show completion of current LoRA
|
||||
updateProgress(100, completedDownloads, '');
|
||||
|
||||
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
|
||||
this.importManager.loadingManager.setStatus(
|
||||
`Completed ${completedDownloads}/${this.importManager.downloadableLoRAs.length} LoRAs. Starting next download...`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (downloadError) {
|
||||
console.error(`Error downloading LoRA ${lora.name}:`, downloadError);
|
||||
failedDownloads++;
|
||||
// Continue with next download
|
||||
}
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
ws.close();
|
||||
|
||||
// Show appropriate completion message based on results
|
||||
if (failedDownloads === 0) {
|
||||
showToast(`All ${completedDownloads} LoRAs downloaded successfully`, 'success');
|
||||
} else {
|
||||
if (accessFailures > 0) {
|
||||
showToast(
|
||||
`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs. ${accessFailures} failed due to access restrictions. Check your API key in settings or early access status.`,
|
||||
'error'
|
||||
);
|
||||
} else {
|
||||
showToast(`Downloaded ${completedDownloads} of ${this.importManager.downloadableLoRAs.length} LoRAs`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
return failedDownloads;
|
||||
}
|
||||
}
|
||||
220
static/js/managers/import/FolderBrowser.js
Normal file
220
static/js/managers/import/FolderBrowser.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
export class FolderBrowser {
|
||||
constructor(importManager) {
|
||||
this.importManager = importManager;
|
||||
this.folderClickHandler = null;
|
||||
this.updateTargetPath = this.updateTargetPath.bind(this);
|
||||
}
|
||||
|
||||
async proceedToLocation() {
|
||||
// Show the location step with special handling
|
||||
this.importManager.stepManager.showStep('locationStep');
|
||||
|
||||
// Double-check after a short delay to ensure the step is visible
|
||||
setTimeout(() => {
|
||||
const locationStep = document.getElementById('locationStep');
|
||||
if (locationStep.style.display !== 'block' ||
|
||||
window.getComputedStyle(locationStep).display !== 'block') {
|
||||
// Force display again
|
||||
locationStep.style.display = 'block';
|
||||
|
||||
// If still not visible, try with injected style
|
||||
if (window.getComputedStyle(locationStep).display !== 'block') {
|
||||
this.importManager.stepManager.injectedStyles = document.createElement('style');
|
||||
this.importManager.stepManager.injectedStyles.innerHTML = `
|
||||
#locationStep {
|
||||
display: block !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(this.importManager.stepManager.injectedStyles);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
// Display missing LoRAs that will be downloaded
|
||||
const missingLorasList = document.getElementById('missingLorasList');
|
||||
if (missingLorasList && this.importManager.downloadableLoRAs.length > 0) {
|
||||
// Calculate total size
|
||||
const totalSize = this.importManager.downloadableLoRAs.reduce((sum, lora) => {
|
||||
return sum + (lora.size ? parseInt(lora.size) : 0);
|
||||
}, 0);
|
||||
|
||||
// Update total size display
|
||||
const totalSizeDisplay = document.getElementById('totalDownloadSize');
|
||||
if (totalSizeDisplay) {
|
||||
totalSizeDisplay.textContent = this.importManager.formatFileSize(totalSize);
|
||||
}
|
||||
|
||||
// Update header to include count of missing LoRAs
|
||||
const missingLorasHeader = document.querySelector('.summary-header h3');
|
||||
if (missingLorasHeader) {
|
||||
missingLorasHeader.innerHTML = `Missing LoRAs <span class="lora-count-badge">(${this.importManager.downloadableLoRAs.length})</span> <span id="totalDownloadSize" class="total-size-badge">${this.importManager.formatFileSize(totalSize)}</span>`;
|
||||
}
|
||||
|
||||
// Generate missing LoRAs list
|
||||
missingLorasList.innerHTML = this.importManager.downloadableLoRAs.map(lora => {
|
||||
const sizeDisplay = lora.size ?
|
||||
this.importManager.formatFileSize(lora.size) : 'Unknown size';
|
||||
const baseModel = lora.baseModel ?
|
||||
`<span class="lora-base-model">${lora.baseModel}</span>` : '';
|
||||
const isEarlyAccess = lora.isEarlyAccess;
|
||||
|
||||
// Early access badge
|
||||
let earlyAccessBadge = '';
|
||||
if (isEarlyAccess) {
|
||||
earlyAccessBadge = `<span class="early-access-badge">
|
||||
<i class="fas fa-clock"></i> Early Access
|
||||
</span>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="missing-lora-item ${isEarlyAccess ? 'is-early-access' : ''}">
|
||||
<div class="missing-lora-info">
|
||||
<div class="missing-lora-name">${lora.name}</div>
|
||||
${baseModel}
|
||||
${earlyAccessBadge}
|
||||
</div>
|
||||
<div class="missing-lora-size">${sizeDisplay}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Set up toggle for missing LoRAs list
|
||||
const toggleBtn = document.getElementById('toggleMissingLorasList');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
missingLorasList.classList.toggle('collapsed');
|
||||
const icon = toggleBtn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.classList.toggle('fa-chevron-down');
|
||||
icon.classList.toggle('fa-chevron-up');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch LoRA roots
|
||||
const rootsResponse = await fetch('/api/lora-roots');
|
||||
if (!rootsResponse.ok) {
|
||||
throw new Error(`Failed to fetch LoRA roots: ${rootsResponse.status}`);
|
||||
}
|
||||
|
||||
const rootsData = await rootsResponse.json();
|
||||
const loraRoot = document.getElementById('importLoraRoot');
|
||||
if (loraRoot) {
|
||||
loraRoot.innerHTML = rootsData.roots.map(root =>
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
// Set default lora root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
loraRoot.value = defaultRoot;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch folders
|
||||
const foldersResponse = await fetch('/api/folders');
|
||||
if (!foldersResponse.ok) {
|
||||
throw new Error(`Failed to fetch folders: ${foldersResponse.status}`);
|
||||
}
|
||||
|
||||
const foldersData = await foldersResponse.json();
|
||||
const folderBrowser = document.getElementById('importFolderBrowser');
|
||||
if (folderBrowser) {
|
||||
folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
||||
folder ? `<div class="folder-item" data-folder="${folder}">${folder}</div>` : ''
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Initialize folder browser after loading data
|
||||
this.initializeFolderBrowser();
|
||||
} catch (error) {
|
||||
console.error('Error in API calls:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
initializeFolderBrowser() {
|
||||
const folderBrowser = document.getElementById('importFolderBrowser');
|
||||
if (!folderBrowser) return;
|
||||
|
||||
// Cleanup existing handler if any
|
||||
this.cleanup();
|
||||
|
||||
// Create new handler
|
||||
this.folderClickHandler = (event) => {
|
||||
const folderItem = event.target.closest('.folder-item');
|
||||
if (!folderItem) return;
|
||||
|
||||
if (folderItem.classList.contains('selected')) {
|
||||
folderItem.classList.remove('selected');
|
||||
this.importManager.selectedFolder = '';
|
||||
} else {
|
||||
folderBrowser.querySelectorAll('.folder-item').forEach(f =>
|
||||
f.classList.remove('selected'));
|
||||
folderItem.classList.add('selected');
|
||||
this.importManager.selectedFolder = folderItem.dataset.folder;
|
||||
}
|
||||
|
||||
// Update path display after folder selection
|
||||
this.updateTargetPath();
|
||||
};
|
||||
|
||||
// Add the new handler
|
||||
folderBrowser.addEventListener('click', this.folderClickHandler);
|
||||
|
||||
// Add event listeners for path updates
|
||||
const loraRoot = document.getElementById('importLoraRoot');
|
||||
const newFolder = document.getElementById('importNewFolder');
|
||||
|
||||
if (loraRoot) loraRoot.addEventListener('change', this.updateTargetPath);
|
||||
if (newFolder) newFolder.addEventListener('input', this.updateTargetPath);
|
||||
|
||||
// Update initial path
|
||||
this.updateTargetPath();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.folderClickHandler) {
|
||||
const folderBrowser = document.getElementById('importFolderBrowser');
|
||||
if (folderBrowser) {
|
||||
folderBrowser.removeEventListener('click', this.folderClickHandler);
|
||||
this.folderClickHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove path update listeners
|
||||
const loraRoot = document.getElementById('importLoraRoot');
|
||||
const newFolder = document.getElementById('importNewFolder');
|
||||
|
||||
if (loraRoot) loraRoot.removeEventListener('change', this.updateTargetPath);
|
||||
if (newFolder) newFolder.removeEventListener('input', this.updateTargetPath);
|
||||
}
|
||||
|
||||
updateTargetPath() {
|
||||
const pathDisplay = document.getElementById('importTargetPathDisplay');
|
||||
if (!pathDisplay) return;
|
||||
|
||||
const loraRoot = document.getElementById('importLoraRoot')?.value || '';
|
||||
const newFolder = document.getElementById('importNewFolder')?.value?.trim() || '';
|
||||
|
||||
let fullPath = loraRoot || 'Select a LoRA root directory';
|
||||
|
||||
if (loraRoot) {
|
||||
if (this.importManager.selectedFolder) {
|
||||
fullPath += '/' + this.importManager.selectedFolder;
|
||||
}
|
||||
if (newFolder) {
|
||||
fullPath += '/' + newFolder;
|
||||
}
|
||||
}
|
||||
|
||||
pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`;
|
||||
}
|
||||
}
|
||||
208
static/js/managers/import/ImageProcessor.js
Normal file
208
static/js/managers/import/ImageProcessor.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
export class ImageProcessor {
|
||||
constructor(importManager) {
|
||||
this.importManager = importManager;
|
||||
}
|
||||
|
||||
handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
const errorElement = document.getElementById('uploadError');
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.match('image.*')) {
|
||||
errorElement.textContent = 'Please select an image file';
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset error
|
||||
errorElement.textContent = '';
|
||||
this.importManager.recipeImage = file;
|
||||
|
||||
// Auto-proceed to next step if file is selected
|
||||
this.importManager.uploadAndAnalyzeImage();
|
||||
}
|
||||
|
||||
async handleUrlInput() {
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
const errorElement = document.getElementById('urlError');
|
||||
const input = urlInput.value.trim();
|
||||
|
||||
// Validate input
|
||||
if (!input) {
|
||||
errorElement.textContent = 'Please enter a URL or file path';
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset error
|
||||
errorElement.textContent = '';
|
||||
|
||||
// Show loading indicator
|
||||
this.importManager.loadingManager.showSimpleLoading('Processing input...');
|
||||
|
||||
try {
|
||||
// Check if it's a URL or a local file path
|
||||
if (input.startsWith('http://') || input.startsWith('https://')) {
|
||||
// Handle as URL
|
||||
await this.analyzeImageFromUrl(input);
|
||||
} else {
|
||||
// Handle as local file path
|
||||
await this.analyzeImageFromLocalPath(input);
|
||||
}
|
||||
} catch (error) {
|
||||
errorElement.textContent = error.message || 'Failed to process input';
|
||||
} finally {
|
||||
this.importManager.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeImageFromUrl(url) {
|
||||
try {
|
||||
// Call the API with URL data
|
||||
const response = await fetch('/api/recipes/analyze-image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ url: url })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to analyze image from URL');
|
||||
}
|
||||
|
||||
// Get recipe data from response
|
||||
this.importManager.recipeData = await response.json();
|
||||
|
||||
// Check if we have an error message
|
||||
if (this.importManager.recipeData.error) {
|
||||
throw new Error(this.importManager.recipeData.error);
|
||||
}
|
||||
|
||||
// Check if we have valid recipe data
|
||||
if (!this.importManager.recipeData ||
|
||||
!this.importManager.recipeData.loras ||
|
||||
this.importManager.recipeData.loras.length === 0) {
|
||||
throw new Error('No LoRA information found in this image');
|
||||
}
|
||||
|
||||
// Find missing LoRAs
|
||||
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(
|
||||
lora => !lora.existsLocally
|
||||
);
|
||||
|
||||
// Reset import as new flag
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeImageFromLocalPath(path) {
|
||||
try {
|
||||
// Call the API with local path data
|
||||
const response = await fetch('/api/recipes/analyze-local-image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ path: path })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to load image from local path');
|
||||
}
|
||||
|
||||
// Get recipe data from response
|
||||
this.importManager.recipeData = await response.json();
|
||||
|
||||
// Check if we have an error message
|
||||
if (this.importManager.recipeData.error) {
|
||||
throw new Error(this.importManager.recipeData.error);
|
||||
}
|
||||
|
||||
// Check if we have valid recipe data
|
||||
if (!this.importManager.recipeData ||
|
||||
!this.importManager.recipeData.loras ||
|
||||
this.importManager.recipeData.loras.length === 0) {
|
||||
throw new Error('No LoRA information found in this image');
|
||||
}
|
||||
|
||||
// Find missing LoRAs
|
||||
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(
|
||||
lora => !lora.existsLocally
|
||||
);
|
||||
|
||||
// Reset import as new flag
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error analyzing local path:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadAndAnalyzeImage() {
|
||||
if (!this.importManager.recipeImage) {
|
||||
showToast('Please select an image first', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.importManager.loadingManager.showSimpleLoading('Analyzing image metadata...');
|
||||
|
||||
// Create form data for upload
|
||||
const formData = new FormData();
|
||||
formData.append('image', this.importManager.recipeImage);
|
||||
|
||||
// Upload image for analysis
|
||||
const response = await fetch('/api/recipes/analyze-image', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
// Get recipe data from response
|
||||
this.importManager.recipeData = await response.json();
|
||||
|
||||
// Check if we have an error message
|
||||
if (this.importManager.recipeData.error) {
|
||||
throw new Error(this.importManager.recipeData.error);
|
||||
}
|
||||
|
||||
// Check if we have valid recipe data
|
||||
if (!this.importManager.recipeData ||
|
||||
!this.importManager.recipeData.loras ||
|
||||
this.importManager.recipeData.loras.length === 0) {
|
||||
throw new Error('No LoRA information found in this image');
|
||||
}
|
||||
|
||||
// Find missing LoRAs
|
||||
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(
|
||||
lora => !lora.existsLocally
|
||||
);
|
||||
|
||||
// Reset import as new flag
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
} catch (error) {
|
||||
document.getElementById('uploadError').textContent = error.message;
|
||||
} finally {
|
||||
this.importManager.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
57
static/js/managers/import/ImportStepManager.js
Normal file
57
static/js/managers/import/ImportStepManager.js
Normal file
@@ -0,0 +1,57 @@
|
||||
export class ImportStepManager {
|
||||
constructor() {
|
||||
this.injectedStyles = null;
|
||||
}
|
||||
|
||||
removeInjectedStyles() {
|
||||
if (this.injectedStyles && this.injectedStyles.parentNode) {
|
||||
this.injectedStyles.parentNode.removeChild(this.injectedStyles);
|
||||
this.injectedStyles = null;
|
||||
}
|
||||
|
||||
// Reset inline styles
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
step.style.cssText = '';
|
||||
});
|
||||
}
|
||||
|
||||
showStep(stepId) {
|
||||
// Remove any injected styles to prevent conflicts
|
||||
this.removeInjectedStyles();
|
||||
|
||||
// Hide all steps first
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
step.style.display = 'none';
|
||||
});
|
||||
|
||||
// Show target step with a monitoring mechanism
|
||||
const targetStep = document.getElementById(stepId);
|
||||
if (targetStep) {
|
||||
// Use direct style setting
|
||||
targetStep.style.display = 'block';
|
||||
|
||||
// For the locationStep specifically, we need additional measures
|
||||
if (stepId === 'locationStep') {
|
||||
// Create a more persistent style to override any potential conflicts
|
||||
this.injectedStyles = document.createElement('style');
|
||||
this.injectedStyles.innerHTML = `
|
||||
#locationStep {
|
||||
display: block !important;
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(this.injectedStyles);
|
||||
|
||||
// Force layout recalculation
|
||||
targetStep.offsetHeight;
|
||||
}
|
||||
|
||||
// Scroll modal content to top
|
||||
const modalContent = document.querySelector('#importModal .modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
436
static/js/managers/import/RecipeDataManager.js
Normal file
436
static/js/managers/import/RecipeDataManager.js
Normal file
@@ -0,0 +1,436 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
export class RecipeDataManager {
|
||||
constructor(importManager) {
|
||||
this.importManager = importManager;
|
||||
}
|
||||
|
||||
showRecipeDetailsStep() {
|
||||
this.importManager.stepManager.showStep('detailsStep');
|
||||
|
||||
// Set default recipe name from prompt or image filename
|
||||
const recipeName = document.getElementById('recipeName');
|
||||
|
||||
// Check if we have recipe metadata from a shared recipe
|
||||
if (this.importManager.recipeData && this.importManager.recipeData.from_recipe_metadata) {
|
||||
// Use title from recipe metadata
|
||||
if (this.importManager.recipeData.title) {
|
||||
recipeName.value = this.importManager.recipeData.title;
|
||||
this.importManager.recipeName = this.importManager.recipeData.title;
|
||||
}
|
||||
|
||||
// Use tags from recipe metadata
|
||||
if (this.importManager.recipeData.tags && Array.isArray(this.importManager.recipeData.tags)) {
|
||||
this.importManager.recipeTags = [...this.importManager.recipeData.tags];
|
||||
this.updateTagsDisplay();
|
||||
}
|
||||
} else if (this.importManager.recipeData &&
|
||||
this.importManager.recipeData.gen_params &&
|
||||
this.importManager.recipeData.gen_params.prompt) {
|
||||
// Use the first 10 words from the prompt as the default recipe name
|
||||
const promptWords = this.importManager.recipeData.gen_params.prompt.split(' ');
|
||||
const truncatedPrompt = promptWords.slice(0, 10).join(' ');
|
||||
recipeName.value = truncatedPrompt;
|
||||
this.importManager.recipeName = truncatedPrompt;
|
||||
|
||||
// Set up click handler to select all text for easy editing
|
||||
if (!recipeName.hasSelectAllHandler) {
|
||||
recipeName.addEventListener('click', function() {
|
||||
this.select();
|
||||
});
|
||||
recipeName.hasSelectAllHandler = true;
|
||||
}
|
||||
} else if (this.importManager.recipeImage && !recipeName.value) {
|
||||
// Fallback to image filename if no prompt is available
|
||||
const fileName = this.importManager.recipeImage.name.split('.')[0];
|
||||
recipeName.value = fileName;
|
||||
this.importManager.recipeName = fileName;
|
||||
}
|
||||
|
||||
// Always set up click handler for easy editing if not already set
|
||||
if (!recipeName.hasSelectAllHandler) {
|
||||
recipeName.addEventListener('click', function() {
|
||||
this.select();
|
||||
});
|
||||
recipeName.hasSelectAllHandler = true;
|
||||
}
|
||||
|
||||
// Display the uploaded image in the preview
|
||||
const imagePreview = document.getElementById('recipeImagePreview');
|
||||
if (imagePreview) {
|
||||
if (this.importManager.recipeImage) {
|
||||
// For file upload mode
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
imagePreview.innerHTML = `<img src="${e.target.result}" alt="Recipe preview">`;
|
||||
};
|
||||
reader.readAsDataURL(this.importManager.recipeImage);
|
||||
} else if (this.importManager.recipeData && this.importManager.recipeData.image_base64) {
|
||||
// For URL mode - use the base64 image data returned from the backend
|
||||
imagePreview.innerHTML = `<img src="data:image/jpeg;base64,${this.importManager.recipeData.image_base64}" alt="Recipe preview">`;
|
||||
} else if (this.importManager.importMode === 'url') {
|
||||
// Fallback for URL mode if no base64 data
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
if (urlInput && urlInput.value) {
|
||||
imagePreview.innerHTML = `<img src="${urlInput.value}" alt="Recipe preview" crossorigin="anonymous">`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update LoRA count information
|
||||
const totalLoras = this.importManager.recipeData.loras.length;
|
||||
const existingLoras = this.importManager.recipeData.loras.filter(lora => lora.existsLocally).length;
|
||||
const loraCountInfo = document.getElementById('loraCountInfo');
|
||||
if (loraCountInfo) {
|
||||
loraCountInfo.textContent = `(${existingLoras}/${totalLoras} in library)`;
|
||||
}
|
||||
|
||||
// Display LoRAs list
|
||||
const lorasList = document.getElementById('lorasList');
|
||||
if (lorasList) {
|
||||
lorasList.innerHTML = this.importManager.recipeData.loras.map(lora => {
|
||||
const existsLocally = lora.existsLocally;
|
||||
const isDeleted = lora.isDeleted;
|
||||
const isEarlyAccess = lora.isEarlyAccess;
|
||||
const localPath = lora.localPath || '';
|
||||
|
||||
// Create status badge based on LoRA status
|
||||
let statusBadge;
|
||||
if (isDeleted) {
|
||||
statusBadge = `<div class="deleted-badge">
|
||||
<i class="fas fa-exclamation-circle"></i> Deleted from Civitai
|
||||
</div>`;
|
||||
} else {
|
||||
statusBadge = existsLocally ?
|
||||
`<div class="local-badge">
|
||||
<i class="fas fa-check"></i> In Library
|
||||
<div class="local-path">${localPath}</div>
|
||||
</div>` :
|
||||
`<div class="missing-badge">
|
||||
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Early access badge (shown additionally with other badges)
|
||||
let earlyAccessBadge = '';
|
||||
if (isEarlyAccess) {
|
||||
// Format the early access end date if available
|
||||
let earlyAccessInfo = 'This LoRA requires early access payment to download.';
|
||||
if (lora.earlyAccessEndsAt) {
|
||||
try {
|
||||
const endDate = new Date(lora.earlyAccessEndsAt);
|
||||
const formattedDate = endDate.toLocaleDateString();
|
||||
earlyAccessInfo += ` Early access ends on ${formattedDate}.`;
|
||||
} catch (e) {
|
||||
console.warn('Failed to format early access date', e);
|
||||
}
|
||||
}
|
||||
|
||||
earlyAccessBadge = `<div class="early-access-badge">
|
||||
<i class="fas fa-clock"></i> Early Access
|
||||
<div class="early-access-info">${earlyAccessInfo} Verify that you have purchased early access before downloading.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Format size if available
|
||||
const sizeDisplay = lora.size ?
|
||||
`<div class="size-badge">${this.importManager.formatFileSize(lora.size)}</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="lora-item ${existsLocally ? 'exists-locally' : isDeleted ? 'is-deleted' : 'missing-locally'} ${isEarlyAccess ? 'is-early-access' : ''}">
|
||||
<div class="lora-thumbnail">
|
||||
<img src="${lora.thumbnailUrl || '/loras_static/images/no-preview.png'}" alt="LoRA preview">
|
||||
</div>
|
||||
<div class="lora-content">
|
||||
<div class="lora-header">
|
||||
<h3>${lora.name}</h3>
|
||||
<div class="badge-container">
|
||||
${statusBadge}
|
||||
${earlyAccessBadge}
|
||||
</div>
|
||||
</div>
|
||||
${lora.version ? `<div class="lora-version">${lora.version}</div>` : ''}
|
||||
<div class="lora-info">
|
||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||
${sizeDisplay}
|
||||
<div class="weight-badge">Weight: ${lora.weight || 1.0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Check for early access loras and show warning if any exist
|
||||
const earlyAccessLoras = this.importManager.recipeData.loras.filter(lora =>
|
||||
lora.isEarlyAccess && !lora.existsLocally && !lora.isDeleted);
|
||||
if (earlyAccessLoras.length > 0) {
|
||||
// Show a warning about early access loras
|
||||
const warningMessage = `
|
||||
<div class="early-access-warning">
|
||||
<div class="warning-icon"><i class="fas fa-clock"></i></div>
|
||||
<div class="warning-content">
|
||||
<div class="warning-title">${earlyAccessLoras.length} LoRA(s) require Early Access</div>
|
||||
<div class="warning-text">
|
||||
These LoRAs require a payment to access. Download will fail if you haven't purchased access.
|
||||
You may need to log in to your Civitai account in browser settings.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the warning message
|
||||
const buttonsContainer = document.querySelector('#detailsStep .modal-actions');
|
||||
if (buttonsContainer) {
|
||||
// Remove existing warning if any
|
||||
const existingWarning = document.getElementById('earlyAccessWarning');
|
||||
if (existingWarning) {
|
||||
existingWarning.remove();
|
||||
}
|
||||
|
||||
// Add new warning
|
||||
const warningContainer = document.createElement('div');
|
||||
warningContainer.id = 'earlyAccessWarning';
|
||||
warningContainer.innerHTML = warningMessage;
|
||||
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate recipes and display warning if found
|
||||
this.checkAndDisplayDuplicates();
|
||||
|
||||
// Update Next button state based on missing LoRAs and duplicates
|
||||
this.updateNextButtonState();
|
||||
}
|
||||
|
||||
checkAndDisplayDuplicates() {
|
||||
// Check if we have duplicate recipes
|
||||
if (this.importManager.recipeData &&
|
||||
this.importManager.recipeData.matching_recipes &&
|
||||
this.importManager.recipeData.matching_recipes.length > 0) {
|
||||
|
||||
// Store duplicates in the importManager for later use
|
||||
this.importManager.duplicateRecipes = this.importManager.recipeData.matching_recipes;
|
||||
|
||||
// Create duplicate warning container
|
||||
const duplicateContainer = document.getElementById('duplicateRecipesContainer') ||
|
||||
this.createDuplicateContainer();
|
||||
|
||||
// Format date helper function
|
||||
const formatDate = (timestamp) => {
|
||||
try {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
} catch (e) {
|
||||
return 'Unknown date';
|
||||
}
|
||||
};
|
||||
|
||||
// Generate the HTML for duplicate recipes warning
|
||||
duplicateContainer.innerHTML = `
|
||||
<div class="duplicate-warning">
|
||||
<div class="warning-icon"><i class="fas fa-clone"></i></div>
|
||||
<div class="warning-content">
|
||||
<div class="warning-title">
|
||||
${this.importManager.duplicateRecipes.length} identical ${this.importManager.duplicateRecipes.length === 1 ? 'recipe' : 'recipes'} found in your library
|
||||
</div>
|
||||
<div class="warning-text">
|
||||
These recipes contain the same LoRAs with identical weights.
|
||||
<button id="toggleDuplicatesList" class="toggle-duplicates-btn">
|
||||
Show duplicates <i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="duplicate-recipes-list collapsed">
|
||||
${this.importManager.duplicateRecipes.map((recipe) => `
|
||||
<div class="duplicate-recipe-card">
|
||||
<div class="duplicate-recipe-preview">
|
||||
<img src="${recipe.file_url}" alt="Recipe preview">
|
||||
<div class="duplicate-recipe-title">${recipe.title}</div>
|
||||
</div>
|
||||
<div class="duplicate-recipe-details">
|
||||
<div class="duplicate-recipe-date">
|
||||
<i class="fas fa-calendar-alt"></i> ${formatDate(recipe.modified)}
|
||||
</div>
|
||||
<div class="duplicate-recipe-lora-count">
|
||||
<i class="fas fa-layer-group"></i> ${recipe.lora_count} LoRAs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the duplicate container
|
||||
duplicateContainer.style.display = 'block';
|
||||
|
||||
// Add click event for the toggle button
|
||||
const toggleButton = document.getElementById('toggleDuplicatesList');
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const list = duplicateContainer.querySelector('.duplicate-recipes-list');
|
||||
if (list) {
|
||||
list.classList.toggle('collapsed');
|
||||
const icon = toggleButton.querySelector('i');
|
||||
if (icon) {
|
||||
if (list.classList.contains('collapsed')) {
|
||||
toggleButton.innerHTML = `Show duplicates <i class="fas fa-chevron-down"></i>`;
|
||||
} else {
|
||||
toggleButton.innerHTML = `Hide duplicates <i class="fas fa-chevron-up"></i>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No duplicates, hide the container if it exists
|
||||
const duplicateContainer = document.getElementById('duplicateRecipesContainer');
|
||||
if (duplicateContainer) {
|
||||
duplicateContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Reset duplicate tracking
|
||||
this.importManager.duplicateRecipes = [];
|
||||
}
|
||||
}
|
||||
|
||||
createDuplicateContainer() {
|
||||
// Find where to insert the duplicate container
|
||||
const lorasListContainer = document.querySelector('.input-group:has(#lorasList)');
|
||||
|
||||
if (!lorasListContainer) return null;
|
||||
|
||||
// Create container
|
||||
const duplicateContainer = document.createElement('div');
|
||||
duplicateContainer.id = 'duplicateRecipesContainer';
|
||||
duplicateContainer.className = 'duplicate-recipes-container';
|
||||
|
||||
// Insert before the LoRA list
|
||||
lorasListContainer.parentNode.insertBefore(duplicateContainer, lorasListContainer);
|
||||
|
||||
return duplicateContainer;
|
||||
}
|
||||
|
||||
updateNextButtonState() {
|
||||
const nextButton = document.querySelector('#detailsStep .primary-btn');
|
||||
const actionsContainer = document.querySelector('#detailsStep .modal-actions');
|
||||
if (!nextButton || !actionsContainer) return;
|
||||
|
||||
// Always clean up previous warnings and buttons first
|
||||
const existingWarning = document.getElementById('deletedLorasWarning');
|
||||
if (existingWarning) {
|
||||
existingWarning.remove();
|
||||
}
|
||||
|
||||
// Remove any existing "import anyway" button
|
||||
const importAnywayBtn = document.getElementById('importAnywayBtn');
|
||||
if (importAnywayBtn) {
|
||||
importAnywayBtn.remove();
|
||||
}
|
||||
|
||||
// Count deleted LoRAs
|
||||
const deletedLoras = this.importManager.recipeData.loras.filter(lora => lora.isDeleted).length;
|
||||
|
||||
// If we have deleted LoRAs, show a warning
|
||||
if (deletedLoras > 0) {
|
||||
// Create a new warning container above the buttons
|
||||
const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode;
|
||||
const warningContainer = document.createElement('div');
|
||||
warningContainer.id = 'deletedLorasWarning';
|
||||
warningContainer.className = 'deleted-loras-warning';
|
||||
|
||||
// Create warning message
|
||||
warningContainer.innerHTML = `
|
||||
<div class="warning-icon"><i class="fas fa-exclamation-triangle"></i></div>
|
||||
<div class="warning-content">
|
||||
<div class="warning-title">${deletedLoras} LoRA(s) have been deleted from Civitai</div>
|
||||
<div class="warning-text">These LoRAs cannot be downloaded. If you continue, they will remain in the recipe but won't be included when used.</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Insert before the buttons container
|
||||
buttonsContainer.parentNode.insertBefore(warningContainer, buttonsContainer);
|
||||
}
|
||||
|
||||
// Check for duplicates but don't change button actions
|
||||
const missingNotDeleted = this.importManager.recipeData.loras.filter(
|
||||
lora => !lora.existsLocally && !lora.isDeleted
|
||||
).length;
|
||||
|
||||
// Standard button behavior regardless of duplicates
|
||||
nextButton.classList.remove('warning-btn');
|
||||
|
||||
if (missingNotDeleted > 0) {
|
||||
nextButton.textContent = 'Download Missing LoRAs';
|
||||
} else {
|
||||
nextButton.textContent = 'Save Recipe';
|
||||
}
|
||||
}
|
||||
|
||||
addTag() {
|
||||
const tagInput = document.getElementById('tagInput');
|
||||
const tag = tagInput.value.trim();
|
||||
|
||||
if (!tag) return;
|
||||
|
||||
if (!this.importManager.recipeTags.includes(tag)) {
|
||||
this.importManager.recipeTags.push(tag);
|
||||
this.updateTagsDisplay();
|
||||
}
|
||||
|
||||
tagInput.value = '';
|
||||
}
|
||||
|
||||
removeTag(tag) {
|
||||
this.importManager.recipeTags = this.importManager.recipeTags.filter(t => t !== tag);
|
||||
this.updateTagsDisplay();
|
||||
}
|
||||
|
||||
updateTagsDisplay() {
|
||||
const tagsContainer = document.getElementById('tagsContainer');
|
||||
|
||||
if (this.importManager.recipeTags.length === 0) {
|
||||
tagsContainer.innerHTML = '<div class="empty-tags">No tags added</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tagsContainer.innerHTML = this.importManager.recipeTags.map(tag => `
|
||||
<div class="recipe-tag">
|
||||
${tag}
|
||||
<i class="fas fa-times" onclick="importManager.removeTag('${tag}')"></i>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
proceedFromDetails() {
|
||||
// Validate recipe name
|
||||
if (!this.importManager.recipeName) {
|
||||
showToast('Please enter a recipe name', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Automatically mark all deleted LoRAs as excluded
|
||||
if (this.importManager.recipeData && this.importManager.recipeData.loras) {
|
||||
this.importManager.recipeData.loras.forEach(lora => {
|
||||
if (lora.isDeleted) {
|
||||
lora.exclude = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update missing LoRAs list to exclude deleted LoRAs
|
||||
this.importManager.missingLoras = this.importManager.recipeData.loras.filter(lora =>
|
||||
!lora.existsLocally && !lora.isDeleted);
|
||||
|
||||
// If we have downloadable missing LoRAs, go to location step
|
||||
if (this.importManager.missingLoras.length > 0) {
|
||||
// Store only downloadable LoRAs for the download step
|
||||
this.importManager.downloadableLoRAs = this.importManager.missingLoras;
|
||||
this.importManager.proceedToLocation();
|
||||
} else {
|
||||
// Otherwise, save the recipe directly
|
||||
this.importManager.saveRecipe();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
// Recipe manager module
|
||||
import { appCore } from './core.js';
|
||||
import { ImportManager } from './managers/ImportManager.js';
|
||||
import { RecipeCard } from './components/RecipeCard.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
import { getCurrentPageState } from './state/index.js';
|
||||
import { getCurrentPageState, state } from './state/index.js';
|
||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||
import { initializeInfiniteScroll, refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||
import { resetAndReload, refreshRecipes } from './api/recipeApi.js';
|
||||
|
||||
class RecipeManager {
|
||||
constructor() {
|
||||
@@ -18,12 +20,15 @@ class RecipeManager {
|
||||
// Initialize RecipeModal
|
||||
this.recipeModal = new RecipeModal();
|
||||
|
||||
// Initialize DuplicatesManager
|
||||
this.duplicatesManager = new DuplicatesManager(this);
|
||||
|
||||
// Add state tracking for infinite scroll
|
||||
this.pageState.isLoading = false;
|
||||
this.pageState.hasMore = true;
|
||||
|
||||
// Custom filter state
|
||||
this.customFilter = {
|
||||
// Custom filter state - move to pageState for compatibility with virtual scrolling
|
||||
this.pageState.customFilter = {
|
||||
active: false,
|
||||
loraName: null,
|
||||
loraHash: null,
|
||||
@@ -44,13 +49,10 @@ class RecipeManager {
|
||||
// Check for custom filter parameters in session storage
|
||||
this._checkCustomFilter();
|
||||
|
||||
// Load initial set of recipes
|
||||
await this.loadRecipes();
|
||||
|
||||
// Expose necessary functions to the page
|
||||
this._exposeGlobalFunctions();
|
||||
|
||||
// Initialize common page features (lazy loading, infinite scroll)
|
||||
// Initialize common page features
|
||||
appCore.initializePageFeatures();
|
||||
}
|
||||
|
||||
@@ -82,7 +84,7 @@ class RecipeManager {
|
||||
|
||||
// Set custom filter if any parameter is present
|
||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
||||
this.customFilter = {
|
||||
this.pageState.customFilter = {
|
||||
active: true,
|
||||
loraName: filterLoraName,
|
||||
loraHash: filterLoraHash,
|
||||
@@ -103,11 +105,11 @@ class RecipeManager {
|
||||
// Update text based on filter type
|
||||
let filterText = '';
|
||||
|
||||
if (this.customFilter.recipeId) {
|
||||
if (this.pageState.customFilter.recipeId) {
|
||||
filterText = 'Viewing specific recipe';
|
||||
} else if (this.customFilter.loraName) {
|
||||
} else if (this.pageState.customFilter.loraName) {
|
||||
// Format with Lora name
|
||||
const loraName = this.customFilter.loraName;
|
||||
const loraName = this.pageState.customFilter.loraName;
|
||||
const displayName = loraName.length > 25 ?
|
||||
loraName.substring(0, 22) + '...' :
|
||||
loraName;
|
||||
@@ -120,8 +122,8 @@ class RecipeManager {
|
||||
// Update indicator text and show it
|
||||
textElement.innerHTML = filterText;
|
||||
// Add title attribute to show the lora name as a tooltip
|
||||
if (this.customFilter.loraName) {
|
||||
textElement.setAttribute('title', this.customFilter.loraName);
|
||||
if (this.pageState.customFilter.loraName) {
|
||||
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
||||
}
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
@@ -144,7 +146,7 @@ class RecipeManager {
|
||||
|
||||
_clearCustomFilter() {
|
||||
// Reset custom filter
|
||||
this.customFilter = {
|
||||
this.pageState.customFilter = {
|
||||
active: false,
|
||||
loraName: null,
|
||||
loraHash: null,
|
||||
@@ -162,8 +164,8 @@ class RecipeManager {
|
||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Reload recipes without custom filter
|
||||
this.loadRecipes();
|
||||
// Reset and refresh the virtual scroller
|
||||
refreshVirtualScroll();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
@@ -172,99 +174,21 @@ class RecipeManager {
|
||||
if (sortSelect) {
|
||||
sortSelect.addEventListener('change', () => {
|
||||
this.pageState.sortBy = sortSelect.value;
|
||||
this.loadRecipes();
|
||||
refreshVirtualScroll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// This method is kept for compatibility but now uses virtual scrolling
|
||||
async loadRecipes(resetPage = true) {
|
||||
try {
|
||||
// Show loading indicator
|
||||
document.body.classList.add('loading');
|
||||
this.pageState.isLoading = true;
|
||||
|
||||
// Reset to first page if requested
|
||||
if (resetPage) {
|
||||
this.pageState.currentPage = 1;
|
||||
// Clear grid if resetting
|
||||
const grid = document.getElementById('recipeGrid');
|
||||
if (grid) grid.innerHTML = '';
|
||||
}
|
||||
|
||||
// If we have a specific recipe ID to load
|
||||
if (this.customFilter.active && this.customFilter.recipeId) {
|
||||
await this._loadSpecificRecipe(this.customFilter.recipeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams({
|
||||
page: this.pageState.currentPage,
|
||||
page_size: this.pageState.pageSize || 20,
|
||||
sort_by: this.pageState.sortBy
|
||||
});
|
||||
|
||||
// Add custom filter for Lora if present
|
||||
if (this.customFilter.active && this.customFilter.loraHash) {
|
||||
params.append('lora_hash', this.customFilter.loraHash);
|
||||
|
||||
// Skip other filters when using custom filter
|
||||
params.append('bypass_filters', 'true');
|
||||
} else {
|
||||
// Normal filtering logic
|
||||
|
||||
// Add search filter if present
|
||||
if (this.pageState.filters.search) {
|
||||
params.append('search', this.pageState.filters.search);
|
||||
|
||||
// Add search option parameters
|
||||
if (this.pageState.searchOptions) {
|
||||
params.append('search_title', this.pageState.searchOptions.title.toString());
|
||||
params.append('search_tags', this.pageState.searchOptions.tags.toString());
|
||||
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
|
||||
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
|
||||
params.append('fuzzy', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// Add base model filters
|
||||
if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) {
|
||||
params.append('base_models', this.pageState.filters.baseModel.join(','));
|
||||
}
|
||||
|
||||
// Add tag filters
|
||||
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
|
||||
params.append('tags', this.pageState.filters.tags.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
const response = await fetch(`/api/recipes?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update recipes grid
|
||||
this.updateRecipesGrid(data, resetPage);
|
||||
|
||||
// Update pagination state based on current page and total pages
|
||||
this.pageState.hasMore = data.page < data.total_pages;
|
||||
|
||||
// Increment the page number AFTER successful loading
|
||||
if (data.items.length > 0) {
|
||||
this.pageState.currentPage++;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading recipes:', error);
|
||||
appCore.showToast('Failed to load recipes', 'error');
|
||||
} finally {
|
||||
// Hide loading indicator
|
||||
document.body.classList.remove('loading');
|
||||
this.pageState.isLoading = false;
|
||||
// Skip loading if in duplicates mode
|
||||
const pageState = getCurrentPageState();
|
||||
if (pageState.duplicatesMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resetPage) {
|
||||
refreshVirtualScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,100 +196,44 @@ class RecipeManager {
|
||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||
*/
|
||||
async refreshRecipes() {
|
||||
try {
|
||||
// Call the new endpoint to rebuild the recipe cache
|
||||
const response = await fetch('/api/recipes/scan');
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
||||
}
|
||||
|
||||
// After successful cache rebuild, load the recipes
|
||||
await this.loadRecipes(true);
|
||||
|
||||
appCore.showToast('Refresh complete', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error refreshing recipes:', error);
|
||||
appCore.showToast(error.message || 'Failed to refresh recipes', 'error');
|
||||
|
||||
// Still try to load recipes even if scan failed
|
||||
await this.loadRecipes(true);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadSpecificRecipe(recipeId) {
|
||||
try {
|
||||
// Fetch specific recipe by ID
|
||||
const response = await fetch(`/api/recipe/${recipeId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const recipe = await response.json();
|
||||
|
||||
// Create a data structure that matches the expected format
|
||||
const recipeData = {
|
||||
items: [recipe],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
total_pages: 1
|
||||
};
|
||||
|
||||
// Update grid with single recipe
|
||||
this.updateRecipesGrid(recipeData, true);
|
||||
|
||||
// Pagination not needed for single recipe
|
||||
this.pageState.hasMore = false;
|
||||
|
||||
// Show recipe details modal
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(recipe);
|
||||
}, 300);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading specific recipe:', error);
|
||||
appCore.showToast('Failed to load recipe details', 'error');
|
||||
|
||||
// Clear the filter and show all recipes
|
||||
this._clearCustomFilter();
|
||||
}
|
||||
}
|
||||
|
||||
updateRecipesGrid(data, resetGrid = true) {
|
||||
const grid = document.getElementById('recipeGrid');
|
||||
if (!grid) return;
|
||||
|
||||
// Check if data exists and has items
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (resetGrid) {
|
||||
grid.innerHTML = `
|
||||
<div class="placeholder-message">
|
||||
<p>No recipes found</p>
|
||||
<p>Add recipe images to your recipes folder to see them here.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear grid if resetting
|
||||
if (resetGrid) {
|
||||
grid.innerHTML = '';
|
||||
}
|
||||
|
||||
// Create recipe cards
|
||||
data.items.forEach(recipe => {
|
||||
const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe));
|
||||
grid.appendChild(recipeCard.element);
|
||||
});
|
||||
return refreshRecipes();
|
||||
}
|
||||
|
||||
showRecipeDetails(recipe) {
|
||||
this.recipeModal.showRecipeDetails(recipe);
|
||||
}
|
||||
|
||||
// Duplicate detection and management methods
|
||||
async findDuplicateRecipes() {
|
||||
return await this.duplicatesManager.findDuplicates();
|
||||
}
|
||||
|
||||
selectLatestDuplicates() {
|
||||
this.duplicatesManager.selectLatestDuplicates();
|
||||
}
|
||||
|
||||
deleteSelectedDuplicates() {
|
||||
this.duplicatesManager.deleteSelectedDuplicates();
|
||||
}
|
||||
|
||||
confirmDeleteDuplicates() {
|
||||
this.duplicatesManager.confirmDeleteDuplicates();
|
||||
}
|
||||
|
||||
exitDuplicateMode() {
|
||||
// Clear the grid first to prevent showing old content temporarily
|
||||
const recipeGrid = document.getElementById('recipeGrid');
|
||||
if (recipeGrid) {
|
||||
recipeGrid.innerHTML = '';
|
||||
}
|
||||
|
||||
this.duplicatesManager.exitDuplicateMode();
|
||||
|
||||
// Use a small delay before initializing to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
initializeInfiniteScroll('recipes');
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize components
|
||||
|
||||
@@ -65,6 +65,7 @@ export const state = {
|
||||
},
|
||||
pageSize: 20,
|
||||
showFavoritesOnly: false,
|
||||
duplicatesMode: false, // Add flag for duplicates mode
|
||||
},
|
||||
|
||||
checkpoints: {
|
||||
|
||||
961
static/js/utils/VirtualScroller.js
Normal file
961
static/js/utils/VirtualScroller.js
Normal file
@@ -0,0 +1,961 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
|
||||
export class VirtualScroller {
|
||||
constructor(options) {
|
||||
// Configuration
|
||||
this.gridElement = options.gridElement;
|
||||
this.createItemFn = options.createItemFn;
|
||||
this.fetchItemsFn = options.fetchItemsFn;
|
||||
this.overscan = options.overscan || 5; // Extra items to render above/below viewport
|
||||
this.containerElement = options.containerElement || this.gridElement.parentElement;
|
||||
this.scrollContainer = options.scrollContainer || this.containerElement;
|
||||
this.batchSize = options.batchSize || 50;
|
||||
this.pageSize = options.pageSize || 100;
|
||||
this.itemAspectRatio = 896/1152; // Aspect ratio of cards
|
||||
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
|
||||
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
|
||||
|
||||
// State
|
||||
this.items = []; // All items metadata
|
||||
this.renderedItems = new Map(); // Map of rendered DOM elements by index
|
||||
this.totalItems = 0;
|
||||
this.isLoading = false;
|
||||
this.hasMore = true;
|
||||
this.lastScrollTop = 0;
|
||||
this.scrollDirection = 'down';
|
||||
this.lastRenderRange = { start: 0, end: 0 };
|
||||
this.pendingScroll = null;
|
||||
this.resizeObserver = null;
|
||||
|
||||
// Data windowing parameters
|
||||
this.windowSize = options.windowSize || 2000; // ±1000 items from current view
|
||||
this.windowPadding = options.windowPadding || 500; // Buffer before loading more
|
||||
this.dataWindow = { start: 0, end: 0 }; // Current data window indices
|
||||
this.absoluteWindowStart = 0; // Start index in absolute terms
|
||||
this.fetchingWindow = false; // Flag to track window fetching state
|
||||
|
||||
// Responsive layout state
|
||||
this.itemWidth = 0;
|
||||
this.itemHeight = 0;
|
||||
this.columnsCount = 0;
|
||||
this.gridPadding = 12; // Gap between cards
|
||||
this.columnGap = 12; // Horizontal gap
|
||||
|
||||
// Add loading timeout state
|
||||
this.loadingTimeout = null;
|
||||
this.loadingTimeoutDuration = options.loadingTimeoutDuration || 15000; // 15 seconds default
|
||||
|
||||
// Initialize
|
||||
this.initializeContainer();
|
||||
this.setupEventListeners();
|
||||
this.calculateLayout();
|
||||
}
|
||||
|
||||
initializeContainer() {
|
||||
// Add virtual scroll class to grid
|
||||
this.gridElement.classList.add('virtual-scroll');
|
||||
|
||||
// Set the container to have relative positioning
|
||||
if (getComputedStyle(this.containerElement).position === 'static') {
|
||||
this.containerElement.style.position = 'relative';
|
||||
}
|
||||
|
||||
// Create a spacer element with the total height
|
||||
this.spacerElement = document.createElement('div');
|
||||
this.spacerElement.className = 'virtual-scroll-spacer';
|
||||
this.spacerElement.style.width = '100%';
|
||||
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
|
||||
this.spacerElement.style.pointerEvents = 'none';
|
||||
|
||||
// The grid will be used for the actual visible items
|
||||
this.gridElement.style.position = 'relative';
|
||||
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
|
||||
this.gridElement.appendChild(this.spacerElement);
|
||||
}
|
||||
|
||||
calculateLayout() {
|
||||
// Get container width and style information
|
||||
const containerWidth = this.containerElement.clientWidth;
|
||||
const containerStyle = getComputedStyle(this.containerElement);
|
||||
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
|
||||
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
|
||||
|
||||
// Calculate available content width (excluding padding)
|
||||
const availableContentWidth = containerWidth - paddingLeft - paddingRight;
|
||||
|
||||
// Get display density setting
|
||||
const displayDensity = state.global.settings?.displayDensity || 'default';
|
||||
|
||||
// Set exact column counts and grid widths to match CSS container widths
|
||||
let maxColumns, maxGridWidth;
|
||||
|
||||
// Match exact column counts and CSS container width values based on density
|
||||
if (window.innerWidth >= 3000) { // 4K
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 8;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 9;
|
||||
} else { // compact
|
||||
maxColumns = 10;
|
||||
}
|
||||
maxGridWidth = 2400; // Match exact CSS container width for 4K
|
||||
} else if (window.innerWidth >= 2000) { // 2K/1440p
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 6;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 7;
|
||||
} else { // compact
|
||||
maxColumns = 8;
|
||||
}
|
||||
maxGridWidth = 1800; // Match exact CSS container width for 2K
|
||||
} else {
|
||||
// 1080p
|
||||
if (displayDensity === 'default') {
|
||||
maxColumns = 5;
|
||||
} else if (displayDensity === 'medium') {
|
||||
maxColumns = 6;
|
||||
} else { // compact
|
||||
maxColumns = 7;
|
||||
}
|
||||
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
||||
}
|
||||
|
||||
// Calculate baseCardWidth based on desired column count and available space
|
||||
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
||||
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
||||
|
||||
// Use the smaller of available content width or max grid width
|
||||
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
||||
|
||||
// Set exact column count based on screen size and mode
|
||||
this.columnsCount = maxColumns;
|
||||
|
||||
// When available width is smaller than maxGridWidth, recalculate columns
|
||||
if (availableContentWidth < maxGridWidth) {
|
||||
// Calculate how many columns can fit in the available space
|
||||
this.columnsCount = Math.max(1, Math.floor(
|
||||
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
|
||||
));
|
||||
}
|
||||
|
||||
// Calculate actual item width
|
||||
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
||||
|
||||
// Calculate height based on aspect ratio
|
||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||
|
||||
// Calculate the left offset to center the grid within the content area
|
||||
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
||||
|
||||
// Log layout info
|
||||
console.log('Virtual Scroll Layout:', {
|
||||
containerWidth,
|
||||
availableContentWidth,
|
||||
actualGridWidth,
|
||||
columnsCount: this.columnsCount,
|
||||
itemWidth: this.itemWidth,
|
||||
itemHeight: this.itemHeight,
|
||||
leftOffset: this.leftOffset,
|
||||
paddingLeft,
|
||||
paddingRight,
|
||||
displayDensity,
|
||||
maxColumns,
|
||||
baseCardWidth,
|
||||
rowGap: this.rowGap
|
||||
});
|
||||
|
||||
// Update grid element max-width to match available width
|
||||
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||
|
||||
// Add or remove density classes for style adjustments
|
||||
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||
this.gridElement.classList.add(`${displayDensity}-density`);
|
||||
|
||||
// Update spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Re-render with new layout
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Debounced scroll handler
|
||||
this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
|
||||
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||
|
||||
// Window resize handler for layout recalculation
|
||||
this.resizeHandler = this.debounce(() => {
|
||||
this.calculateLayout();
|
||||
}, 150);
|
||||
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
|
||||
// Use ResizeObserver for more accurate container size detection
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(this.debounce(() => {
|
||||
this.calculateLayout();
|
||||
}, 150));
|
||||
|
||||
this.resizeObserver.observe(this.containerElement);
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
await this.loadInitialBatch();
|
||||
this.scheduleRender();
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize virtual scroller:', err);
|
||||
showToast('Failed to load items', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadInitialBatch() {
|
||||
const pageState = getCurrentPageState();
|
||||
if (this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.setLoadingTimeout(); // Add loading timeout safety
|
||||
|
||||
try {
|
||||
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
|
||||
|
||||
// Initialize the data window with the first batch of items
|
||||
this.items = items || [];
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
this.dataWindow = { start: 0, end: this.items.length };
|
||||
this.absoluteWindowStart = 0;
|
||||
|
||||
// Update the spacer height based on the total number of items
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Check if there are no items and show placeholder if needed
|
||||
if (this.items.length === 0) {
|
||||
this.showNoItemsPlaceholder();
|
||||
} else {
|
||||
this.removeNoItemsPlaceholder();
|
||||
}
|
||||
|
||||
// Reset page state to sync with our virtual scroller
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
pageState.hasMore = this.hasMore;
|
||||
pageState.isLoading = false;
|
||||
|
||||
return { items, totalItems, hasMore };
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial batch:', err);
|
||||
this.showNoItemsPlaceholder('Failed to load items. Please try refreshing the page.');
|
||||
throw err;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.clearLoadingTimeout(); // Clear the timeout
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreItems() {
|
||||
const pageState = getCurrentPageState();
|
||||
if (this.isLoading || !this.hasMore) return;
|
||||
|
||||
this.isLoading = true;
|
||||
pageState.isLoading = true;
|
||||
this.setLoadingTimeout(); // Add loading timeout safety
|
||||
|
||||
try {
|
||||
console.log('Loading more items, page:', pageState.currentPage);
|
||||
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
|
||||
|
||||
if (items && items.length > 0) {
|
||||
this.items = [...this.items, ...items];
|
||||
this.hasMore = hasMore;
|
||||
pageState.hasMore = hasMore;
|
||||
|
||||
// Update page for next request
|
||||
pageState.currentPage++;
|
||||
|
||||
// Update the spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Render the newly loaded items if they're in view
|
||||
this.scheduleRender();
|
||||
|
||||
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
|
||||
} else {
|
||||
this.hasMore = false;
|
||||
pageState.hasMore = false;
|
||||
console.log('No more items to load');
|
||||
}
|
||||
|
||||
return items;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more items:', err);
|
||||
showToast('Failed to load more items', 'error');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
pageState.isLoading = false;
|
||||
this.clearLoadingTimeout(); // Clear the timeout
|
||||
}
|
||||
}
|
||||
|
||||
// Add new methods for loading timeout
|
||||
setLoadingTimeout() {
|
||||
// Clear any existing timeout first
|
||||
this.clearLoadingTimeout();
|
||||
|
||||
// Set a new timeout to prevent loading state from getting stuck
|
||||
this.loadingTimeout = setTimeout(() => {
|
||||
if (this.isLoading) {
|
||||
console.warn('Loading timeout occurred. Resetting loading state.');
|
||||
this.isLoading = false;
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.isLoading = false;
|
||||
}
|
||||
}, this.loadingTimeoutDuration);
|
||||
}
|
||||
|
||||
clearLoadingTimeout() {
|
||||
if (this.loadingTimeout) {
|
||||
clearTimeout(this.loadingTimeout);
|
||||
this.loadingTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateSpacerHeight() {
|
||||
if (this.columnsCount === 0) return;
|
||||
|
||||
// Calculate total rows needed based on total items and columns
|
||||
const totalRows = Math.ceil(this.totalItems / this.columnsCount);
|
||||
// Add row gaps to the total height calculation
|
||||
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
|
||||
this.spacerElement.style.height = `${spacerHeight}px`;
|
||||
}
|
||||
|
||||
getVisibleRange() {
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const viewportHeight = this.scrollContainer.clientHeight;
|
||||
|
||||
// Calculate the visible row range, accounting for row gaps
|
||||
const rowHeight = this.itemHeight + this.rowGap;
|
||||
const startRow = Math.floor(scrollTop / rowHeight);
|
||||
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
||||
|
||||
// Add overscan for smoother scrolling
|
||||
const overscanRows = this.overscan;
|
||||
const firstRow = Math.max(0, startRow - overscanRows);
|
||||
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
|
||||
|
||||
// Calculate item indices
|
||||
const firstIndex = firstRow * this.columnsCount;
|
||||
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
|
||||
|
||||
return { start: firstIndex, end: lastIndex };
|
||||
}
|
||||
|
||||
// Update the scheduleRender method to check for disabled state
|
||||
scheduleRender() {
|
||||
if (this.disabled || this.renderScheduled) return;
|
||||
|
||||
this.renderScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.renderItems();
|
||||
this.renderScheduled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Update the renderItems method to check for disabled state
|
||||
renderItems() {
|
||||
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
|
||||
|
||||
const { start, end } = this.getVisibleRange();
|
||||
|
||||
// Check if render range has significantly changed
|
||||
const isSameRange =
|
||||
start >= this.lastRenderRange.start &&
|
||||
end <= this.lastRenderRange.end &&
|
||||
Math.abs(start - this.lastRenderRange.start) < 10;
|
||||
|
||||
if (isSameRange) return;
|
||||
|
||||
this.lastRenderRange = { start, end };
|
||||
|
||||
// Determine which items need to be added and removed
|
||||
const currentIndices = new Set();
|
||||
for (let i = start; i < end && i < this.items.length; i++) {
|
||||
currentIndices.add(i);
|
||||
}
|
||||
|
||||
// Remove items that are no longer visible
|
||||
for (const [index, element] of this.renderedItems.entries()) {
|
||||
if (!currentIndices.has(index)) {
|
||||
element.remove();
|
||||
this.renderedItems.delete(index);
|
||||
}
|
||||
}
|
||||
|
||||
// Use DocumentFragment for batch DOM operations
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Add new visible items to the fragment
|
||||
for (let i = start; i < end && i < this.items.length; i++) {
|
||||
if (!this.renderedItems.has(i)) {
|
||||
const item = this.items[i];
|
||||
const element = this.createItemElement(item, i);
|
||||
fragment.appendChild(element);
|
||||
this.renderedItems.set(i, element);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the fragment to the grid (single DOM operation)
|
||||
if (fragment.childNodes.length > 0) {
|
||||
this.gridElement.appendChild(fragment);
|
||||
}
|
||||
|
||||
// If we're close to the end and have more items to load, fetch them
|
||||
if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) {
|
||||
this.loadMoreItems();
|
||||
}
|
||||
|
||||
// Check if we need to slide the data window
|
||||
this.slideDataWindow();
|
||||
}
|
||||
|
||||
clearRenderedItems() {
|
||||
this.renderedItems.forEach(element => element.remove());
|
||||
this.renderedItems.clear();
|
||||
this.lastRenderRange = { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
refreshWithData(items, totalItems, hasMore) {
|
||||
this.items = items || [];
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Check if there are no items and show placeholder if needed
|
||||
if (this.items.length === 0) {
|
||||
this.showNoItemsPlaceholder();
|
||||
} else {
|
||||
this.removeNoItemsPlaceholder();
|
||||
}
|
||||
|
||||
// Clear all rendered items and redraw
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
createItemElement(item, index) {
|
||||
// Create the DOM element
|
||||
const element = this.createItemFn(item);
|
||||
|
||||
// Add virtual scroll item class
|
||||
element.classList.add('virtual-scroll-item');
|
||||
|
||||
// Calculate the position
|
||||
const row = Math.floor(index / this.columnsCount);
|
||||
const col = index % this.columnsCount;
|
||||
|
||||
// Calculate precise positions with row gap included
|
||||
// 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
|
||||
// positioning is already relative to the padding edge of the container)
|
||||
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
|
||||
|
||||
// Position the element with absolute positioning
|
||||
element.style.position = 'absolute';
|
||||
element.style.left = `${leftPos}px`;
|
||||
element.style.top = `${topPos}px`;
|
||||
element.style.width = `${this.itemWidth}px`;
|
||||
element.style.height = `${this.itemHeight}px`;
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
handleScroll() {
|
||||
// Determine scroll direction
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
|
||||
this.lastScrollTop = scrollTop;
|
||||
|
||||
// Handle large jumps in scroll position - check if we need to fetch a new window
|
||||
const { scrollHeight } = this.scrollContainer;
|
||||
const scrollRatio = scrollTop / scrollHeight;
|
||||
|
||||
// Only perform data windowing if the feature is enabled
|
||||
if (this.enableDataWindowing && this.totalItems > this.windowSize) {
|
||||
const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
|
||||
const currentWindowStart = this.absoluteWindowStart;
|
||||
const currentWindowEnd = currentWindowStart + this.items.length;
|
||||
|
||||
// If the estimated position is outside our current window by a significant amount
|
||||
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
|
||||
// Fetch a new data window centered on the estimated position
|
||||
this.fetchDataWindow(Math.max(0, estimatedIndex - Math.floor(this.windowSize / 2)));
|
||||
return; // Skip normal rendering until new data is loaded
|
||||
}
|
||||
}
|
||||
|
||||
// Render visible items
|
||||
this.scheduleRender();
|
||||
|
||||
// If we're near the bottom and have more items, load them
|
||||
const { clientHeight } = this.scrollContainer;
|
||||
const scrollBottom = scrollTop + clientHeight;
|
||||
|
||||
// Fix the threshold calculation - use percentage of remaining height instead
|
||||
// We'll trigger loading when within 20% of the bottom of rendered content
|
||||
const remainingScroll = scrollHeight - scrollBottom;
|
||||
const scrollThreshold = Math.min(
|
||||
// Either trigger when within 20% of the total height from bottom
|
||||
scrollHeight * 0.2,
|
||||
// Or when within 2 rows of content from the bottom, whichever is larger
|
||||
(this.itemHeight + this.rowGap) * 2
|
||||
);
|
||||
|
||||
const shouldLoadMore = remainingScroll <= scrollThreshold;
|
||||
|
||||
if (shouldLoadMore && this.hasMore && !this.isLoading) {
|
||||
this.loadMoreItems();
|
||||
}
|
||||
}
|
||||
|
||||
// Method to fetch data for a specific window position
|
||||
async fetchDataWindow(targetIndex) {
|
||||
// Skip if data windowing is disabled or already fetching
|
||||
if (!this.enableDataWindowing || this.fetchingWindow) return;
|
||||
|
||||
this.fetchingWindow = true;
|
||||
|
||||
try {
|
||||
// Calculate which page we need to fetch based on target index
|
||||
const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
|
||||
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
|
||||
|
||||
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
|
||||
|
||||
if (items && items.length > 0) {
|
||||
// Calculate new absolute window start
|
||||
this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
|
||||
|
||||
// Replace the entire data window with new items
|
||||
this.items = items;
|
||||
this.dataWindow = {
|
||||
start: 0,
|
||||
end: items.length
|
||||
};
|
||||
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
|
||||
// Update the current page for future fetches
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.currentPage = targetPage + 1;
|
||||
pageState.hasMore = hasMore;
|
||||
|
||||
// Update the spacer height and clear current rendered items
|
||||
this.updateSpacerHeight();
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data window:', err);
|
||||
showToast('Failed to load items at this position', 'error');
|
||||
} finally {
|
||||
this.fetchingWindow = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Method to slide the data window if we're approaching its edges
|
||||
async slideDataWindow() {
|
||||
// Skip if data windowing is disabled
|
||||
if (!this.enableDataWindowing) return;
|
||||
|
||||
const { start, end } = this.getVisibleRange();
|
||||
const windowStart = this.dataWindow.start;
|
||||
const windowEnd = this.dataWindow.end;
|
||||
const absoluteIndex = this.absoluteWindowStart + windowStart;
|
||||
|
||||
// Calculate the midpoint of the visible range
|
||||
const visibleMidpoint = Math.floor((start + end) / 2);
|
||||
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
|
||||
|
||||
// Check if we're too close to the window edges
|
||||
const closeToStart = start - windowStart < this.windowPadding;
|
||||
const closeToEnd = windowEnd - end < this.windowPadding;
|
||||
|
||||
// If we're close to either edge and have total items > window size
|
||||
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
|
||||
// Calculate a new target index centered around the current viewport
|
||||
const halfWindow = Math.floor(this.windowSize / 2);
|
||||
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
|
||||
|
||||
// Don't fetch a new window if we're already showing items near the beginning
|
||||
if (targetIndex === 0 && this.absoluteWindowStart === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't fetch if we're showing the end of the list and are near the end
|
||||
if (this.absoluteWindowStart + this.items.length >= this.totalItems &&
|
||||
this.totalItems - end < halfWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the new data window
|
||||
await this.fetchDataWindow(targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
// Remove all rendered items
|
||||
this.clearRenderedItems();
|
||||
|
||||
// Reset state
|
||||
this.items = [];
|
||||
this.totalItems = 0;
|
||||
this.hasMore = true;
|
||||
|
||||
// Reset spacer height
|
||||
this.spacerElement.style.height = '0px';
|
||||
|
||||
// Remove any placeholder
|
||||
this.removeNoItemsPlaceholder();
|
||||
|
||||
// Schedule a re-render
|
||||
this.scheduleRender();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// Remove event listeners
|
||||
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
|
||||
// Clean up the resize observer if present
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
// Remove rendered elements
|
||||
this.clearRenderedItems();
|
||||
|
||||
// Remove spacer
|
||||
this.spacerElement.remove();
|
||||
|
||||
// Remove virtual scroll class
|
||||
this.gridElement.classList.remove('virtual-scroll');
|
||||
|
||||
// Clear any pending timeout
|
||||
this.clearLoadingTimeout();
|
||||
}
|
||||
|
||||
// Add methods to handle placeholder display
|
||||
showNoItemsPlaceholder(message) {
|
||||
// Remove any existing placeholder first
|
||||
this.removeNoItemsPlaceholder();
|
||||
|
||||
// Create placeholder message
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'placeholder-message';
|
||||
|
||||
// Determine appropriate message based on page type
|
||||
let placeholderText = '';
|
||||
|
||||
if (message) {
|
||||
placeholderText = message;
|
||||
} else {
|
||||
const pageType = state.currentPageType;
|
||||
|
||||
if (pageType === 'recipes') {
|
||||
placeholderText = `
|
||||
<p>No recipes found</p>
|
||||
<p>Add recipe images to your recipes folder to see them here.</p>
|
||||
`;
|
||||
} else if (pageType === 'loras') {
|
||||
placeholderText = `
|
||||
<p>No LoRAs found</p>
|
||||
<p>Add LoRAs to your models folder to see them here.</p>
|
||||
`;
|
||||
} else if (pageType === 'checkpoints') {
|
||||
placeholderText = `
|
||||
<p>No checkpoints found</p>
|
||||
<p>Add checkpoints to your models folder to see them here.</p>
|
||||
`;
|
||||
} else {
|
||||
placeholderText = `
|
||||
<p>No items found</p>
|
||||
<p>Try adjusting your search filters or add more content.</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
placeholder.innerHTML = placeholderText;
|
||||
placeholder.id = 'virtualScrollPlaceholder';
|
||||
|
||||
// Append placeholder to the grid
|
||||
this.gridElement.appendChild(placeholder);
|
||||
}
|
||||
|
||||
removeNoItemsPlaceholder() {
|
||||
const placeholder = document.getElementById('virtualScrollPlaceholder');
|
||||
if (placeholder) {
|
||||
placeholder.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Utility method for debouncing
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
// Add disable method to stop rendering and events
|
||||
disable() {
|
||||
// Detach scroll event listener
|
||||
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
||||
|
||||
// Clear all rendered items from the DOM
|
||||
this.clearRenderedItems();
|
||||
|
||||
// Hide the spacer element
|
||||
if (this.spacerElement) {
|
||||
this.spacerElement.style.display = 'none';
|
||||
}
|
||||
|
||||
// Flag as disabled
|
||||
this.disabled = true;
|
||||
|
||||
console.log('Virtual scroller disabled');
|
||||
}
|
||||
|
||||
// Add enable method to resume rendering and events
|
||||
enable() {
|
||||
if (!this.disabled) return;
|
||||
|
||||
// Reattach scroll event listener
|
||||
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||
|
||||
// Show the spacer element
|
||||
if (this.spacerElement) {
|
||||
this.spacerElement.style.display = 'block';
|
||||
}
|
||||
|
||||
// Flag as enabled
|
||||
this.disabled = false;
|
||||
|
||||
// Re-render items
|
||||
this.scheduleRender();
|
||||
|
||||
console.log('Virtual scroller enabled');
|
||||
}
|
||||
|
||||
// New method to remove an item by file path
|
||||
removeItemByFilePath(filePath) {
|
||||
if (!filePath || this.disabled || this.items.length === 0) return false;
|
||||
|
||||
// Find the index of the item with the matching file path
|
||||
const index = this.items.findIndex(item =>
|
||||
item.file_path === filePath ||
|
||||
item.filepath === filePath ||
|
||||
item.path === filePath
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
console.warn(`Item with file path ${filePath} not found in virtual scroller data`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove the item from the data array
|
||||
this.items.splice(index, 1);
|
||||
|
||||
// Decrement total count
|
||||
this.totalItems = Math.max(0, this.totalItems - 1);
|
||||
|
||||
// Remove the item from rendered items if it exists
|
||||
if (this.renderedItems.has(index)) {
|
||||
this.renderedItems.get(index).remove();
|
||||
this.renderedItems.delete(index);
|
||||
}
|
||||
|
||||
// Shift all rendered items with higher indices down by 1
|
||||
const indicesToUpdate = [];
|
||||
|
||||
// Collect all indices that need to be updated
|
||||
for (const [idx, element] of this.renderedItems.entries()) {
|
||||
if (idx > index) {
|
||||
indicesToUpdate.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the elements and map entries
|
||||
for (const idx of indicesToUpdate) {
|
||||
const element = this.renderedItems.get(idx);
|
||||
this.renderedItems.delete(idx);
|
||||
// The item is now at the previous index
|
||||
this.renderedItems.set(idx - 1, element);
|
||||
}
|
||||
|
||||
// Update the spacer height to reflect the new total
|
||||
this.updateSpacerHeight();
|
||||
|
||||
// Re-render to ensure proper layout
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a more contained transition indicator - commented out as it's no longer needed
|
||||
/*
|
||||
showTransitionIndicator() {
|
||||
const container = this.containerElement;
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'page-transition-indicator';
|
||||
|
||||
// Get container position to properly position the indicator
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
// Style the indicator to match just the container area
|
||||
indicator.style.position = 'fixed';
|
||||
indicator.style.top = `${containerRect.top}px`;
|
||||
indicator.style.left = `${containerRect.left}px`;
|
||||
indicator.style.width = `${containerRect.width}px`;
|
||||
indicator.style.height = `${containerRect.height}px`;
|
||||
|
||||
document.body.appendChild(indicator);
|
||||
|
||||
// Remove after animation completes
|
||||
setTimeout(() => {
|
||||
if (indicator.parentNode) {
|
||||
indicator.remove();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
*/
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -125,4 +125,65 @@ export function updateLoraCard(filePath, updates, newFilePath) {
|
||||
});
|
||||
|
||||
return loraCard; // Return the updated card element for chaining
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the recipe card after metadata edits in the modal
|
||||
* @param {string} recipeId - ID of the recipe to update
|
||||
* @param {Object} updates - Object containing the updates (title, tags, source_path)
|
||||
*/
|
||||
export function updateRecipeCard(recipeId, updates) {
|
||||
// Find the card with matching recipe ID
|
||||
const recipeCard = document.querySelector(`.lora-card[data-id="${recipeId}"]`);
|
||||
if (!recipeCard) return;
|
||||
|
||||
// Get the recipe card component instance
|
||||
const recipeCardInstance = recipeCard._recipeCardInstance;
|
||||
|
||||
// Update card dataset and visual elements based on the updates object
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
// Update dataset
|
||||
recipeCard.dataset[key] = value;
|
||||
|
||||
// Update visual elements based on the property
|
||||
switch(key) {
|
||||
case 'title':
|
||||
// Update the title in the recipe object
|
||||
if (recipeCardInstance && recipeCardInstance.recipe) {
|
||||
recipeCardInstance.recipe.title = value;
|
||||
}
|
||||
|
||||
// Update the title shown in the card
|
||||
const modelNameElement = recipeCard.querySelector('.model-name');
|
||||
if (modelNameElement) modelNameElement.textContent = value;
|
||||
break;
|
||||
|
||||
case 'tags':
|
||||
// Update tags in the recipe object (not displayed on card UI)
|
||||
if (recipeCardInstance && recipeCardInstance.recipe) {
|
||||
recipeCardInstance.recipe.tags = value;
|
||||
}
|
||||
|
||||
// Store in dataset as JSON string
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
recipeCard.dataset.tags = value;
|
||||
} else {
|
||||
recipeCard.dataset.tags = JSON.stringify(value);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to update recipe tags:', e);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'source_path':
|
||||
// Update source_path in the recipe object (not displayed on card UI)
|
||||
if (recipeCardInstance && recipeCardInstance.recipe) {
|
||||
recipeCardInstance.recipe.source_path = value;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return recipeCard; // Return the updated card element for chaining
|
||||
}
|
||||
12
static/js/utils/formatters.js
Normal file
12
static/js/utils/formatters.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Format a file size in bytes to a human-readable string
|
||||
* @param {number} bytes - The size in bytes
|
||||
* @returns {string} Formatted size string (e.g., "1.5 MB")
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (!bytes || isNaN(bytes)) return '';
|
||||
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
||||
}
|
||||
@@ -1,12 +1,53 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { loadMoreLoras } from '../api/loraApi.js';
|
||||
import { loadMoreCheckpoints } from '../api/checkpointApi.js';
|
||||
import { debounce } from './debounce.js';
|
||||
import { VirtualScroller } from './VirtualScroller.js';
|
||||
import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js';
|
||||
import { createCheckpointCard } from '../components/CheckpointCard.js';
|
||||
import { fetchLorasPage } from '../api/loraApi.js';
|
||||
import { fetchCheckpointsPage } from '../api/checkpointApi.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
|
||||
export function initializeInfiniteScroll(pageType = 'loras') {
|
||||
// Clean up any existing observer
|
||||
if (state.observer) {
|
||||
state.observer.disconnect();
|
||||
// Function to dynamically import the appropriate card creator based on page type
|
||||
async function getCardCreator(pageType) {
|
||||
if (pageType === 'loras') {
|
||||
return createLoraCard;
|
||||
} else if (pageType === 'recipes') {
|
||||
// Import the RecipeCard module
|
||||
const { RecipeCard } = await import('../components/RecipeCard.js');
|
||||
|
||||
// Return a wrapper function that creates a recipe card element
|
||||
return (recipe) => {
|
||||
const recipeCard = new RecipeCard(recipe, (recipe) => {
|
||||
if (window.recipeManager) {
|
||||
window.recipeManager.showRecipeDetails(recipe);
|
||||
}
|
||||
});
|
||||
return recipeCard.element;
|
||||
};
|
||||
} else if (pageType === 'checkpoints') {
|
||||
return createCheckpointCard;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Function to get the appropriate data fetcher based on page type
|
||||
async function getDataFetcher(pageType) {
|
||||
if (pageType === 'loras') {
|
||||
return fetchLorasPage;
|
||||
} else if (pageType === 'recipes') {
|
||||
// Import the recipeApi module and use the fetchRecipesPage function
|
||||
const { fetchRecipesPage } = await import('../api/recipeApi.js');
|
||||
return fetchRecipesPage;
|
||||
} else if (pageType === 'checkpoints') {
|
||||
return fetchCheckpointsPage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function initializeInfiniteScroll(pageType = 'loras') {
|
||||
// Clean up any existing virtual scroller
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.dispose();
|
||||
state.virtualScroller = null;
|
||||
}
|
||||
|
||||
// Set the current page type
|
||||
@@ -14,107 +55,160 @@ export function initializeInfiniteScroll(pageType = 'loras') {
|
||||
|
||||
// Get the current page state
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
// Skip initializing if in duplicates mode (for recipes page)
|
||||
if (pageType === 'recipes' && pageState.duplicatesMode) {
|
||||
console.log('Skipping virtual scroll initialization - duplicates mode is active');
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the load more function and grid ID based on page type
|
||||
let loadMoreFunction;
|
||||
// Use virtual scrolling for all page types
|
||||
await initializeVirtualScroll(pageType);
|
||||
|
||||
// Setup event delegation for lora cards if on the loras page
|
||||
if (pageType === 'loras') {
|
||||
setupLoraCardEventDelegation();
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeVirtualScroll(pageType) {
|
||||
// Determine the grid ID based on page type
|
||||
let gridId;
|
||||
|
||||
switch (pageType) {
|
||||
case 'recipes':
|
||||
loadMoreFunction = () => {
|
||||
if (!pageState.isLoading && pageState.hasMore) {
|
||||
window.recipeManager.loadRecipes(false); // false to not reset pagination
|
||||
}
|
||||
};
|
||||
gridId = 'recipeGrid';
|
||||
break;
|
||||
case 'checkpoints':
|
||||
loadMoreFunction = () => {
|
||||
if (!pageState.isLoading && pageState.hasMore) {
|
||||
loadMoreCheckpoints(false); // false to not reset
|
||||
}
|
||||
};
|
||||
gridId = 'checkpointGrid';
|
||||
break;
|
||||
case 'loras':
|
||||
default:
|
||||
loadMoreFunction = () => {
|
||||
if (!pageState.isLoading && pageState.hasMore) {
|
||||
loadMoreLoras(false); // false to not reset
|
||||
}
|
||||
};
|
||||
gridId = 'loraGrid';
|
||||
break;
|
||||
}
|
||||
|
||||
const debouncedLoadMore = debounce(loadMoreFunction, 100);
|
||||
|
||||
const grid = document.getElementById(gridId);
|
||||
|
||||
if (!grid) {
|
||||
console.warn(`Grid with ID "${gridId}" not found for infinite scroll`);
|
||||
console.warn(`Grid with ID "${gridId}" not found for virtual scroll`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove any existing sentinel
|
||||
const existingSentinel = document.getElementById('scroll-sentinel');
|
||||
if (existingSentinel) {
|
||||
existingSentinel.remove();
|
||||
// Change this line to get the actual scrolling container
|
||||
const scrollContainer = document.querySelector('.page-content');
|
||||
const gridContainer = scrollContainer.querySelector('.container');
|
||||
|
||||
if (!gridContainer) {
|
||||
console.warn('Grid container element not found for virtual scroll');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a sentinel element after the grid (not inside it)
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
sentinel.style.width = '100%';
|
||||
sentinel.style.height = '20px';
|
||||
sentinel.style.visibility = 'hidden'; // Make it invisible but still affect layout
|
||||
try {
|
||||
// Get the card creator and data fetcher for this page type
|
||||
const createCardFn = await getCardCreator(pageType);
|
||||
const fetchDataFn = await getDataFetcher(pageType);
|
||||
|
||||
if (!createCardFn || !fetchDataFn) {
|
||||
throw new Error(`Required components not available for ${pageType} page`);
|
||||
}
|
||||
|
||||
// Initialize virtual scroller with renamed container elements
|
||||
state.virtualScroller = new VirtualScroller({
|
||||
gridElement: grid,
|
||||
containerElement: gridContainer,
|
||||
scrollContainer: scrollContainer,
|
||||
createItemFn: createCardFn,
|
||||
fetchItemsFn: fetchDataFn,
|
||||
pageSize: 100,
|
||||
rowGap: 20,
|
||||
containerPaddingTop: 4,
|
||||
containerPaddingBottom: 4,
|
||||
enableDataWindowing: false // Explicitly set to false to disable data windowing
|
||||
});
|
||||
|
||||
// Initialize the virtual scroller
|
||||
await state.virtualScroller.initialize();
|
||||
|
||||
// Add grid class for CSS styling
|
||||
grid.classList.add('virtual-scroll');
|
||||
|
||||
// Setup keyboard navigation
|
||||
setupKeyboardNavigation();
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error initializing virtual scroller for ${pageType}:`, error);
|
||||
showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error');
|
||||
|
||||
// Fallback: show a message in the grid
|
||||
grid.innerHTML = `
|
||||
<div class="placeholder-message">
|
||||
<h3>Failed to initialize ${pageType}</h3>
|
||||
<p>There was an error loading this page. Please try reloading.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Insert after grid instead of inside
|
||||
grid.parentNode.insertBefore(sentinel, grid.nextSibling);
|
||||
|
||||
// Create observer with appropriate settings, slightly different for checkpoints page
|
||||
const observerOptions = {
|
||||
threshold: 0.1,
|
||||
rootMargin: pageType === 'checkpoints' ? '0px 0px 200px 0px' : '0px 0px 100px 0px'
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the observer
|
||||
state.observer = new IntersectionObserver((entries) => {
|
||||
const target = entries[0];
|
||||
if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) {
|
||||
debouncedLoadMore();
|
||||
}
|
||||
}, observerOptions);
|
||||
// Add the event listener
|
||||
document.addEventListener('keydown', keyboardNavHandler);
|
||||
|
||||
// Start observing
|
||||
state.observer.observe(sentinel);
|
||||
|
||||
// Clean up any existing scroll event listener
|
||||
if (state.scrollHandler) {
|
||||
window.removeEventListener('scroll', state.scrollHandler);
|
||||
state.scrollHandler = null;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Add a simple backup scroll handler
|
||||
const handleScroll = debounce(() => {
|
||||
if (pageState.isLoading || !pageState.hasMore) return;
|
||||
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
if (!sentinel) return;
|
||||
|
||||
const rect = sentinel.getBoundingClientRect();
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (rect.top < windowHeight + 200) {
|
||||
debouncedLoadMore();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
state.scrollHandler = handleScroll;
|
||||
window.addEventListener('scroll', state.scrollHandler);
|
||||
|
||||
// Clear any existing interval
|
||||
if (state.scrollCheckInterval) {
|
||||
clearInterval(state.scrollCheckInterval);
|
||||
state.scrollCheckInterval = null;
|
||||
}
|
||||
|
||||
// Export a method to refresh the virtual scroller when filters change
|
||||
export function refreshVirtualScroll() {
|
||||
if (state.virtualScroller) {
|
||||
state.virtualScroller.reset();
|
||||
state.virtualScroller.initialize();
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ export function showDeleteModal(filePath, modelType = 'lora') {
|
||||
export async function confirmDelete() {
|
||||
if (!pendingDeletePath) return;
|
||||
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`);
|
||||
|
||||
try {
|
||||
// Use appropriate delete function based on model type
|
||||
if (pendingModelType === 'checkpoint') {
|
||||
@@ -37,10 +35,7 @@ export async function confirmDelete() {
|
||||
} else {
|
||||
await deleteLora(pendingDeletePath);
|
||||
}
|
||||
|
||||
if (card) {
|
||||
card.remove();
|
||||
}
|
||||
|
||||
closeDeleteModal();
|
||||
} catch (error) {
|
||||
console.error('Error deleting model:', error);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user