Compare commits

..

25 Commits

Author SHA1 Message Date
Will Miao
3079131337 feat: Update version to 0.8.30 and add release notes for automatic model path correction and UI enhancements 2025-08-24 19:22:42 +08:00
Will Miao
a34ade0120 feat: Enhance preview tooltip loading behavior for smoother display 2025-08-24 19:02:08 +08:00
Will Miao
e9ada70088 feat: Add ClownsharKSampler_Beta to NODE_EXTRACTORS for enhanced sampler support 2025-08-23 08:08:51 +08:00
Will Miao
597cc48248 feat: Refactor selection state handling for LoRA entries to avoid style conflicts 2025-08-22 17:19:37 +08:00
Will Miao
ec3f857ef1 feat: Add expand/collapse button functionality and improve drag event handling 2025-08-22 16:51:55 +08:00
Will Miao
383b4de539 feat: Improve cursor handling during drag operations for better user experience 2025-08-22 15:36:27 +08:00
Will Miao
1bf9326604 feat: Enhance download path template handling to support JSON strings and ensure defaults 2025-08-22 11:13:37 +08:00
Will Miao
d9f5459d46 feat: Add additional checkpoint loaders to PATH_CORRECTION_TARGETS for improved model support 2025-08-22 10:18:20 +08:00
Will Miao
e45a1b1e19 feat: Add new WAN video models to BASE_MODELS for enhanced support 2025-08-22 08:48:07 +08:00
Will Miao
331ad8f644 feat: Update showToast function to support options object and improve notification handling
fix: Adjust modal max-height for better responsiveness
2025-08-22 08:18:43 +08:00
Will Miao
52fa88b04c feat: Add widget configuration for "Checkpoint Loader with Name (Image Saver)" in path correction targets 2025-08-21 15:03:26 +08:00
Will Miao
8895a64d24 feat: Enhance path correction functionality for widget nodes with pattern matching and user notifications 2025-08-21 13:39:35 +08:00
Will Miao
fdec535559 fix: Normalize path separators in relative path handling for improved compatibility across platforms 2025-08-21 11:52:46 +08:00
Will Miao
6c5559ae2d chore: Update version to 0.8.29 and add release notes for enhanced recipe imports and bug fixes 2025-08-21 08:44:07 +08:00
Will Miao
9f54622b17 fix: Improve author retrieval logic in calculate_relative_path_for_model function to handle missing creator data 2025-08-21 07:34:54 +08:00
Will Miao
03b6f4b378 refactor: Clean up and optimize import modal and related components, removing unused styles and improving path selection functionality 2025-08-20 23:12:38 +08:00
Will Miao
af4cbe2332 feat: Add LoraManagerTextLoader for loading LoRAs from text syntax with enhanced parsing 2025-08-20 18:16:29 +08:00
Will Miao
141f72963a fix: Enhance download functionality with resumable downloads and improved error handling 2025-08-20 16:40:22 +08:00
Will Miao
3d3c66e12f fix: Improve widget handling in lora_loader, lora_stacker, and wanvideo_lora_select, and ensuring expanded state preservation in loras_widget 2025-08-19 22:31:11 +08:00
Will Miao
ee84571bdb refactor: Simplify handling of base model path mappings and download path templates by removing unnecessary JSON.stringify calls 2025-08-19 20:20:30 +08:00
Will Miao
6500936aad refactor: Remove unused DataWrapper class to clean up utils.js 2025-08-19 20:19:58 +08:00
Will Miao
32d2b6c013 fix: disable pysssss autocomplete in Lora-related nodes
Disable PySSSS autocomplete functionality in:
- Lora Loader
- Lora Stacker
- WanVideo Lora Select node
2025-08-19 08:54:12 +08:00
Will Miao
05df40977d refactor: Update chunk size to 4MB for improved HDD throughput and optimize file writing during downloads 2025-08-18 17:21:24 +08:00
Will Miao
5d7a1dcde5 refactor: Comment out duplicate filename logging in ModelScanner for cleaner cache build process, fixes #365 2025-08-18 16:46:16 +08:00
Will Miao
9c45d9db6c feat: Enhance WanVideoLoraSelect with improved low_mem_load and merge_loras options for better LORA management, see #363 2025-08-18 15:05:57 +08:00
33 changed files with 1573 additions and 842 deletions

View File

@@ -34,6 +34,18 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes ## Release Notes
### v0.8.30
* **Automatic Model Path Correction** - Added auto-correction for model paths in built-in nodes such as Load Checkpoint, Load Diffusion Model, Load LoRA, and other custom nodes with similar functionality. Workflows containing outdated or incorrect model paths will now be automatically updated to reflect the current location of your models.
* **Node UI Enhancements** - Improved node interface for a smoother and more intuitive user experience.
* **Bug Fixes** - Addressed various bugs to enhance stability and reliability.
### v0.8.29
* **Enhanced Recipe Imports** - Improved recipe importing with new target folder selection, featuring path input autocomplete and interactive folder tree navigation. Added a "Use Default Path" option when downloading missing LoRAs.
* **WanVideo Lora Select Node Update** - Updated the WanVideo Lora Select node with a 'merge_loras' option to match the counterpart node in the WanVideoWrapper node package.
* **Autocomplete Conflict Resolution** - Resolved an autocomplete feature conflict in LoRA nodes with pysssss autocomplete.
* **Improved Download Functionality** - Enhanced download functionality with resumable downloads and improved error handling.
* **Bug Fixes** - Addressed several bugs for improved stability and performance.
### v0.8.28 ### v0.8.28
* **Autocomplete for Node Inputs** - Instantly find and add LoRAs by filename directly in Lora Loader, Lora Stacker, and WanVideo Lora Select nodes. Autocomplete suggestions include preview tooltips and preset weights, allowing you to quickly select LoRAs without opening the LoRA Manager UI. * **Autocomplete for Node Inputs** - Instantly find and add LoRAs by filename directly in Lora Loader, Lora Stacker, and WanVideo Lora Select nodes. Autocomplete suggestions include preview tooltips and preset weights, allowing you to quickly select LoRAs without opening the LoRA Manager UI.
* **Duplicate Notification Control** - Added a switch to duplicates mode, enabling users to turn off duplicate model notifications for a more streamlined experience. * **Duplicate Notification Control** - Added a switch to duplicates mode, enabling users to turn off duplicate model notifications for a more streamlined experience.
@@ -296,3 +308,6 @@ Join our Discord community for support, discussions, and updates:
[Discord Server](https://discord.gg/vcqNrWVFvM) [Discord Server](https://discord.gg/vcqNrWVFvM)
--- ---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=willmiao/ComfyUI-Lora-Manager&type=Date)](https://star-history.com/#willmiao/ComfyUI-Lora-Manager&Date)

View File

@@ -1,5 +1,5 @@
from .py.lora_manager import LoraManager from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraManagerLoader from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
from .py.nodes.trigger_word_toggle import TriggerWordToggle from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.lora_stacker import LoraStacker from .py.nodes.lora_stacker import LoraStacker
from .py.nodes.save_image import SaveImage from .py.nodes.save_image import SaveImage
@@ -10,6 +10,7 @@ from .py.metadata_collector import init as init_metadata_collector
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
LoraManagerLoader.NAME: LoraManagerLoader, LoraManagerLoader.NAME: LoraManagerLoader,
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
TriggerWordToggle.NAME: TriggerWordToggle, TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker, LoraStacker.NAME: LoraStacker,
SaveImage.NAME: SaveImage, SaveImage.NAME: SaveImage,

View File

@@ -644,6 +644,7 @@ NODE_EXTRACTORS = {
"KSamplerAdvanced": KSamplerAdvancedExtractor, "KSamplerAdvanced": KSamplerAdvancedExtractor,
"SamplerCustom": KSamplerAdvancedExtractor, "SamplerCustom": KSamplerAdvancedExtractor,
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor, "SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
"ClownsharKSampler_Beta": SamplerExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes "TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes "TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack "KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack

View File

@@ -1,4 +1,5 @@
import logging import logging
import re
from nodes import LoraLoader from nodes import LoraLoader
from comfy.comfy_types import IO # type: ignore from comfy.comfy_types import IO # type: ignore
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info
@@ -17,7 +18,8 @@ class LoraManagerLoader:
"model": ("MODEL",), "model": ("MODEL",),
# "clip": ("CLIP",), # "clip": ("CLIP",),
"text": (IO.STRING, { "text": (IO.STRING, {
"multiline": True, "multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True, "dynamicPrompts": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
"placeholder": "LoRA syntax input: <lora:name:strength>" "placeholder": "LoRA syntax input: <lora:name:strength>"
@@ -128,4 +130,142 @@ class LoraManagerLoader:
formatted_loras_text = " ".join(formatted_loras) formatted_loras_text = " ".join(formatted_loras)
return (model, clip, trigger_words_text, formatted_loras_text)
class LoraManagerTextLoader:
NAME = "LoRA Text Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"lora_syntax": (IO.STRING, {
"defaultInput": True,
"forceInput": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation"
}),
},
"optional": {
"clip": ("CLIP",),
"lora_stack": ("LORA_STACK",),
}
}
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras_from_text"
def parse_lora_syntax(self, text):
"""Parse LoRA syntax from text input."""
# Pattern to match <lora:name:strength> or <lora:name:model_strength:clip_strength>
pattern = r'<lora:([^:>]+):([^:>]+)(?::([^:>]+))?>'
matches = re.findall(pattern, text, re.IGNORECASE)
loras = []
for match in matches:
lora_name = match[0].strip()
model_strength = float(match[1])
clip_strength = float(match[2]) if match[2] else model_strength
loras.append({
'name': lora_name,
'model_strength': model_strength,
'clip_strength': clip_strength
})
return loras
def load_loras_from_text(self, model, lora_syntax, clip=None, lora_stack=None):
"""Load LoRAs based on text syntax input."""
loaded_loras = []
all_trigger_words = []
# Check if model is a Nunchaku Flux model - simplified approach
is_nunchaku_model = False
try:
model_wrapper = model.model.diffusion_model
# Check if model is a Nunchaku Flux model using only class name
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
is_nunchaku_model = True
logger.info("Detected Nunchaku Flux model")
except (AttributeError, TypeError):
# Not a model with the expected structure
pass
# First process lora_stack if available
if lora_stack:
for lora_path, model_strength, clip_strength in lora_stack:
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# Use our custom function for Flux models
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged for Nunchaku models
else:
# Use default loader for standard models
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
# Extract lora name for trigger words lookup
lora_name = extract_lora_name(lora_path)
_, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words)
# Add clip strength to output if different from model strength (except for Nunchaku models)
if not is_nunchaku_model and 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}")
# Parse and process LoRAs from text syntax
parsed_loras = self.parse_lora_syntax(lora_syntax)
for lora in parsed_loras:
lora_name = lora['name']
model_strength = lora['model_strength']
clip_strength = lora['clip_strength']
# Get lora path and trigger words
lora_path, trigger_words = get_lora_info(lora_name)
# Apply the LoRA using the appropriate loader
if is_nunchaku_model:
# For Nunchaku models, use our custom function
model = nunchaku_load_lora(model, lora_path, model_strength)
# clip remains unchanged
else:
# Use default loader for standard models
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
# Include clip strength in output if different from model strength and not a Nunchaku model
if not is_nunchaku_model and 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)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
# 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_text) return (model, clip, trigger_words_text, formatted_loras_text)

View File

@@ -17,6 +17,7 @@ class LoraStacker:
"required": { "required": {
"text": (IO.STRING, { "text": (IO.STRING, {
"multiline": True, "multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True, "dynamicPrompts": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
"placeholder": "LoRA syntax input: <lora:name:strength>" "placeholder": "LoRA syntax input: <lora:name:strength>"

View File

@@ -14,9 +14,11 @@ class WanVideoLoraSelect:
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load the LORA model with less VRAM usage, slower loading"}), "low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load LORA models with less VRAM usage, slower loading. This affects ALL LoRAs, not just the current ones. No effect if merge_loras is False"}),
"merge_loras": ("BOOLEAN", {"default": True, "tooltip": "Merge LoRAs into the model, otherwise they are loaded on the fly. Always disabled for GGUF and scaled fp8 models. This affects ALL LoRAs, not just the current one"}),
"text": (IO.STRING, { "text": (IO.STRING, {
"multiline": True, "multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True, "dynamicPrompts": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
"placeholder": "LoRA syntax input: <lora:name:strength>" "placeholder": "LoRA syntax input: <lora:name:strength>"
@@ -29,7 +31,7 @@ class WanVideoLoraSelect:
RETURN_NAMES = ("lora", "trigger_words", "active_loras") RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_loras" FUNCTION = "process_loras"
def process_loras(self, text, low_mem_load=False, **kwargs): def process_loras(self, text, low_mem_load=False, merge_loras=True, **kwargs):
loras_list = [] loras_list = []
all_trigger_words = [] all_trigger_words = []
active_loras = [] active_loras = []
@@ -38,6 +40,9 @@ class WanVideoLoraSelect:
prev_lora = kwargs.get('prev_lora', None) prev_lora = kwargs.get('prev_lora', None)
if prev_lora is not None: if prev_lora is not None:
loras_list.extend(prev_lora) loras_list.extend(prev_lora)
if not merge_loras:
low_mem_load = False # Unmerged LoRAs don't need low_mem_load
# Get blocks if available # Get blocks if available
blocks = kwargs.get('blocks', {}) blocks = kwargs.get('blocks', {})
@@ -65,6 +70,7 @@ class WanVideoLoraSelect:
"blocks": selected_blocks, "blocks": selected_blocks,
"layer_filter": layer_filter, "layer_filter": layer_filter,
"low_mem_load": low_mem_load, "low_mem_load": low_mem_load,
"merge_loras": merge_loras,
} }
# Add to list and collect active loras # Add to list and collect active loras

View File

@@ -1,4 +1,3 @@
import json
import logging import logging
import os import os
import sys import sys
@@ -183,16 +182,6 @@ class MiscRoutes:
if old_path != value: if old_path != value:
logger.info(f"Example images path changed to {value} - server restart required") logger.info(f"Example images path changed to {value} - server restart required")
# Special handling for base_model_path_mappings - parse JSON string
if (key == 'base_model_path_mappings' or key == 'download_path_templates') and value:
try:
value = json.loads(value)
except json.JSONDecodeError:
return web.json_response({
'success': False,
'error': f"Invalid JSON format for base_model_path_mappings: {value}"
})
# Save to settings # Save to settings
settings.set(key, value) settings.set(key, value)

View File

@@ -398,12 +398,12 @@ class BaseModelService(ABC):
relative_path = None relative_path = None
for root in model_roots: for root in model_roots:
# Normalize paths for comparison # Normalize paths for comparison
normalized_root = os.path.normpath(root).replace(os.sep, '/') normalized_root = os.path.normpath(root)
normalized_file = os.path.normpath(file_path).replace(os.sep, '/') normalized_file = os.path.normpath(file_path)
if normalized_file.startswith(normalized_root): if normalized_file.startswith(normalized_root):
# Remove root and leading slash to get relative path # Remove root and leading separator to get relative path
relative_path = normalized_file[len(normalized_root):].lstrip('/') relative_path = normalized_file[len(normalized_root):].lstrip(os.sep)
break break
if relative_path and search_lower in relative_path.lower(): if relative_path and search_lower in relative_path.lower():

View File

@@ -33,8 +33,8 @@ class CivitaiClient:
} }
self._session = None self._session = None
self._session_created_at = None self._session_created_at = None
# Set default buffer size to 1MB for higher throughput # Adjust chunk size based on storage type - consider making this configurable
self.chunk_size = 1024 * 1024 self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better HDD throughput
@property @property
async def session(self) -> aiohttp.ClientSession: async def session(self) -> aiohttp.ClientSession:
@@ -49,8 +49,8 @@ class CivitaiClient:
enable_cleanup_closed=True enable_cleanup_closed=True
) )
trust_env = True # Allow using system environment proxy settings trust_env = True # Allow using system environment proxy settings
# Configure timeout parameters - increase read timeout for large files # Configure timeout parameters - increase read timeout for large files and remove sock_read timeout
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=120) timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=None)
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
connector=connector, connector=connector,
trust_env=trust_env, trust_env=trust_env,
@@ -102,7 +102,7 @@ class CivitaiClient:
return headers return headers
async def _download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]: async def _download_file(self, url: str, save_dir: str, default_filename: str, progress_callback=None) -> Tuple[bool, str]:
"""Download file with content-disposition support and progress tracking """Download file with resumable downloads and retry mechanism
Args: Args:
url: Download URL url: Download URL
@@ -113,73 +113,190 @@ class CivitaiClient:
Returns: Returns:
Tuple[bool, str]: (success, save_path or error message) Tuple[bool, str]: (success, save_path or error message)
""" """
logger.debug(f"Resolving DNS for: {url}") max_retries = 5
retry_count = 0
base_delay = 2.0 # Base delay for exponential backoff
# Initial setup
session = await self._ensure_fresh_session() session = await self._ensure_fresh_session()
try: save_path = os.path.join(save_dir, default_filename)
headers = self._get_request_headers() part_path = save_path + '.part'
# Add Range header to allow resumable downloads # Get existing file size for resume
headers['Accept-Encoding'] = 'identity' # Disable compression for better chunked downloads resume_offset = 0
if os.path.exists(part_path):
logger.debug(f"Starting download from: {url}") resume_offset = os.path.getsize(part_path)
async with session.get(url, headers=headers, allow_redirects=True) as response: logger.info(f"Resuming download from offset {resume_offset} bytes")
if response.status != 200:
# Handle 401 unauthorized responses total_size = 0
if response.status == 401: filename = default_filename
while retry_count <= max_retries:
try:
headers = self._get_request_headers()
# Add Range header for resume if we have partial data
if resume_offset > 0:
headers['Range'] = f'bytes={resume_offset}-'
# Add Range header to allow resumable downloads
headers['Accept-Encoding'] = 'identity' # Disable compression for better chunked downloads
logger.debug(f"Download attempt {retry_count + 1}/{max_retries + 1} from: {url}")
if resume_offset > 0:
logger.debug(f"Requesting range from byte {resume_offset}")
async with session.get(url, headers=headers, allow_redirects=True) as response:
# Handle different response codes
if response.status == 200:
# Full content response
if resume_offset > 0:
# Server doesn't support ranges, restart from beginning
logger.warning("Server doesn't support range requests, restarting download")
resume_offset = 0
if os.path.exists(part_path):
os.remove(part_path)
elif response.status == 206:
# Partial content response (resume successful)
content_range = response.headers.get('Content-Range')
if content_range:
# Parse total size from Content-Range header (e.g., "bytes 1024-2047/2048")
range_parts = content_range.split('/')
if len(range_parts) == 2:
total_size = int(range_parts[1])
logger.info(f"Successfully resumed download from byte {resume_offset}")
elif response.status == 416:
# Range not satisfiable - file might be complete or corrupted
if os.path.exists(part_path):
part_size = os.path.getsize(part_path)
logger.warning(f"Range not satisfiable. Part file size: {part_size}")
# Try to get actual file size
head_response = await session.head(url, headers=self._get_request_headers())
if head_response.status == 200:
actual_size = int(head_response.headers.get('content-length', 0))
if part_size == actual_size:
# File is complete, just rename it
os.rename(part_path, save_path)
if progress_callback:
await progress_callback(100)
return True, save_path
# Remove corrupted part file and restart
os.remove(part_path)
resume_offset = 0
continue
elif response.status == 401:
logger.warning(f"Unauthorized access to resource: {url} (Status 401)") logger.warning(f"Unauthorized access to resource: {url} (Status 401)")
return False, "Invalid or missing CivitAI API key, or early access restriction." return False, "Invalid or missing CivitAI API key, or early access restriction."
elif response.status == 403:
# Handle other client errors that might be permission-related
if response.status == 403:
logger.warning(f"Forbidden access to resource: {url} (Status 403)") logger.warning(f"Forbidden access to resource: {url} (Status 403)")
return False, "Access forbidden: You don't have permission to download this file." return False, "Access forbidden: You don't have permission to download this file."
else:
logger.error(f"Download failed for {url} with status {response.status}")
return False, f"Download failed with status {response.status}"
# Get filename from content-disposition header (only on first attempt)
if retry_count == 0:
content_disposition = response.headers.get('Content-Disposition')
parsed_filename = self._parse_content_disposition(content_disposition)
if parsed_filename:
filename = parsed_filename
# Update paths with correct filename
save_path = os.path.join(save_dir, filename)
new_part_path = save_path + '.part'
# Rename existing part file if filename changed
if part_path != new_part_path and os.path.exists(part_path):
os.rename(part_path, new_part_path)
part_path = new_part_path
# Generic error response for other status codes # Get total file size for progress calculation (if not set from Content-Range)
logger.error(f"Download failed for {url} with status {response.status}") if total_size == 0:
return False, f"Download failed with status {response.status}" total_size = int(response.headers.get('content-length', 0))
if response.status == 206:
# For partial content, add the offset to get total file size
total_size += resume_offset
# Get filename from content-disposition header current_size = resume_offset
content_disposition = response.headers.get('Content-Disposition') last_progress_report_time = datetime.now()
filename = self._parse_content_disposition(content_disposition)
if not filename:
filename = default_filename
save_path = os.path.join(save_dir, filename)
# Get total file size for progress calculation
total_size = int(response.headers.get('content-length', 0))
current_size = 0
last_progress_report_time = datetime.now()
# Stream download to file with progress updates using larger buffer # Stream download to file with progress updates using larger buffer
with open(save_path, 'wb') as f: loop = asyncio.get_running_loop()
async for chunk in response.content.iter_chunked(self.chunk_size): mode = 'ab' if resume_offset > 0 else 'wb'
if chunk: with open(part_path, mode) as f:
f.write(chunk) async for chunk in response.content.iter_chunked(self.chunk_size):
current_size += len(chunk) if chunk:
# Run blocking file write in executor
# Limit progress update frequency to reduce overhead await loop.run_in_executor(None, f.write, chunk)
now = datetime.now() current_size += len(chunk)
time_diff = (now - last_progress_report_time).total_seconds()
# Limit progress update frequency to reduce overhead
if progress_callback and total_size and time_diff >= 1.0: now = datetime.now()
progress = (current_size / total_size) * 100 time_diff = (now - last_progress_report_time).total_seconds()
await progress_callback(progress)
last_progress_report_time = now if progress_callback and total_size and time_diff >= 1.0:
progress = (current_size / total_size) * 100
# Ensure 100% progress is reported await progress_callback(progress)
if progress_callback: last_progress_report_time = now
await progress_callback(100)
# Download completed successfully
# Verify file size if total_size was provided
final_size = os.path.getsize(part_path)
if total_size > 0 and final_size != total_size:
logger.warning(f"File size mismatch. Expected: {total_size}, Got: {final_size}")
# Don't treat this as fatal error, rename anyway
# Atomically rename .part to final file with retries
max_rename_attempts = 5
rename_attempt = 0
rename_success = False
while rename_attempt < max_rename_attempts and not rename_success:
try:
os.rename(part_path, save_path)
rename_success = True
except PermissionError as e:
rename_attempt += 1
if rename_attempt < max_rename_attempts:
logger.info(f"File still in use, retrying rename in 2 seconds (attempt {rename_attempt}/{max_rename_attempts})")
await asyncio.sleep(2) # Wait before retrying
else:
logger.error(f"Failed to rename file after {max_rename_attempts} attempts: {e}")
return False, f"Failed to finalize download: {str(e)}"
# Ensure 100% progress is reported
if progress_callback:
await progress_callback(100)
return True, save_path return True, save_path
except (aiohttp.ClientError, aiohttp.ClientPayloadError,
aiohttp.ServerDisconnectedError, asyncio.TimeoutError) as e:
retry_count += 1
logger.warning(f"Network error during download (attempt {retry_count}/{max_retries + 1}): {e}")
except aiohttp.ClientError as e: if retry_count <= max_retries:
logger.error(f"Network error during download: {e}") # Calculate delay with exponential backoff
return False, f"Network error: {str(e)}" delay = base_delay * (2 ** (retry_count - 1))
except Exception as e: logger.info(f"Retrying in {delay} seconds...")
logger.error(f"Download error: {e}") await asyncio.sleep(delay)
return False, str(e)
# Update resume offset for next attempt
if os.path.exists(part_path):
resume_offset = os.path.getsize(part_path)
logger.info(f"Will resume from byte {resume_offset}")
# Refresh session to get new connection
await self.close()
session = await self._ensure_fresh_session()
continue
else:
logger.error(f"Max retries exceeded for download: {e}")
return False, f"Network error after {max_retries + 1} attempts: {str(e)}"
except Exception as e:
logger.error(f"Unexpected download error: {e}")
return False, str(e)
return False, f"Download failed after {max_retries + 1} attempts"
async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]: async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
try: try:

View File

@@ -274,9 +274,9 @@ class DownloadManager:
from datetime import datetime from datetime import datetime
date_obj = datetime.fromisoformat(early_access_date.replace('Z', '+00:00')) date_obj = datetime.fromisoformat(early_access_date.replace('Z', '+00:00'))
formatted_date = date_obj.strftime('%Y-%m-%d') formatted_date = date_obj.strftime('%Y-%m-%d')
early_access_msg = f"This model requires early access payment (until {formatted_date}). " early_access_msg = f"This model requires payment (until {formatted_date}). "
except: except:
early_access_msg = "This model requires early access payment. " early_access_msg = "This model requires payment. "
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai." early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
logger.warning(f"Early access model detected: {version_info.get('name', 'Unknown')}") logger.warning(f"Early access model detected: {version_info.get('name', 'Unknown')}")
@@ -321,6 +321,10 @@ class DownloadManager:
download_id=download_id download_id=download_id
) )
# If early_access_msg exists and download failed, replace error message
if 'early_access_msg' in locals() and not result.get('success', False):
result['error'] = early_access_msg
return result return result
except Exception as e: except Exception as e:
@@ -392,11 +396,13 @@ class DownloadManager:
try: try:
civitai_client = await self._get_civitai_client() civitai_client = await self._get_civitai_client()
save_path = metadata.file_path save_path = metadata.file_path
part_path = save_path + '.part'
metadata_path = os.path.splitext(save_path)[0] + '.metadata.json' metadata_path = os.path.splitext(save_path)[0] + '.metadata.json'
# Store file path in active_downloads for potential cleanup # Store file paths in active_downloads for potential cleanup
if download_id and download_id in self._active_downloads: if download_id and download_id in self._active_downloads:
self._active_downloads[download_id]['file_path'] = save_path self._active_downloads[download_id]['file_path'] = save_path
self._active_downloads[download_id]['part_path'] = part_path
# Download preview image if available # Download preview image if available
images = version_info.get('images', []) images = version_info.get('images', [])
@@ -463,10 +469,22 @@ class DownloadManager:
) )
if not success: if not success:
# Clean up files on failure # Clean up files on failure, but preserve .part file for resume
for path in [save_path, metadata_path, metadata.preview_url]: cleanup_files = [metadata_path]
if metadata.preview_url and os.path.exists(metadata.preview_url):
cleanup_files.append(metadata.preview_url)
for path in cleanup_files:
if path and os.path.exists(path): if path and os.path.exists(path):
os.remove(path) try:
os.remove(path)
except Exception as e:
logger.warning(f"Failed to cleanup file {path}: {e}")
# Log but don't remove .part file to allow resume
if os.path.exists(part_path):
logger.info(f"Preserving partial download for resume: {part_path}")
return {'success': False, 'error': result} return {'success': False, 'error': result}
# 4. Update file information (size and modified time) # 4. Update file information (size and modified time)
@@ -502,10 +520,18 @@ class DownloadManager:
except Exception as e: except Exception as e:
logger.error(f"Error in _execute_download: {e}", exc_info=True) logger.error(f"Error in _execute_download: {e}", exc_info=True)
# Clean up partial downloads # Clean up partial downloads except .part file
for path in [save_path, metadata_path]: cleanup_files = [metadata_path]
if hasattr(metadata, 'preview_url') and metadata.preview_url and os.path.exists(metadata.preview_url):
cleanup_files.append(metadata.preview_url)
for path in cleanup_files:
if path and os.path.exists(path): if path and os.path.exists(path):
os.remove(path) try:
os.remove(path)
except Exception as e:
logger.warning(f"Failed to cleanup file {path}: {e}")
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
async def _handle_download_progress(self, file_progress: float, progress_callback): async def _handle_download_progress(self, file_progress: float, progress_callback):
@@ -547,35 +573,48 @@ class DownloadManager:
except (asyncio.CancelledError, asyncio.TimeoutError): except (asyncio.CancelledError, asyncio.TimeoutError):
pass pass
# Clean up partial downloads # Clean up ALL files including .part when user cancels
download_info = self._active_downloads.get(download_id) download_info = self._active_downloads.get(download_id)
if download_info and 'file_path' in download_info: if download_info:
# Delete the partial file # Delete the main file
file_path = download_info['file_path'] if 'file_path' in download_info:
if os.path.exists(file_path): file_path = download_info['file_path']
try: if os.path.exists(file_path):
os.unlink(file_path) try:
logger.debug(f"Deleted partial download: {file_path}") os.unlink(file_path)
except Exception as e: logger.debug(f"Deleted cancelled download: {file_path}")
logger.error(f"Error deleting partial file: {e}") except Exception as e:
logger.error(f"Error deleting file: {e}")
# Delete the .part file (only on user cancellation)
if 'part_path' in download_info:
part_path = download_info['part_path']
if os.path.exists(part_path):
try:
os.unlink(part_path)
logger.debug(f"Deleted partial download: {part_path}")
except Exception as e:
logger.error(f"Error deleting part file: {e}")
# Delete metadata file if exists # Delete metadata file if exists
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' if 'file_path' in download_info:
if os.path.exists(metadata_path): file_path = download_info['file_path']
try: metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
os.unlink(metadata_path) if os.path.exists(metadata_path):
except Exception as e:
logger.error(f"Error deleting metadata file: {e}")
# Delete preview file if exists (.webp or .mp4)
for preview_ext in ['.webp', '.mp4']:
preview_path = os.path.splitext(file_path)[0] + preview_ext
if os.path.exists(preview_path):
try: try:
os.unlink(preview_path) os.unlink(metadata_path)
logger.debug(f"Deleted preview file: {preview_path}")
except Exception as e: except Exception as e:
logger.error(f"Error deleting preview file: {e}") logger.error(f"Error deleting metadata file: {e}")
# Delete preview file if exists (.webp or .mp4)
for preview_ext in ['.webp', '.mp4']:
preview_path = os.path.splitext(file_path)[0] + preview_ext
if os.path.exists(preview_path):
try:
os.unlink(preview_path)
logger.debug(f"Deleted preview file: {preview_path}")
except Exception as e:
logger.error(f"Error deleting preview file: {e}")
return {'success': True, 'message': 'Download cancelled successfully'} return {'success': True, 'message': 'Download cancelled successfully'}
except Exception as e: except Exception as e:

View File

@@ -167,6 +167,7 @@ class LoraService(BaseModelService):
if file_path: if file_path:
# Convert to forward slashes and extract relative path # Convert to forward slashes and extract relative path
file_path_normalized = file_path.replace('\\', '/') file_path_normalized = file_path.replace('\\', '/')
relative_path = relative_path.replace('\\', '/')
# Find the relative path part by looking for the relative_path in the full path # Find the relative path part by looking for the relative_path in the full path
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized: if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized:
return lora.get('usage_tips', '') return lora.get('usage_tips', '')

View File

@@ -303,11 +303,11 @@ class ModelScanner:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index # Log duplicate filename warnings after building the index
duplicate_filenames = self._hash_index.get_duplicate_filenames() # duplicate_filenames = self._hash_index.get_duplicate_filenames()
if duplicate_filenames: # if duplicate_filenames:
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:") # logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
for filename, paths in duplicate_filenames.items(): # for filename, paths in duplicate_filenames.items():
logger.warning(f" Duplicate filename '{filename}': {paths}") # logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache # Update cache
self._cache.raw_data = raw_data self._cache.raw_data = raw_data
@@ -375,11 +375,11 @@ class ModelScanner:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index # Log duplicate filename warnings after building the index
duplicate_filenames = self._hash_index.get_duplicate_filenames() # duplicate_filenames = self._hash_index.get_duplicate_filenames()
if duplicate_filenames: # if duplicate_filenames:
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:") # logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
for filename, paths in duplicate_filenames.items(): # for filename, paths in duplicate_filenames.items():
logger.warning(f" Duplicate filename '{filename}': {paths}") # logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache # Update cache
self._cache = ModelCache( self._cache = ModelCache(

View File

@@ -110,6 +110,43 @@ class SettingsManager:
Template string for the model type, defaults to '{base_model}/{first_tag}' Template string for the model type, defaults to '{base_model}/{first_tag}'
""" """
templates = self.settings.get('download_path_templates', {}) templates = self.settings.get('download_path_templates', {})
# Handle edge case where templates might be stored as JSON string
if isinstance(templates, str):
try:
# Try to parse JSON string
parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict):
# Update settings with parsed dictionary
self.settings['download_path_templates'] = parsed_templates
self._save_settings()
templates = parsed_templates
logger.info("Successfully parsed download_path_templates from JSON string")
else:
raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e:
# If parsing fails, set default values
logger.warning(f"Failed to parse download_path_templates JSON string: {e}. Setting default values.")
default_template = '{base_model}/{first_tag}'
templates = {
'lora': default_template,
'checkpoint': default_template,
'embedding': default_template
}
self.settings['download_path_templates'] = templates
self._save_settings()
# Ensure templates is a dictionary
if not isinstance(templates, dict):
default_template = '{base_model}/{first_tag}'
templates = {
'lora': default_template,
'checkpoint': default_template,
'embedding': default_template
}
self.settings['download_path_templates'] = templates
self._save_settings()
return templates.get(model_type, '{base_model}/{first_tag}') return templates.get(model_type, '{base_model}/{first_tag}')
settings = SettingsManager() settings = SettingsManager()

View File

@@ -628,15 +628,6 @@ class ModelRouteUtils:
if not result.get('success', False): if not result.get('success', False):
error_message = result.get('error', 'Unknown error') error_message = result.get('error', 'Unknown error')
# Return 401 for early access errors
if 'early access' in error_message.lower():
logger.warning(f"Early access download failed: {error_message}")
return web.json_response({
'success': False,
'error': f"Early Access Restriction: {error_message}",
'download_id': download_id
}, status=401)
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': error_message, 'error': error_message,

View File

@@ -156,7 +156,8 @@ def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora'
if civitai_data and civitai_data.get('id') is not None: if civitai_data and civitai_data.get('id') is not None:
base_model = civitai_data.get('baseModel', '') base_model = civitai_data.get('baseModel', '')
# Get author from civitai creator data # Get author from civitai creator data
author = civitai_data.get('creator', {}).get('username') or 'Anonymous' creator_info = civitai_data.get('creator') or {}
author = creator_info.get('username') or 'Anonymous'
else: else:
# Fallback to model_data fields for non-CivitAI models # Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '') base_model = model_data.get('base_model', '')

View File

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

View File

@@ -337,72 +337,7 @@
margin-left: 8px; margin-left: 8px;
} }
/* Location Selection Styles */
.location-selection {
margin: var(--space-2) 0;
padding: var(--space-2);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
}
/* Reuse folder browser and path preview styles from download-modal.css */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
/* Input Group Styles */ /* Input Group Styles */
.input-group {
margin-bottom: var(--space-2);
}
.input-with-button { .input-with-button {
display: flex; display: flex;
@@ -430,22 +365,6 @@
background: oklch(from var(--lora-accent) l c h / 0.9); background: oklch(from var(--lora-accent) l c h / 0.9);
} }
.input-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
}
.input-group input,
.input-group select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
}
/* Dark theme adjustments */ /* Dark theme adjustments */
[data-theme="dark"] .lora-item { [data-theme="dark"] .lora-item {
background: var(--lora-surface); background: var(--lora-surface);

View File

@@ -23,7 +23,7 @@ body.modal-open {
position: relative; position: relative;
max-width: 800px; max-width: 800px;
height: auto; height: auto;
max-height: calc(90vh - 48px); /* Adjust to account for header height */ max-height: calc(90vh);
margin: 1rem auto; /* Keep reduced top margin */ margin: 1rem auto; /* Keep reduced top margin */
background: var(--lora-surface); background: var(--lora-surface);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);

View File

@@ -121,15 +121,6 @@
gap: 4px; gap: 4px;
} }
/* Folder Browser Styles */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item { .folder-item {
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;

View File

@@ -4,8 +4,13 @@ import { ImportStepManager } from './import/ImportStepManager.js';
import { ImageProcessor } from './import/ImageProcessor.js'; import { ImageProcessor } from './import/ImageProcessor.js';
import { RecipeDataManager } from './import/RecipeDataManager.js'; import { RecipeDataManager } from './import/RecipeDataManager.js';
import { DownloadManager } from './import/DownloadManager.js'; import { DownloadManager } from './import/DownloadManager.js';
import { FolderBrowser } from './import/FolderBrowser.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { formatFileSize } from '../utils/formatters.js'; import { formatFileSize } from '../utils/formatters.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { state } from '../state/index.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { showToast } from '../utils/uiHelpers.js';
export class ImportManager { export class ImportManager {
constructor() { constructor() {
@@ -20,6 +25,8 @@ export class ImportManager {
this.downloadableLoRAs = []; this.downloadableLoRAs = [];
this.recipeId = null; this.recipeId = null;
this.importMode = 'url'; // Default mode: 'url' or 'upload' this.importMode = 'url'; // Default mode: 'url' or 'upload'
this.useDefaultPath = false;
this.apiClient = null;
// Initialize sub-managers // Initialize sub-managers
this.loadingManager = new LoadingManager(); this.loadingManager = new LoadingManager();
@@ -27,10 +34,12 @@ export class ImportManager {
this.imageProcessor = new ImageProcessor(this); this.imageProcessor = new ImageProcessor(this);
this.recipeDataManager = new RecipeDataManager(this); this.recipeDataManager = new RecipeDataManager(this);
this.downloadManager = new DownloadManager(this); this.downloadManager = new DownloadManager(this);
this.folderBrowser = new FolderBrowser(this); this.folderTreeManager = new FolderTreeManager();
// Bind methods // Bind methods
this.formatFileSize = formatFileSize; this.formatFileSize = formatFileSize;
this.updateTargetPath = this.updateTargetPath.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
} }
showImportModal(recipeData = null, recipeId = null) { showImportModal(recipeData = null, recipeId = null) {
@@ -40,9 +49,13 @@ export class ImportManager {
console.error('Import modal element not found'); console.error('Import modal element not found');
return; return;
} }
this.initializeEventHandlers();
this.initialized = true; this.initialized = true;
} }
// Get API client for LoRAs
this.apiClient = getModelApiClient(MODEL_TYPES.LORA);
// Reset state // Reset state
this.resetSteps(); this.resetSteps();
if (recipeData) { if (recipeData) {
@@ -52,14 +65,12 @@ export class ImportManager {
// Show modal // Show modal
modalManager.showModal('importModal', null, () => { modalManager.showModal('importModal', null, () => {
this.folderBrowser.cleanup(); this.cleanupFolderBrowser();
this.stepManager.removeInjectedStyles(); this.stepManager.removeInjectedStyles();
}); });
// Verify visibility and focus on URL input // Verify visibility and focus on URL input
setTimeout(() => { setTimeout(() => {
this.ensureModalVisible();
// Ensure URL option is selected and focus on the input // Ensure URL option is selected and focus on the input
this.toggleImportMode('url'); this.toggleImportMode('url');
const urlInput = document.getElementById('imageUrlInput'); const urlInput = document.getElementById('imageUrlInput');
@@ -69,6 +80,14 @@ export class ImportManager {
}, 50); }, 50);
} }
initializeEventHandlers() {
// Default path toggle handler
const useDefaultPathToggle = document.getElementById('importUseDefaultPath');
if (useDefaultPathToggle) {
useDefaultPathToggle.addEventListener('change', this.handleToggleDefaultPath);
}
}
resetSteps() { resetSteps() {
// Clear UI state // Clear UI state
this.stepManager.removeInjectedStyles(); this.stepManager.removeInjectedStyles();
@@ -93,6 +112,12 @@ export class ImportManager {
const tagsContainer = document.getElementById('tagsContainer'); const tagsContainer = document.getElementById('tagsContainer');
if (tagsContainer) tagsContainer.innerHTML = '<div class="empty-tags">No tags added</div>'; if (tagsContainer) tagsContainer.innerHTML = '<div class="empty-tags">No tags added</div>';
// Clear folder path input
const folderPathInput = document.getElementById('importFolderPath');
if (folderPathInput) {
folderPathInput.value = '';
}
// Reset state variables // Reset state variables
this.recipeImage = null; this.recipeImage = null;
this.recipeData = null; this.recipeData = null;
@@ -100,33 +125,19 @@ export class ImportManager {
this.recipeTags = []; this.recipeTags = [];
this.missingLoras = []; this.missingLoras = [];
this.downloadableLoRAs = []; this.downloadableLoRAs = [];
this.selectedFolder = '';
// Reset import mode // Reset import mode
this.importMode = 'url'; this.importMode = 'url';
this.toggleImportMode('url'); this.toggleImportMode('url');
// Reset folder browser // Clear folder tree selection
this.selectedFolder = ''; if (this.folderTreeManager) {
const folderBrowser = document.getElementById('importFolderBrowser'); this.folderTreeManager.clearSelection();
if (folderBrowser) {
folderBrowser.querySelectorAll('.folder-item').forEach(f =>
f.classList.remove('selected'));
} }
// Clear missing LoRAs list // Reset default path toggle
const missingLorasList = document.getElementById('missingLorasList'); this.loadDefaultPathSetting();
if (missingLorasList) missingLorasList.innerHTML = '';
// Reset total download size
const totalSizeDisplay = document.getElementById('totalDownloadSize');
if (totalSizeDisplay) totalSizeDisplay.textContent = 'Calculating...';
// Remove warnings
const deletedLorasWarning = document.getElementById('deletedLorasWarning');
if (deletedLorasWarning) deletedLorasWarning.remove();
const earlyAccessWarning = document.getElementById('earlyAccessWarning');
if (earlyAccessWarning) earlyAccessWarning.remove();
// Reset duplicate related properties // Reset duplicate related properties
this.duplicateRecipes = []; this.duplicateRecipes = [];
@@ -204,7 +215,54 @@ export class ImportManager {
} }
async proceedToLocation() { async proceedToLocation() {
await this.folderBrowser.proceedToLocation(); this.stepManager.showStep('locationStep');
try {
// Fetch LoRA roots
const rootsData = await this.apiClient.fetchModelRoots();
const loraRoot = document.getElementById('importLoraRoot');
loraRoot.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>`
).join('');
// Set default root if available
const defaultRootKey = 'default_lora_root';
const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
loraRoot.value = defaultRoot;
}
// Set autocomplete="off" on folderPath input
const folderPathInput = document.getElementById('importFolderPath');
if (folderPathInput) {
folderPathInput.setAttribute('autocomplete', 'off');
}
// Setup folder tree manager
this.folderTreeManager.init({
elementsPrefix: 'import',
onPathChange: (path) => {
this.selectedFolder = path;
this.updateTargetPath();
}
});
// Initialize folder tree
await this.initializeFolderTree();
// Setup lora root change handler
loraRoot.addEventListener('change', async () => {
await this.initializeFolderTree();
this.updateTargetPath();
});
// Load default path setting for LoRAs
this.loadDefaultPathSetting();
this.updateTargetPath();
} catch (error) {
showToast(error.message, 'error');
}
} }
backToUpload() { backToUpload() {
@@ -234,25 +292,107 @@ export class ImportManager {
await this.downloadManager.saveRecipe(); await this.downloadManager.saveRecipe();
} }
updateTargetPath() { loadDefaultPathSetting() {
this.folderBrowser.updateTargetPath(); const storageKey = 'use_default_path_loras';
this.useDefaultPath = getStorageItem(storageKey, false);
const toggleInput = document.getElementById('importUseDefaultPath');
if (toggleInput) {
toggleInput.checked = this.useDefaultPath;
this.updatePathSelectionUI();
}
} }
ensureModalVisible() { toggleDefaultPath(event) {
const importModal = document.getElementById('importModal'); this.useDefaultPath = event.target.checked;
if (!importModal) {
console.error('Import modal element not found'); // Save to localStorage for LoRAs
return false; const storageKey = 'use_default_path_loras';
setStorageItem(storageKey, this.useDefaultPath);
this.updatePathSelectionUI();
this.updateTargetPath();
}
updatePathSelectionUI() {
const manualSelection = document.getElementById('importManualPathSelection');
// Always show manual path selection, but disable/enable based on useDefaultPath
if (manualSelection) {
manualSelection.style.display = 'block';
if (this.useDefaultPath) {
manualSelection.classList.add('disabled');
// Disable all inputs and buttons inside manualSelection
manualSelection.querySelectorAll('input, select, button').forEach(el => {
el.disabled = true;
el.tabIndex = -1;
});
} else {
manualSelection.classList.remove('disabled');
manualSelection.querySelectorAll('input, select, button').forEach(el => {
el.disabled = false;
el.tabIndex = 0;
});
}
} }
// Check if modal is actually visible // Always update the main path display
const modalDisplay = window.getComputedStyle(importModal).display; this.updateTargetPath();
if (modalDisplay !== 'block') { }
console.error('Import modal is not visible, display: ' + modalDisplay);
return false; async initializeFolderTree() {
try {
// Fetch unified folder tree
const treeData = await this.apiClient.fetchUnifiedFolderTree();
if (treeData.success) {
// Load tree data into folder tree manager
await this.folderTreeManager.loadTree(treeData.tree);
} else {
console.error('Failed to fetch folder tree:', treeData.error);
showToast('Failed to load folder tree', 'error');
}
} catch (error) {
console.error('Error initializing folder tree:', error);
showToast('Error loading folder tree', 'error');
} }
}
cleanupFolderBrowser() {
if (this.folderTreeManager) {
this.folderTreeManager.destroy();
}
}
updateTargetPath() {
const pathDisplay = document.getElementById('importTargetPathDisplay');
const loraRoot = document.getElementById('importLoraRoot').value;
return true; let fullPath = loraRoot || 'Select a LoRA root directory';
if (loraRoot) {
if (this.useDefaultPath) {
// Show actual template path
try {
const templates = state.global.settings.download_path_templates;
const template = templates.lora;
fullPath += `/${template}`;
} catch (error) {
console.error('Failed to fetch template:', error);
fullPath += '/[Auto-organized by path template]';
}
} else {
// Show manual path selection
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
if (selectedPath) {
fullPath += '/' + selectedPath;
}
}
}
if (pathDisplay) {
pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`;
}
} }
/** /**

View File

@@ -132,11 +132,7 @@ export class SettingsManager {
fieldsToSync.forEach(key => { fieldsToSync.forEach(key => {
if (localSettings[key] !== undefined) { if (localSettings[key] !== undefined) {
if (key === 'base_model_path_mappings' || key === 'download_path_templates') { payload[key] = localSettings[key];
payload[key] = JSON.stringify(localSettings[key]);
} else {
payload[key] = localSettings[key];
}
} }
}); });
@@ -546,7 +542,7 @@ export class SettingsManager {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
base_model_path_mappings: JSON.stringify(state.global.settings.base_model_path_mappings) base_model_path_mappings: state.global.settings.base_model_path_mappings
}) })
}); });
@@ -733,7 +729,7 @@ export class SettingsManager {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
download_path_templates: JSON.stringify(state.global.settings.download_path_templates) download_path_templates: state.global.settings.download_path_templates
}) })
}); });
@@ -868,7 +864,7 @@ export class SettingsManager {
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates') { if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates') {
const payload = {}; const payload = {};
if (settingKey === 'download_path_templates') { if (settingKey === 'download_path_templates') {
payload[settingKey] = JSON.stringify(state.global.settings.download_path_templates); payload[settingKey] = state.global.settings.download_path_templates;
} else { } else {
payload[settingKey] = value; payload[settingKey] = value;
} }

View File

@@ -1,6 +1,7 @@
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
export class DownloadManager { export class DownloadManager {
constructor(importManager) { constructor(importManager) {
@@ -120,14 +121,9 @@ export class DownloadManager {
} }
// Build target path // Build target path
let targetPath = loraRoot; let targetPath = '';
if (this.importManager.selectedFolder) { if (this.importManager.selectedFolder) {
targetPath += '/' + this.importManager.selectedFolder; targetPath = this.importManager.selectedFolder;
}
const newFolder = document.getElementById('importNewFolder')?.value?.trim();
if (newFolder) {
targetPath += '/' + newFolder;
} }
// Generate a unique ID for this batch download // Generate a unique ID for this batch download
@@ -189,6 +185,8 @@ export class DownloadManager {
} }
} }
}; };
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) { for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
const lora = this.importManager.downloadableLoRAs[i]; const lora = this.importManager.downloadableLoRAs[i];
@@ -207,6 +205,7 @@ export class DownloadManager {
lora.id, lora.id,
loraRoot, loraRoot,
targetPath.replace(loraRoot + '/', ''), targetPath.replace(loraRoot + '/', ''),
useDefaultPaths,
batchDownloadId batchDownloadId
); );

View File

@@ -45,6 +45,9 @@ export const BASE_MODELS = {
WAN_VIDEO_14B_T2V: "Wan Video 14B t2v", WAN_VIDEO_14B_T2V: "Wan Video 14B t2v",
WAN_VIDEO_14B_I2V_480P: "Wan Video 14B i2v 480p", WAN_VIDEO_14B_I2V_480P: "Wan Video 14B i2v 480p",
WAN_VIDEO_14B_I2V_720P: "Wan Video 14B i2v 720p", WAN_VIDEO_14B_I2V_720P: "Wan Video 14B i2v 720p",
WAN_VIDEO_2_2_TI2V_5B: "Wan Video 2.2 TI2V-5B",
WAN_VIDEO_2_2_T2V_A14B: "Wan Video 2.2 T2V-A14B",
WAN_VIDEO_2_2_I2V_A14B: "Wan Video 2.2 I2V-A14B",
HUNYUAN_VIDEO: "Hunyuan Video", HUNYUAN_VIDEO: "Hunyuan Video",
// Default // Default
UNKNOWN: "Other" UNKNOWN: "Other"

View File

@@ -1,7 +1,9 @@
<div id="importModal" class="modal"> <div id="importModal" class="modal">
<div class="modal-content"> <div class="modal-content">
<button class="close" onclick="modalManager.closeModal('importModal')">&times;</button> <div class="modal-header">
<h2>Import Recipe</h2> <button class="close" onclick="modalManager.closeModal('importModal')">&times;</button>
<h2>Import Recipe</h2>
</div>
<!-- Step 1: Upload Image or Input URL --> <!-- Step 1: Upload Image or Input URL -->
<div class="import-step" id="uploadStep"> <div class="import-step" id="uploadStep">
@@ -99,42 +101,59 @@
<!-- Step 3: Download Location (if needed) --> <!-- Step 3: Download Location (if needed) -->
<div class="import-step" id="locationStep" style="display: none;"> <div class="import-step" id="locationStep" style="display: none;">
<div class="location-selection"> <div class="location-selection">
<!-- Improved missing LoRAs summary section --> <!-- Path preview with inline toggle -->
<div class="missing-loras-summary">
<div class="summary-header">
<h3>Missing LoRAs <span class="lora-count-badge">(0)</span> <span id="totalDownloadSize" class="total-size-badge">Calculating...</span></h3>
<button id="toggleMissingLorasList" class="toggle-list-btn">
<i class="fas fa-chevron-down"></i>
</button>
</div>
<div id="missingLorasList" class="missing-loras-list collapsed">
<!-- Missing LoRAs will be populated here -->
</div>
</div>
<!-- Move path preview to top -->
<div class="path-preview"> <div class="path-preview">
<label>Download Location Preview:</label> <div class="path-preview-header">
<label>Download Location Preview:</label>
<div class="inline-toggle-container" title="When enabled, files are automatically organized using configured path templates">
<span class="inline-toggle-label">Use Default Path</span>
<div class="toggle-switch">
<input type="checkbox" id="importUseDefaultPath">
<label for="importUseDefaultPath" class="toggle-slider"></label>
</div>
</div>
</div>
<div class="path-display" id="importTargetPathDisplay"> <div class="path-display" id="importTargetPathDisplay">
<span class="path-text">Select a LoRA root directory</span> <span class="path-text">Select a LoRA root directory</span>
</div> </div>
</div> </div>
<!-- Model Root Selection -->
<div class="input-group"> <div class="input-group">
<label>Select LoRA Root:</label> <label for="importLoraRoot">Select LoRA Root:</label>
<select id="importLoraRoot"></select> <select id="importLoraRoot"></select>
</div> </div>
<div class="input-group"> <!-- Manual Path Selection -->
<label>Target Folder:</label> <div class="manual-path-selection" id="importManualPathSelection">
<div class="folder-browser" id="importFolderBrowser"> <!-- Path input with autocomplete -->
<!-- Folders will be populated here --> <div class="input-group">
<label for="importFolderPath">Target Folder Path:</label>
<div class="path-input-container">
<input type="text" id="importFolderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" />
<button type="button" id="importCreateFolderBtn" class="create-folder-btn" title="Create new folder">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="path-suggestions" id="importPathSuggestions" style="display: none;"></div>
</div>
<!-- Breadcrumb navigation -->
<div class="breadcrumb-nav" id="importBreadcrumbNav">
<span class="breadcrumb-item root" data-path="">
<i class="fas fa-home"></i> Root
</span>
</div>
<!-- Hierarchical folder tree -->
<div class="input-group">
<label>Browse Folders:</label>
<div class="folder-tree-container">
<div class="folder-tree" id="importFolderTree">
<!-- Tree will be loaded dynamically -->
</div>
</div>
</div> </div>
</div>
<div class="input-group">
<label for="importNewFolder">New Folder (optional):</label>
<input type="text" id="importNewFolder" placeholder="Enter folder name">
</div> </div>
</div> </div>

View File

@@ -141,11 +141,18 @@ class AutoComplete {
} }
getSearchTerm(value) { getSearchTerm(value) {
const lastCommaIndex = value.lastIndexOf(','); // Use helper to get text before cursor for more accurate positioning
if (lastCommaIndex === -1) { const beforeCursor = this.helper.getBeforeCursor();
return value.trim(); if (!beforeCursor) {
return '';
} }
return value.substring(lastCommaIndex + 1).trim();
// Split on multiple delimiters: comma, space, '>' and other common separators
const segments = beforeCursor.split(/[,\s>]+/);
// Return the last non-empty segment as search term
const lastSegment = segments[segments.length - 1] || '';
return lastSegment.trim();
} }
async search(term = '') { async search(term = '') {
@@ -221,6 +228,13 @@ class AutoComplete {
if (this.dropdown.lastChild) { if (this.dropdown.lastChild) {
this.dropdown.lastChild.style.borderBottom = 'none'; this.dropdown.lastChild.style.borderBottom = 'none';
} }
// Auto-select the first item with a small delay
if (this.items.length > 0) {
setTimeout(() => {
this.selectItem(0);
}, 100); // 50ms delay
}
} }
highlightMatch(text, searchTerm) { highlightMatch(text, searchTerm) {
@@ -234,7 +248,7 @@ class AutoComplete {
if (!this.previewTooltip) return; if (!this.previewTooltip) return;
// Extract filename without extension for preview // Extract filename without extension for preview
const fileName = relativePath.split('/').pop(); const fileName = relativePath.split(/[/\\]/).pop();
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, ''); const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
// Get item position for tooltip positioning // Get item position for tooltip positioning
@@ -242,7 +256,7 @@ class AutoComplete {
const x = rect.right + 10; const x = rect.right + 10;
const y = rect.top; const y = rect.top;
this.previewTooltip.show(loraName, x, y); this.previewTooltip.show(loraName, x, y, true); // Pass true for fromAutocomplete flag
} }
hidePreview() { hidePreview() {
@@ -366,7 +380,7 @@ class AutoComplete {
async insertSelection(relativePath) { async insertSelection(relativePath) {
// Extract just the filename for LoRA name // Extract just the filename for LoRA name
const fileName = relativePath.split('/').pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, ''); const fileName = relativePath.split(/[/\\]/).pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
// Get usage tips and extract strength // Get usage tips and extract strength
let strength = 1.0; // Default strength let strength = 1.0; // Default strength

View File

@@ -1,209 +1,225 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { import {
LORA_PATTERN, LORA_PATTERN,
collectActiveLorasFromChain, collectActiveLorasFromChain,
updateConnectedTriggerWords, updateConnectedTriggerWords,
chainCallback, chainCallback,
mergeLoras, mergeLoras,
setupInputWidgetWithAutocomplete setupInputWidgetWithAutocomplete,
} from "./utils.js"; } from "./utils.js";
import { addLorasWidget } from "./loras_widget.js"; import { addLorasWidget } from "./loras_widget.js";
app.registerExtension({ app.registerExtension({
name: "LoraManager.LoraLoader", name: "LoraManager.LoraLoader",
setup() { setup() {
// Add message handler to listen for messages from Python // Add message handler to listen for messages from Python
api.addEventListener("lora_code_update", (event) => { api.addEventListener("lora_code_update", (event) => {
const { id, lora_code, mode } = event.detail; const { id, lora_code, mode } = event.detail;
this.handleLoraCodeUpdate(id, lora_code, mode); this.handleLoraCodeUpdate(id, lora_code, mode);
});
},
// Handle lora code updates from Python
handleLoraCodeUpdate(id, loraCode, mode) {
// Handle broadcast mode (for Desktop/non-browser support)
if (id === -1) {
// Find all Lora Loader nodes in the current graph
const loraLoaderNodes = [];
for (const nodeId in app.graph._nodes_by_id) {
const node = app.graph._nodes_by_id[nodeId];
if (node.comfyClass === "Lora Loader (LoraManager)") {
loraLoaderNodes.push(node);
}
}
// Update each Lora Loader node found
if (loraLoaderNodes.length > 0) {
loraLoaderNodes.forEach((node) => {
this.updateNodeLoraCode(node, loraCode, mode);
}); });
}, console.log(
`Updated ${loraLoaderNodes.length} Lora Loader nodes in broadcast mode`
// Handle lora code updates from Python );
handleLoraCodeUpdate(id, loraCode, mode) { } else {
// Handle broadcast mode (for Desktop/non-browser support) console.warn(
if (id === -1) { "No Lora Loader nodes found in the workflow for broadcast update"
// Find all Lora Loader nodes in the current graph );
const loraLoaderNodes = []; }
for (const nodeId in app.graph._nodes_by_id) {
const node = app.graph._nodes_by_id[nodeId]; return;
if (node.comfyClass === "Lora Loader (LoraManager)") { }
loraLoaderNodes.push(node);
// Standard mode - update a specific node
const node = app.graph.getNodeById(+id);
if (
!node ||
(node.comfyClass !== "Lora Loader (LoraManager)" &&
node.comfyClass !== "Lora Stacker (LoraManager)" &&
node.comfyClass !== "WanVideo Lora Select (LoraManager)")
) {
console.warn("Node not found or not a LoraLoader:", id);
return;
}
this.updateNodeLoraCode(node, loraCode, mode);
},
// Helper method to update a single node's lora code
updateNodeLoraCode(node, loraCode, mode) {
// Update the input widget with new lora code
const inputWidget = node.inputWidget;
if (!inputWidget) return;
// Get the current lora code
const currentValue = inputWidget.value || "";
// Update based on mode (replace or append)
if (mode === "replace") {
inputWidget.value = loraCode;
} else {
// Append mode - add a space if the current value isn't empty
inputWidget.value = currentValue.trim()
? `${currentValue.trim()} ${loraCode}`
: loraCode;
}
// Trigger the callback to update the loras widget
if (typeof inputWidget.callback === "function") {
inputWidget.callback(inputWidget.value);
}
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass == "Lora Loader (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Enable widget serialization
this.serialize_widgets = true;
this.addInput("clip", "CLIP", {
shape: 7,
});
this.addInput("lora_stack", "LORA_STACK", {
shape: 7, // 7 is the shape of the optional input
});
// Add flag to prevent callback loops
let isUpdating = false;
// Get the widget object directly from the returned object
this.lorasWidget = addLorasWidget(
this,
"loras",
{},
(value) => {
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(this);
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(this, allActiveLoraNames);
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = this.widgets[0];
const currentLoras = value.map((l) => l.name);
// Use the constant pattern here as well
let newText = inputWidget.value.replace(
LORA_PATTERN,
(match, name, strength, clipStrength) => {
return currentLoras.includes(name) ? match : "";
} }
);
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
newText = newText
.replace(/\s+/g, " ")
.replace(/,\s*,+/g, ",")
.trim();
if (newText === ",") newText = "";
inputWidget.value = newText;
} finally {
isUpdating = false;
} }
}
// Update each Lora Loader node found ).widget;
if (loraLoaderNodes.length > 0) {
loraLoaderNodes.forEach(node => {
this.updateNodeLoraCode(node, loraCode, mode);
});
console.log(`Updated ${loraLoaderNodes.length} Lora Loader nodes in broadcast mode`);
} else {
console.warn("No Lora Loader nodes found in the workflow for broadcast update");
}
return;
}
// Standard mode - update a specific node
const node = app.graph.getNodeById(+id);
if (!node || (node.comfyClass !== "Lora Loader (LoraManager)" &&
node.comfyClass !== "Lora Stacker (LoraManager)" &&
node.comfyClass !== "WanVideo Lora Select (LoraManager)")) {
console.warn("Node not found or not a LoraLoader:", id);
return;
}
this.updateNodeLoraCode(node, loraCode, mode);
},
// Helper method to update a single node's lora code // Update input widget callback
updateNodeLoraCode(node, loraCode, mode) { const inputWidget = this.widgets[0];
// Update the input widget with new lora code inputWidget.options.getMaxHeight = () => 100;
const inputWidget = node.inputWidget; this.inputWidget = inputWidget;
if (!inputWidget) return;
// Get the current lora code
const currentValue = inputWidget.value || '';
// Update based on mode (replace or append)
if (mode === 'replace') {
inputWidget.value = loraCode;
} else {
// Append mode - add a space if the current value isn't empty
inputWidget.value = currentValue.trim()
? `${currentValue.trim()} ${loraCode}`
: loraCode;
}
// Trigger the callback to update the loras widget
if (typeof inputWidget.callback === 'function') {
inputWidget.callback(inputWidget.value);
}
},
async beforeRegisterNodeDef(nodeType, nodeData, app) { const originalCallback = (value) => {
if (nodeType.comfyClass == "Lora Loader (LoraManager)") { if (isUpdating) return;
chainCallback(nodeType.prototype, "onNodeCreated", function () { isUpdating = true;
// Enable widget serialization
this.serialize_widgets = true;
this.addInput("clip", "CLIP", { try {
shape: 7, const currentLoras = this.lorasWidget.value || [];
}); const mergedLoras = mergeLoras(value, currentLoras);
this.addInput("lora_stack", "LORA_STACK", { this.lorasWidget.value = mergedLoras;
shape: 7, // 7 is the shape of the optional input } finally {
}); isUpdating = false;
}
};
// Restore saved value if exists // Setup input widget with autocomplete
let existingLoras = []; inputWidget.callback = setupInputWidgetWithAutocomplete(
if (this.widgets_values && this.widgets_values.length > 0) { this,
// 0 for input widget, 1 for loras widget inputWidget,
const savedValue = this.widgets_values[1]; originalCallback
existingLoras = savedValue || []; );
}
// Merge the loras data
const mergedLoras = mergeLoras(
this.widgets[0].value,
existingLoras
);
// Add flag to prevent callback loops // Register this node with the backend
let isUpdating = false; this.registerNode = async () => {
try {
// Get the widget object directly from the returned object await fetch("/api/register-node", {
this.lorasWidget = addLorasWidget( method: "POST",
this, headers: {
"loras", "Content-Type": "application/json",
{
defaultVal: mergedLoras, // Pass object directly
}, },
(value) => { body: JSON.stringify({
// Collect all active loras from this node and its input chain node_id: this.id,
const allActiveLoraNames = collectActiveLorasFromChain(this); bgcolor: this.bgcolor,
title: this.title,
graph_id: this.graph.id,
}),
});
} catch (error) {
console.warn("Failed to register node:", error);
}
};
// Update trigger words for connected toggle nodes with the aggregated lora names // Ensure the node is registered after creation
updateConnectedTriggerWords(this, allActiveLoraNames); // Call registration
// setTimeout(() => {
// this.registerNode();
// }, 0);
});
}
},
// Prevent recursive calls async nodeCreated(node) {
if (isUpdating) return; if (node.comfyClass == "Lora Loader (LoraManager)") {
isUpdating = true; requestAnimationFrame(async () => {
// Restore saved value if exists
try { let existingLoras = [];
// Remove loras that are not in the value array if (node.widgets_values && node.widgets_values.length > 0) {
const inputWidget = this.widgets[0]; // 0 for input widget, 1 for loras widget
const currentLoras = value.map((l) => l.name); const savedValue = node.widgets_values[1];
existingLoras = savedValue || [];
// Use the constant pattern here as well
let newText = inputWidget.value.replace(
LORA_PATTERN,
(match, name, strength, clipStrength) => {
return currentLoras.includes(name) ? match : "";
}
);
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
newText = newText.replace(/\s+/g, " ").replace(/,\s*,+/g, ",").trim();
if (newText === ",") newText = "";
inputWidget.value = newText;
} finally {
isUpdating = false;
}
}
).widget;
// Update input widget callback
const inputWidget = this.widgets[0];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
const originalCallback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.lorasWidget.value = mergedLoras;
} finally {
isUpdating = false;
}
};
// Setup input widget with autocomplete
inputWidget.callback = setupInputWidgetWithAutocomplete(this, inputWidget, originalCallback);
// Register this node with the backend
this.registerNode = async () => {
try {
await fetch('/api/register-node', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node_id: this.id,
bgcolor: this.bgcolor,
title: this.title,
graph_id: this.graph.id
})
});
} catch (error) {
console.warn('Failed to register node:', error);
}
};
// Ensure the node is registered after creation
// Call registration
// setTimeout(() => {
// this.registerNode();
// }, 0);
});
} }
}, // Merge the loras data
}); const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
node.lorasWidget.value = mergedLoras;
});
}
},
});

View File

@@ -1,164 +1,185 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { import {
LORA_PATTERN, LORA_PATTERN,
getActiveLorasFromNode, getActiveLorasFromNode,
collectActiveLorasFromChain, collectActiveLorasFromChain,
updateConnectedTriggerWords, updateConnectedTriggerWords,
chainCallback, chainCallback,
mergeLoras, mergeLoras,
setupInputWidgetWithAutocomplete setupInputWidgetWithAutocomplete,
} from "./utils.js"; } from "./utils.js";
import { addLorasWidget } from "./loras_widget.js"; import { addLorasWidget } from "./loras_widget.js";
app.registerExtension({ app.registerExtension({
name: "LoraManager.LoraStacker", name: "LoraManager.LoraStacker",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "Lora Stacker (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", async function() {
// Enable widget serialization
this.serialize_widgets = true;
this.addInput("lora_stack", 'LORA_STACK', { async beforeRegisterNodeDef(nodeType, nodeData, app) {
"shape": 7 // 7 is the shape of the optional input if (nodeType.comfyClass === "Lora Stacker (LoraManager)") {
}); chainCallback(nodeType.prototype, "onNodeCreated", async function () {
// Enable widget serialization
this.serialize_widgets = true;
// Restore saved value if exists this.addInput("lora_stack", "LORA_STACK", {
let existingLoras = []; shape: 7, // 7 is the shape of the optional input
if (this.widgets_values && this.widgets_values.length > 0) { });
// 0 for input widget, 1 for loras widget
const savedValue = this.widgets_values[1];
existingLoras = savedValue || [];
}
// Merge the loras data
const mergedLoras = mergeLoras(this.widgets[0].value, existingLoras);
// Add flag to prevent callback loops
let isUpdating = false;
const result = addLorasWidget(this, "loras", {
defaultVal: mergedLoras // Pass object directly
}, (value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = this.widgets[0];
const currentLoras = value.map(l => l.name);
// Use the constant pattern here as well
let newText = inputWidget.value.replace(LORA_PATTERN, (match, name, strength) => {
return currentLoras.includes(name) ? match : '';
});
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
newText = newText.replace(/\s+/g, " ").replace(/,\s*,+/g, ",").trim();
if (newText === ",") newText = "";
inputWidget.value = newText;
// Update this stacker's direct trigger toggles with its own active loras
const activeLoraNames = new Set();
value.forEach(lora => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
updateConnectedTriggerWords(this, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(this);
} finally {
isUpdating = false;
}
});
this.lorasWidget = result.widget;
// Update input widget callback // Add flag to prevent callback loops
const inputWidget = this.widgets[0]; let isUpdating = false;
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
// Wrap the callback with autocomplete setup
const originalCallback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.lorasWidget.value = mergedLoras;
// Update this stacker's direct trigger toggles with its own active loras
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(this);
} finally {
isUpdating = false;
}
};
inputWidget.callback = setupInputWidgetWithAutocomplete(this, inputWidget, originalCallback);
// Register this node with the backend const result = addLorasWidget(this, "loras", {}, (value) => {
this.registerNode = async () => { // Prevent recursive calls
try { if (isUpdating) return;
await fetch('/api/register-node', { isUpdating = true;
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node_id: this.id,
bgcolor: this.bgcolor,
title: this.title,
graph_id: this.graph.id
})
});
} catch (error) {
console.warn('Failed to register node:', error);
}
};
// Call registration try {
// setTimeout(() => { // Remove loras that are not in the value array
// this.registerNode(); const inputWidget = this.widgets[0];
// }, 0); const currentLoras = value.map((l) => l.name);
// Use the constant pattern here as well
let newText = inputWidget.value.replace(
LORA_PATTERN,
(match, name, strength) => {
return currentLoras.includes(name) ? match : "";
}
);
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
newText = newText
.replace(/\s+/g, " ")
.replace(/,\s*,+/g, ",")
.trim();
if (newText === ",") newText = "";
inputWidget.value = newText;
// Update this stacker's direct trigger toggles with its own active loras
const activeLoraNames = new Set();
value.forEach((lora) => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
}); });
updateConnectedTriggerWords(this, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(this);
} finally {
isUpdating = false;
}
});
this.lorasWidget = result.widget;
// Update input widget callback
const inputWidget = this.widgets[0];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
// Wrap the callback with autocomplete setup
const originalCallback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.lorasWidget.value = mergedLoras;
// Update this stacker's direct trigger toggles with its own active loras
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(this);
} finally {
isUpdating = false;
}
};
inputWidget.callback = setupInputWidgetWithAutocomplete(
this,
inputWidget,
originalCallback
);
// Register this node with the backend
this.registerNode = async () => {
try {
await fetch("/api/register-node", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
node_id: this.id,
bgcolor: this.bgcolor,
title: this.title,
graph_id: this.graph.id,
}),
});
} catch (error) {
console.warn("Failed to register node:", error);
}
};
// Call registration
// setTimeout(() => {
// this.registerNode();
// }, 0);
});
}
},
async nodeCreated(node) {
if (node.comfyClass == "Lora Stacker (LoraManager)") {
requestAnimationFrame(async () => {
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
// 0 for input widget, 1 for loras widget
const savedValue = node.widgets_values[1];
existingLoras = savedValue || [];
} }
}, // Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
node.lorasWidget.value = mergedLoras;
});
}
},
}); });
// Helper function to find and update downstream Lora Loader nodes // Helper function to find and update downstream Lora Loader nodes
function updateDownstreamLoaders(startNode, visited = new Set()) { function updateDownstreamLoaders(startNode, visited = new Set()) {
if (visited.has(startNode.id)) return; if (visited.has(startNode.id)) return;
visited.add(startNode.id); visited.add(startNode.id);
// Check each output link // Check each output link
if (startNode.outputs) { if (startNode.outputs) {
for (const output of startNode.outputs) { for (const output of startNode.outputs) {
if (output.links) { if (output.links) {
for (const linkId of output.links) { for (const linkId of output.links) {
const link = app.graph.links[linkId]; const link = app.graph.links[linkId];
if (link) { if (link) {
const targetNode = app.graph.getNodeById(link.target_id); const targetNode = app.graph.getNodeById(link.target_id);
// If target is a Lora Loader, collect all active loras in the chain and update // If target is a Lora Loader, collect all active loras in the chain and update
if (targetNode && targetNode.comfyClass === "Lora Loader (LoraManager)") { if (
const allActiveLoraNames = collectActiveLorasFromChain(targetNode); targetNode &&
updateConnectedTriggerWords(targetNode, allActiveLoraNames); targetNode.comfyClass === "Lora Loader (LoraManager)"
} ) {
// If target is another Lora Stacker, recursively check its outputs const allActiveLoraNames =
else if (targetNode && targetNode.comfyClass === "Lora Stacker (LoraManager)") { collectActiveLorasFromChain(targetNode);
updateDownstreamLoaders(targetNode, visited); updateConnectedTriggerWords(targetNode, allActiveLoraNames);
}
}
}
} }
// If target is another Lora Stacker, recursively check its outputs
else if (
targetNode &&
targetNode.comfyClass === "Lora Stacker (LoraManager)"
) {
updateDownstreamLoaders(targetNode, visited);
}
}
} }
}
} }
} }
}

View File

@@ -1,4 +1,4 @@
import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection } from "./loras_widget_components.js"; import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection, createExpandButton, updateExpandButtonState } from "./loras_widget_components.js";
import { import {
parseLoraValue, parseLoraValue,
formatLoraValue, formatLoraValue,
@@ -215,7 +215,8 @@ export function addLorasWidget(node, name, opts, callback) {
if (e.target.closest('.comfy-lora-toggle') || if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') || e.target.closest('input') ||
e.target.closest('.comfy-lora-arrow') || e.target.closest('.comfy-lora-arrow') ||
e.target.closest('.comfy-lora-drag-handle')) { e.target.closest('.comfy-lora-drag-handle') ||
e.target.closest('.comfy-lora-expand-button')) {
return; return;
} }
@@ -225,41 +226,6 @@ export function addLorasWidget(node, name, opts, callback) {
container.focus(); // Focus container for keyboard events container.focus(); // Focus container for keyboard events
}); });
// Add double-click handler to toggle clip entry
loraEl.addEventListener('dblclick', (e) => {
// Skip if clicking on toggle or strength control areas
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') ||
e.target.closest('.comfy-lora-arrow')) {
return;
}
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
// Toggle the clip entry expanded state
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
// Explicitly toggle the expansion state
const currentExpanded = shouldShowClipEntry(lorasData[loraIndex]);
lorasData[loraIndex].expanded = !currentExpanded;
// If collapsing, set clipStrength = strength
if (!lorasData[loraIndex].expanded) {
lorasData[loraIndex].clipStrength = lorasData[loraIndex].strength;
}
// Update the widget value
widget.value = formatLoraValue(lorasData);
// Re-render to show/hide clip entry
renderLoras(widget.value, widget);
}
});
// Create drag handle for reordering // Create drag handle for reordering
const dragHandle = createDragHandle(); const dragHandle = createDragHandle();
@@ -280,32 +246,46 @@ export function addLorasWidget(node, name, opts, callback) {
} }
}); });
// Create expand button
const expandButton = createExpandButton(isExpanded, (shouldExpand) => {
// Toggle the clip entry expanded state
const lorasData = parseLoraValue(widget.value);
const loraIndex = lorasData.findIndex(l => l.name === name);
if (loraIndex >= 0) {
// Set the expansion state
lorasData[loraIndex].expanded = shouldExpand;
// If collapsing, set clipStrength = strength
if (!shouldExpand) {
lorasData[loraIndex].clipStrength = lorasData[loraIndex].strength;
}
// Update the widget value
widget.value = formatLoraValue(lorasData);
// Re-render to show/hide clip entry
renderLoras(widget.value, widget);
}
});
// Create name display // Create name display
const nameEl = document.createElement("div"); const nameEl = document.createElement("div");
nameEl.textContent = name; nameEl.textContent = name;
Object.assign(nameEl.style, { Object.assign(nameEl.style, {
marginLeft: "10px", marginLeft: "4px",
flex: "1", flex: "1",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap", whiteSpace: "nowrap",
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)", color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
fontSize: "13px", fontSize: "13px",
cursor: "pointer",
userSelect: "none", userSelect: "none",
WebkitUserSelect: "none", WebkitUserSelect: "none",
MozUserSelect: "none", MozUserSelect: "none",
msUserSelect: "none", msUserSelect: "none",
}); });
// Add expand indicator to name element
const expandIndicator = document.createElement("span");
expandIndicator.textContent = isExpanded ? " ▼" : " ▶";
expandIndicator.style.opacity = "0.7";
expandIndicator.style.fontSize = "9px";
expandIndicator.style.marginLeft = "4px";
nameEl.appendChild(expandIndicator);
// Move preview tooltip events to nameEl instead of loraEl // Move preview tooltip events to nameEl instead of loraEl
nameEl.addEventListener('mouseenter', async (e) => { nameEl.addEventListener('mouseenter', async (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -464,6 +444,7 @@ export function addLorasWidget(node, name, opts, callback) {
leftSection.appendChild(dragHandle); // Add drag handle first leftSection.appendChild(dragHandle); // Add drag handle first
leftSection.appendChild(toggle); leftSection.appendChild(toggle);
leftSection.appendChild(expandButton); // Add expand button
leftSection.appendChild(nameEl); leftSection.appendChild(nameEl);
loraEl.appendChild(leftSection); loraEl.appendChild(leftSection);
@@ -471,9 +452,6 @@ export function addLorasWidget(node, name, opts, callback) {
container.appendChild(loraEl); container.appendChild(loraEl);
// Update selection state
updateEntrySelection(loraEl, name === selectedLora);
// If expanded, show the clip entry // If expanded, show the clip entry
if (isExpanded) { if (isExpanded) {
totalVisibleEntries++; totalVisibleEntries++;
@@ -657,6 +635,13 @@ export function addLorasWidget(node, name, opts, callback) {
// Calculate height based on number of loras and fixed sizes // Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 12) * LORA_ENTRY_HEIGHT); const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 12) * LORA_ENTRY_HEIGHT);
updateWidgetHeight(container, calculatedHeight, defaultHeight, node); updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
// After all LoRA elements are created, apply selection state as the last step
// This ensures the selection state is not overwritten
container.querySelectorAll('.comfy-lora-entry').forEach(entry => {
const entryLoraName = entry.dataset.loraName;
updateEntrySelection(entry, entryLoraName === selectedLora);
});
}; };
// Store the value in a variable to avoid recursion // Store the value in a variable to avoid recursion
@@ -675,25 +660,9 @@ export function addLorasWidget(node, name, opts, callback) {
// Add the current lora // Add the current lora
return [...filtered, lora]; return [...filtered, lora];
}, []); }, []);
// Preserve clip strengths and expanded state when updating the value
const oldLoras = parseLoraValue(widgetValue);
// Apply existing clip strength values and transfer them to the new value // Apply existing clip strength values and transfer them to the new value
const updatedValue = uniqueValue.map(lora => { const updatedValue = uniqueValue.map(lora => {
const existingLora = oldLoras.find(oldLora => oldLora.name === lora.name);
// If there's an existing lora with the same name, preserve its clip strength and expanded state
if (existingLora) {
return {
...lora,
clipStrength: existingLora.clipStrength || lora.strength,
expanded: existingLora.hasOwnProperty('expanded') ?
existingLora.expanded :
Number(existingLora.clipStrength || lora.strength) !== Number(lora.strength)
};
}
// For new loras, default clip strength to model strength and expanded to false // For new loras, default clip strength to model strength and expanded to false
// unless clipStrength is already different from strength // unless clipStrength is already different from strength
const clipStrength = lora.clipStrength || lora.strength; const clipStrength = lora.clipStrength || lora.strength;

View File

@@ -115,6 +115,10 @@ export function createDragHandle() {
handle.onmousedown = () => { handle.onmousedown = () => {
handle.style.cursor = "grabbing"; handle.style.cursor = "grabbing";
}; };
handle.onmouseup = () => {
handle.style.cursor = "grab";
};
return handle; return handle;
} }
@@ -143,15 +147,22 @@ export function createDropIndicator() {
// Function to update entry selection state // Function to update entry selection state
export function updateEntrySelection(entryEl, isSelected) { export function updateEntrySelection(entryEl, isSelected) {
const baseColor = entryEl.dataset.active === 'true' ? // Remove any conflicting styles first
entryEl.style.removeProperty('border');
entryEl.style.removeProperty('box-shadow');
const baseColor = entryEl.dataset.active === 'true' ?
"rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)"; "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)";
const selectedColor = entryEl.dataset.active === 'true' ? const selectedColor = entryEl.dataset.active === 'true' ?
"rgba(66, 153, 225, 0.3)" : "rgba(66, 153, 225, 0.2)"; "rgba(66, 153, 225, 0.3)" : "rgba(66, 153, 225, 0.2)";
// Update data attribute to track selection state
entryEl.dataset.selected = isSelected ? 'true' : 'false';
if (isSelected) { if (isSelected) {
entryEl.style.backgroundColor = selectedColor; entryEl.style.setProperty('backgroundColor', selectedColor, 'important');
entryEl.style.border = "1px solid rgba(66, 153, 225, 0.6)"; entryEl.style.setProperty('border', "1px solid rgba(66, 153, 225, 0.6)", 'important');
entryEl.style.boxShadow = "0 0 0 1px rgba(66, 153, 225, 0.3)"; entryEl.style.setProperty('box-shadow', "0 0 0 1px rgba(66, 153, 225, 0.3)", 'important');
} else { } else {
entryEl.style.backgroundColor = baseColor; entryEl.style.backgroundColor = baseColor;
entryEl.style.border = "1px solid transparent"; entryEl.style.border = "1px solid transparent";
@@ -301,8 +312,6 @@ export class PreviewTooltip {
mediaElement.controls = false; mediaElement.controls = false;
} }
mediaElement.src = data.preview_url;
// Create name label with absolute positioning // Create name label with absolute positioning
const nameLabel = document.createElement('div'); const nameLabel = document.createElement('div');
nameLabel.textContent = loraName; nameLabel.textContent = loraName;
@@ -328,12 +337,43 @@ export class PreviewTooltip {
mediaContainer.appendChild(nameLabel); mediaContainer.appendChild(nameLabel);
this.element.appendChild(mediaContainer); this.element.appendChild(mediaContainer);
// Add fade-in effect // Show element with opacity 0 first to get dimensions
this.element.style.opacity = '0'; this.element.style.opacity = '0';
this.element.style.display = 'block'; this.element.style.display = 'block';
this.position(x, y);
// Wait for media to load before positioning
const waitForLoad = () => {
return new Promise((resolve) => {
if (isVideo) {
if (mediaElement.readyState >= 2) { // HAVE_CURRENT_DATA
resolve();
} else {
mediaElement.addEventListener('loadeddata', resolve, { once: true });
mediaElement.addEventListener('error', resolve, { once: true });
}
} else {
if (mediaElement.complete) {
resolve();
} else {
mediaElement.addEventListener('load', resolve, { once: true });
mediaElement.addEventListener('error', resolve, { once: true });
}
}
// Set a timeout to prevent hanging
setTimeout(resolve, 1000);
});
};
// Set source after setting up load listeners
mediaElement.src = data.preview_url;
// Wait for content to load, then position and show
await waitForLoad();
// Small delay to ensure layout is complete
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.position(x, y);
this.element.style.transition = 'opacity 0.15s ease'; this.element.style.transition = 'opacity 0.15s ease';
this.element.style.opacity = '1'; this.element.style.opacity = '1';
}); });
@@ -399,3 +439,86 @@ export class PreviewTooltip {
this.element.remove(); this.element.remove();
} }
} }
// Function to create expand/collapse button
export function createExpandButton(isExpanded, onClick) {
const button = document.createElement("button");
button.className = "comfy-lora-expand-button";
button.type = "button";
Object.assign(button.style, {
width: "20px",
height: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
userSelect: "none",
fontSize: "10px",
color: "rgba(226, 232, 240, 0.7)",
backgroundColor: "rgba(45, 55, 72, 0.3)",
border: "1px solid rgba(226, 232, 240, 0.2)",
borderRadius: "3px",
transition: "all 0.2s ease",
marginLeft: "6px",
marginRight: "4px",
flexShrink: "0",
outline: "none"
});
// Set icon based on expanded state
updateExpandButtonState(button, isExpanded);
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onClick(!isExpanded);
});
// Add hover effects
button.addEventListener("mouseenter", () => {
button.style.backgroundColor = "rgba(66, 153, 225, 0.2)";
button.style.borderColor = "rgba(66, 153, 225, 0.4)";
button.style.color = "rgba(226, 232, 240, 0.9)";
button.style.transform = "scale(1.05)";
});
button.addEventListener("mouseleave", () => {
button.style.backgroundColor = "rgba(45, 55, 72, 0.3)";
button.style.borderColor = "rgba(226, 232, 240, 0.2)";
button.style.color = "rgba(226, 232, 240, 0.7)";
button.style.transform = "scale(1)";
});
// Add active (pressed) state
button.addEventListener("mousedown", () => {
button.style.transform = "scale(0.95)";
button.style.backgroundColor = "rgba(66, 153, 225, 0.3)";
});
button.addEventListener("mouseup", () => {
button.style.transform = "scale(1.05)"; // Return to hover state
});
// Add focus state for keyboard accessibility
button.addEventListener("focus", () => {
button.style.boxShadow = "0 0 0 2px rgba(66, 153, 225, 0.5)";
});
button.addEventListener("blur", () => {
button.style.boxShadow = "none";
});
return button;
}
// Helper function to update expand button state
export function updateExpandButtonState(button, isExpanded) {
if (isExpanded) {
button.innerHTML = "▼"; // Down arrow for expanded
button.title = "Collapse clip controls";
} else {
button.innerHTML = "▶"; // Right arrow for collapsed
button.title = "Expand clip controls";
}
}

View File

@@ -120,7 +120,9 @@ export function initDrag(dragEl, name, widget, isClipStrength = false, previewTo
// Skip if clicking on toggle or strength control areas // Skip if clicking on toggle or strength control areas
if (e.target.closest('.comfy-lora-toggle') || if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') || e.target.closest('input') ||
e.target.closest('.comfy-lora-arrow')) { e.target.closest('.comfy-lora-arrow') ||
e.target.closest('.comfy-lora-drag-handle') ||
e.target.closest('.comfy-lora-expand-button')) {
return; return;
} }
@@ -304,6 +306,9 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
}); });
document.addEventListener('mouseup', (e) => { document.addEventListener('mouseup', (e) => {
// Always reset cursor regardless of isDragging state
document.body.style.cursor = '';
if (!isDragging || !draggedElement) return; if (!isDragging || !draggedElement) return;
const targetIndex = getDropTargetIndex(container, e.clientY); const targetIndex = getDropTargetIndex(container, e.clientY);
@@ -356,10 +361,13 @@ export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
dropIndicator = null; dropIndicator = null;
} }
// Reset cursor
document.body.style.cursor = '';
container = null; container = null;
}); });
// Also reset cursor when mouse leaves the document
document.addEventListener('mouseleave', () => {
document.body.style.cursor = '';
});
} }
// Function to handle keyboard navigation // Function to handle keyboard navigation

View File

@@ -1,6 +1,21 @@
// ComfyUI extension to track model usage statistics // ComfyUI extension to track model usage statistics
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { showToast } from "./utils.js";
// Define target nodes and their widget configurations
const PATH_CORRECTION_TARGETS = [
{ comfyClass: "CheckpointLoaderSimple", widgetName: "ckpt_name", modelType: "checkpoints" },
{ comfyClass: "Checkpoint Loader with Name (Image Saver)", widgetName: "ckpt_name", modelType: "checkpoints" },
{ comfyClass: "UNETLoader", widgetName: "unet_name", modelType: "checkpoints" },
{ comfyClass: "easy comfyLoader", widgetName: "ckpt_name", modelType: "checkpoints" },
{ comfyClass: "CheckpointLoader|pysssss", widgetName: "ckpt_name", modelType: "checkpoints" },
{ comfyClass: "Efficient Loader", widgetName: "ckpt_name", modelType: "checkpoints" },
{ comfyClass: "UnetLoaderGGUF", widgetName: "unet_name", modelType: "checkpoints" },
{ comfyClass: "UnetLoaderGGUFAdvanced", widgetName: "unet_name", modelType: "checkpoints" },
{ comfyClass: "LoraLoader", widgetName: "lora_name", modelType: "loras" },
{ comfyClass: "easy loraStack", widgetNamePattern: "lora_\\d+_name", modelType: "loras" }
];
// Register the extension // Register the extension
app.registerExtension({ app.registerExtension({
@@ -80,5 +95,110 @@ app.registerExtension({
} catch (error) { } catch (error) {
console.error("Error refreshing registry:", error); console.error("Error refreshing registry:", error);
} }
},
async loadedGraphNode(node) {
// Check if this node type needs path correction
const target = PATH_CORRECTION_TARGETS.find(t => t.comfyClass === node.comfyClass);
if (!target) {
return;
}
await this.correctNodePaths(node, target);
},
async correctNodePaths(node, target) {
try {
if (target.widgetNamePattern) {
// Handle pattern-based widget names (like lora_1_name, lora_2_name, etc.)
const pattern = new RegExp(target.widgetNamePattern);
const widgetIndexes = [];
if (node.widgets) {
node.widgets.forEach((widget, index) => {
if (pattern.test(widget.name)) {
widgetIndexes.push(index);
}
});
}
// Process each matching widget
for (const widgetIndex of widgetIndexes) {
await this.correctWidgetPath(node, widgetIndex, target.modelType);
}
} else {
// Handle single widget name
if (node.widgets) {
const widgetIndex = node.widgets.findIndex(w => w.name === target.widgetName);
if (widgetIndex !== -1) {
await this.correctWidgetPath(node, widgetIndex, target.modelType);
}
}
}
} catch (error) {
console.error("Error correcting node paths:", error);
}
},
async correctWidgetPath(node, widgetIndex, modelType) {
if (!node.widgets_values || !node.widgets_values[widgetIndex]) {
return;
}
const currentPath = node.widgets_values[widgetIndex];
if (!currentPath || typeof currentPath !== 'string') {
return;
}
// Extract filename from path (after last separator)
const fileName = currentPath.split(/[/\\]/).pop();
if (!fileName) {
return;
}
try {
// Search for current relative path
const response = await api.fetchApi(`/${modelType}/relative-paths?search=${encodeURIComponent(fileName)}&limit=2`);
const data = await response.json();
if (!data.success || !data.relative_paths || data.relative_paths.length === 0) {
return;
}
const foundPaths = data.relative_paths;
const firstPath = foundPaths[0];
// Check if we need to update the path
if (firstPath !== currentPath) {
// Update the widget value
// node.widgets_values[widgetIndex] = firstPath;
node.widgets[widgetIndex].value = firstPath;
if (foundPaths.length === 1) {
// Single match found - success
showToast({
severity: 'info',
summary: 'LoRA Manager Path Correction',
detail: `Updated path for ${fileName}: ${firstPath}`,
life: 5000
});
} else {
// Multiple matches found - warning
showToast({
severity: 'warn',
summary: 'LoRA Manager Path Correction',
detail: `Multiple paths found for ${fileName}, using: ${firstPath}`,
life: 5000
});
}
// Mark node as modified
if (node.setDirtyCanvas) {
node.setDirtyCanvas(true);
}
}
} catch (error) {
console.error(`Error correcting path for ${fileName}:`, error);
}
} }
}); });

View File

@@ -23,6 +23,60 @@ export function getComfyUIFrontendVersion() {
return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0"; return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0";
} }
/**
* Show a toast notification
* @param {Object|string} options - Toast options object or message string for backward compatibility
* @param {string} [options.severity] - Message severity level (success, info, warn, error, secondary, contrast)
* @param {string} [options.summary] - Short title for the toast
* @param {any} [options.detail] - Detailed message content
* @param {boolean} [options.closable] - Whether user can close the toast (default: true)
* @param {number} [options.life] - Duration in milliseconds before auto-closing
* @param {string} [options.group] - Group identifier for managing related toasts
* @param {any} [options.styleClass] - Style class of the message
* @param {any} [options.contentStyleClass] - Style class of the content
* @param {string} [type] - Deprecated: severity type for backward compatibility
*/
export function showToast(options, type = 'info') {
// Handle backward compatibility: showToast(message, type)
if (typeof options === 'string') {
options = {
detail: options,
severity: type
};
}
// Set defaults
const toastOptions = {
severity: options.severity || 'info',
summary: options.summary,
detail: options.detail,
closable: options.closable !== false, // default to true
life: options.life,
group: options.group,
styleClass: options.styleClass,
contentStyleClass: options.contentStyleClass
};
// Remove undefined properties
Object.keys(toastOptions).forEach(key => {
if (toastOptions[key] === undefined) {
delete toastOptions[key];
}
});
if (app && app.extensionManager && app.extensionManager.toast) {
app.extensionManager.toast.add(toastOptions);
} else {
const message = toastOptions.detail || toastOptions.summary || 'No message';
const severity = toastOptions.severity.toUpperCase();
console.log(`${severity}: ${message}`);
// Fallback alert for critical errors only
if (toastOptions.severity === 'error') {
alert(message);
}
}
}
// Dynamically import the appropriate widget based on app version // Dynamically import the appropriate widget based on app version
export async function dynamicImportByVersion(latestModulePath, legacyModulePath) { export async function dynamicImportByVersion(latestModulePath, legacyModulePath) {
// Parse app version and compare with 1.12.6 (version when tags widget API changed) // Parse app version and compare with 1.12.6 (version when tags widget API changed)
@@ -69,21 +123,6 @@ export function hideWidgetForGood(node, widget, suffix = "") {
} }
} }
// Wrapper class to handle 'two element array bug' in LiteGraph or comfyui
export class DataWrapper {
constructor(data) {
this.data = data;
}
getData() {
return this.data;
}
setData(data) {
this.data = data;
}
}
// Function to get the appropriate loras widget based on ComfyUI version // Function to get the appropriate loras widget based on ComfyUI version
export async function getLorasWidgetModule() { export async function getLorasWidgetModule() {
return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js"); return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js");
@@ -208,6 +247,7 @@ export function mergeLoras(lorasText, lorasArr) {
name: lora.name, name: lora.name,
strength: lora.strength !== undefined ? lora.strength : parsedLoras[lora.name].strength, strength: lora.strength !== undefined ? lora.strength : parsedLoras[lora.name].strength,
active: lora.active !== undefined ? lora.active : true, active: lora.active !== undefined ? lora.active : true,
expanded: lora.expanded !== undefined ? lora.expanded : false,
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength, clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
}); });
usedNames.add(lora.name); usedNames.add(lora.name);

View File

@@ -1,107 +1,121 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { import {
LORA_PATTERN, LORA_PATTERN,
getActiveLorasFromNode, getActiveLorasFromNode,
updateConnectedTriggerWords, updateConnectedTriggerWords,
chainCallback, chainCallback,
mergeLoras, mergeLoras,
setupInputWidgetWithAutocomplete setupInputWidgetWithAutocomplete,
} from "./utils.js"; } from "./utils.js";
import { addLorasWidget } from "./loras_widget.js"; import { addLorasWidget } from "./loras_widget.js";
app.registerExtension({ app.registerExtension({
name: "LoraManager.WanVideoLoraSelect", name: "LoraManager.WanVideoLoraSelect",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "WanVideo Lora Select (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", async function() {
// Enable widget serialization
this.serialize_widgets = true;
// Add optional inputs async beforeRegisterNodeDef(nodeType, nodeData, app) {
this.addInput("prev_lora", 'WANVIDLORA', { if (nodeType.comfyClass === "WanVideo Lora Select (LoraManager)") {
"shape": 7 // 7 is the shape of the optional input chainCallback(nodeType.prototype, "onNodeCreated", async function () {
}); // Enable widget serialization
this.serialize_widgets = true;
this.addInput("blocks", 'SELECTEDBLOCKS', {
"shape": 7 // 7 is the shape of the optional input
});
// Restore saved value if exists // Add optional inputs
let existingLoras = []; this.addInput("prev_lora", "WANVIDLORA", {
if (this.widgets_values && this.widgets_values.length > 0) { shape: 7, // 7 is the shape of the optional input
// 0 for low_mem_load, 1 for text widget, 2 for loras widget });
const savedValue = this.widgets_values[2];
existingLoras = savedValue || [];
}
// Merge the loras data
const mergedLoras = mergeLoras(this.widgets[1].value, existingLoras);
// Add flag to prevent callback loops
let isUpdating = false;
const result = addLorasWidget(this, "loras", {
defaultVal: mergedLoras // Pass object directly
}, (value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = this.widgets[1];
const currentLoras = value.map(l => l.name);
// Use the constant pattern here as well
let newText = inputWidget.value.replace(LORA_PATTERN, (match, name, strength) => {
return currentLoras.includes(name) ? match : '';
});
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
newText = newText.replace(/\s+/g, " ").replace(/,\s*,+/g, ",").trim();
if (newText === ",") newText = "";
inputWidget.value = newText;
// Update this node's direct trigger toggles with its own active loras
const activeLoraNames = new Set();
value.forEach(lora => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
});
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
});
this.lorasWidget = result.widget;
// Update input widget callback this.addInput("blocks", "SELECTEDBLOCKS", {
const inputWidget = this.widgets[1]; shape: 7, // 7 is the shape of the optional input
inputWidget.options.getMaxHeight = () => 100; });
this.inputWidget = inputWidget;
// Wrap the callback with autocomplete setup // Add flag to prevent callback loops
const originalCallback = (value) => { let isUpdating = false;
if (isUpdating) return;
isUpdating = true; const result = addLorasWidget(this, "loras", {}, (value) => {
// Prevent recursive calls
try { if (isUpdating) return;
const currentLoras = this.lorasWidget.value || []; isUpdating = true;
const mergedLoras = mergeLoras(value, currentLoras);
try {
this.lorasWidget.value = mergedLoras; // Remove loras that are not in the value array
const inputWidget = this.widgets[2];
// Update this node's direct trigger toggles with its own active loras const currentLoras = value.map((l) => l.name);
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames); // Use the constant pattern here as well
} finally { let newText = inputWidget.value.replace(
isUpdating = false; LORA_PATTERN,
} (match, name, strength) => {
}; return currentLoras.includes(name) ? match : "";
inputWidget.callback = setupInputWidgetWithAutocomplete(this, inputWidget, originalCallback); }
);
// Clean up multiple spaces, extra commas, and trim; remove trailing comma if it's the only content
newText = newText
.replace(/\s+/g, " ")
.replace(/,\s*,+/g, ",")
.trim();
if (newText === ",") newText = "";
inputWidget.value = newText;
// Update this node's direct trigger toggles with its own active loras
const activeLoraNames = new Set();
value.forEach((lora) => {
if (lora.active) {
activeLoraNames.add(lora.name);
}
}); });
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
});
this.lorasWidget = result.widget;
// Update input widget callback
const inputWidget = this.widgets[2];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
// Wrap the callback with autocomplete setup
const originalCallback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.lorasWidget.value = mergedLoras;
// Update this node's direct trigger toggles with its own active loras
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames);
} finally {
isUpdating = false;
}
};
inputWidget.callback = setupInputWidgetWithAutocomplete(
this,
inputWidget,
originalCallback
);
});
}
},
async nodeCreated(node) {
if (node.comfyClass == "WanVideo Lora Select (LoraManager)") {
requestAnimationFrame(async () => {
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
// 0 for low_mem_load, 1 for merge_loras, 2 for text widget, 3 for loras widget
const savedValue = node.widgets_values[3];
existingLoras = savedValue || [];
} }
}, // Merge the loras data
const mergedLoras = mergeLoras(node.widgets[2].value, existingLoras);
node.lorasWidget.value = mergedLoras;
});
}
},
}); });