Compare commits

...

57 Commits

Author SHA1 Message Date
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
Will Miao
ca692ed0f2 feat: Update release notes and version to v0.8.28 with new features and enhancements 2025-08-18 07:14:08 +08:00
Will Miao
af499565d3 Revert "feat: Add CheckpointLoaderSimpleExtended to NODE_EXTRACTORS for enhanced checkpoint loading"
This reverts commit fe2d7e3a9e.
2025-08-17 22:43:15 +08:00
Will Miao
fe2d7e3a9e feat: Add CheckpointLoaderSimpleExtended to NODE_EXTRACTORS for enhanced checkpoint loading 2025-08-17 21:16:27 +08:00
Will Miao
9f69822221 feat: Refactor SamplerCustom handling and enhance node extractor mappings for improved metadata processing 2025-08-17 20:42:52 +08:00
Will Miao
bb43f047c2 feat: Add auto-organize progress tracking and WebSocket broadcasting in BaseModelRoutes and WebSocketManager 2025-08-16 21:11:33 +08:00
Will Miao
2356662492 fix: Improve author retrieval logic in DownloadManager to handle non-dictionary creator data 2025-08-16 21:10:57 +08:00
Will Miao
1624a45093 fix: Update author retrieval to handle missing username gracefully in DownloadManager and utils 2025-08-16 16:11:56 +08:00
Will Miao
dcb9983786 feat: Refactor duplicates management with user preference for notification visibility and modular banner component, fixes #359 2025-08-16 09:14:35 +08:00
Will Miao
83d1828905 feat: Enhance text cleanup in LoraLoader, LoraStacker, and WanVideoLoraSelect to handle extra commas and trailing commas 2025-08-16 08:31:04 +08:00
Will Miao
6a281cf3ee feat: Implement autocomplete feature with enhanced UI and tooltip support
- Added AutoComplete class to handle input suggestions based on user input.
- Integrated TextAreaCaretHelper for accurate positioning of the dropdown.
- Enhanced dropdown styling with a new color scheme and custom scrollbar.
- Implemented dynamic loading of preview tooltips for selected items.
- Added keyboard navigation support for dropdown items.
- Included functionality to insert selected items into the input field with usage tips.
- Created a separate TextAreaCaretHelper module for managing caret position calculations.
2025-08-16 07:53:55 +08:00
Will Miao
ed1cd39a6c feat: add model notes, preview URL, and Civitai URL endpoints to BaseModelRoutes and BaseModelService 2025-08-15 18:58:49 +08:00
Will Miao
dda19b3920 feat: add download example images functionality to context menus, see #347 2025-08-15 17:15:31 +08:00
Will Miao
25139ca922 feat: enhance bulk operations panel styling and update downloadExampleImages method to accept optional modelTypes parameter 2025-08-15 15:58:33 +08:00
Will Miao
3cd57a582c feat: add force download functionality for example images with progress tracking 2025-08-15 15:16:12 +08:00
Will Miao
d3903ac655 feat: add success toast notification after metadata update completion 2025-08-15 09:43:16 +08:00
Will Miao
199e374318 feat: update release notes for v0.8.27 and bump version to 0.8.27 2025-08-14 07:32:09 +08:00
pixelpaws
8375c1413d Merge pull request #354 from Clusters/main
feat: Add qwen-image as a selectable base model
2025-08-14 07:14:27 +08:00
Andreas
9e268cf016 Merge branch 'willmiao:main' into main 2025-08-13 17:51:10 +02:00
Andreas
112b3abc26 feat: add qwen-image base model to ModelMetadata 2025-08-13 15:49:30 +00:00
Andreas
a8331a2357 feat: add qwen-image to base model constants.js 2025-08-13 15:48:10 +00:00
Will Miao
52e3ad08c1 feat: add placeholder for empty folder tree in download modal 2025-08-13 23:45:37 +08:00
Will Miao
8d01d04ef0 feat: add default path toggle and update download modal for improved path selection 2025-08-13 23:30:48 +08:00
Will Miao
a141384907 feat: update default path customization image for improved clarity 2025-08-13 20:15:11 +08:00
Will Miao
b8aa7184bd feat: update download path template handling for model types and migrate old settings 2025-08-13 19:23:37 +08:00
Will Miao
e4195f874d feat: implement download path templates configuration with support for multiple model types and custom templates 2025-08-13 17:42:40 +08:00
Will Miao
d04deff5ca feat: enhance download and move modals with improved folder path input, autocomplete, and folder tree integration 2025-08-13 14:41:21 +08:00
Will Miao
20ce0778a0 fix: correct default root key generation by using singular model type 2025-08-13 11:06:39 +08:00
Will Miao
5a0b3470f1 feat: enhance auto-organize functionality with empty directory cleanup and progress reporting 2025-08-13 10:36:31 +08:00
Will Miao
a920921570 feat: implement auto-organize models endpoint with batch processing and error handling 2025-08-12 22:39:40 +08:00
Will Miao
286f4ff384 feat: add folder tree and unified folder tree endpoints, enhance download modal with folder path input and tree navigation 2025-08-12 22:34:53 +08:00
Will Miao
71ddfafa98 refactor: move download modal styles to a dedicated file and update import path 2025-08-12 21:40:43 +08:00
Will Miao
b7e3e53697 feat: implement version mismatch handling and banner registration in UpdateService 2025-08-12 15:09:45 +08:00
Will Miao
16df548b77 fix: expand supported file extensions in CheckpointScanner initialization, fixes #353 2025-08-12 09:20:08 +08:00
Will Miao
425c33ae00 fix: update model identifier handling in RecipeModal and DownloadManager for consistency 2025-08-11 17:13:42 +08:00
Will Miao
c9289ed2dc fix: improve duplicate filename handling and logging in ModelScanner and ModelHashIndex 2025-08-11 17:13:21 +08:00
Will Miao
96517cbdef fix: update model_id and model_version_id handling across various services for improved flexibility 2025-08-11 15:31:49 +08:00
Will Miao
b03420faac fix: skip LoRAs without proper identification in Civitai metadata parser 2025-08-11 11:14:45 +08:00
Will Miao
65a1aa7ca2 fix: add missing embeddings folder paths in settings example 2025-08-11 07:05:58 +08:00
pixelpaws
3a92e8eaf9 Update README.md 2025-08-10 16:11:28 +08:00
Will Miao
a8dc50d64a fix: update portable package link to version 0.8.26 in README 2025-08-10 16:05:50 +08:00
Will Miao
3397cc7d8d fix: update screenshot image to reflect latest UI changes 2025-08-10 09:02:46 +08:00
Will Miao
c3e8131b24 feat: enhance download manager to track failed models and update progress reporting 2025-08-10 08:07:52 +08:00
Will Miao
f8ca8584ae feat: enhance URL safety in path mapping by encoding special characters 2025-08-09 16:25:55 +08:00
Will Miao
3050bbe260 fix: improve image handling logic to ensure input is always a list or array, see #346 2025-08-09 07:20:28 +08:00
Will Miao
e1dda2795a update README.md 2025-08-08 20:13:20 +08:00
75 changed files with 6032 additions and 1499 deletions

View File

@@ -34,13 +34,28 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes
### 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
* **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.
* **Download Example Images from Context Menu** - Introduced a new context menu option to download example images for individual models.
### v0.8.27
* **User Experience Enhancements** - Improved the model download target folder selection with path input autocomplete and interactive folder tree navigation, making it easier and faster to choose where models are saved.
* **Default Path Option for Downloads** - Added a "Use Default Path" option when downloading models. When enabled, models are automatically organized and stored according to your configured path template settings.
* **Advanced Download Path Templates** - Expanded path template settings, allowing users to set individual templates for LoRA, checkpoint, and embedding models for greater flexibility. Introduced the `{author}` placeholder, enabling automatic organization of model files by creator name.
* **Bug Fixes & Stability Improvements** - Addressed various bugs and improved overall stability for a smoother experience.
### v0.8.26
* **Creator Search Option**
- Added ability to search models by creator name, making it easier to find models from specific authors.
* **Enhanced Node Usability**
- Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
* **Compatibility Fixes**
- Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
* **Creator Search Option** - Added ability to search models by creator name, making it easier to find models from specific authors.
* **Enhanced Node Usability** - Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
* **Compatibility Fixes** - Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
### v0.8.25
* **LoRA List Reordering**
@@ -154,10 +169,11 @@ Enhance your Civitai browsing experience with our companion browser extension! S
### Option 2: **Portable Standalone Edition** (No ComfyUI required)
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.15/lora_manager_portable.7z)
1. Download the [Portable Package](https://github.com/willmiao/ComfyUI-Lora-Manager/releases/download/v0.8.26/lora_manager_portable.7z)
2. Copy the provided `settings.json.example` file to create a new file named `settings.json` in `comfyui-lora-manager` folder
3. Edit `settings.json` to include your correct model folder paths and CivitAI API key
4. Run run.bat
- To change the startup port, edit `run.bat` and modify the parameter (e.g. `--port 9001`)
### Option 3: **Manual Installation**
@@ -287,3 +303,6 @@ Join our Discord community for support, discussions, and updates:
[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.nodes.lora_loader import LoraManagerLoader
from .py.nodes.lora_loader import LoraManagerLoader, LoraManagerTextLoader
from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.lora_stacker import LoraStacker
from .py.nodes.save_image import SaveImage
@@ -10,6 +10,7 @@ from .py.metadata_collector import init as init_metadata_collector
NODE_CLASS_MAPPINGS = {
LoraManagerLoader.NAME: LoraManagerLoader,
LoraManagerTextLoader.NAME: LoraManagerTextLoader,
TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker,
SaveImage.NAME: SaveImage,

View File

@@ -5,6 +5,7 @@ from typing import List
import logging
import sys
import json
import urllib.parse
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
@@ -275,8 +276,10 @@ class Config:
for path, route in self._route_mappings.items():
if real_path.startswith(path):
relative_path = os.path.relpath(real_path, path)
return f'{route}/{relative_path.replace(os.sep, "/")}'
relative_path = os.path.relpath(real_path, path).replace(os.sep, '/')
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')]
safe_path = '/'.join(safe_parts)
return f'{route}/{safe_path}'
return ""

View File

@@ -339,44 +339,8 @@ class MetadataProcessor:
is_custom_advanced = prompt.original_prompt[primary_sampler_id].get("class_type") == "SamplerCustomAdvanced"
if is_custom_advanced:
# For SamplerCustomAdvanced, trace specific inputs
# 1. Trace sigmas input to find BasicScheduler
scheduler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sigmas", "BasicScheduler", max_depth=5)
if scheduler_node_id and scheduler_node_id in metadata.get(SAMPLING, {}):
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
params["steps"] = scheduler_params.get("steps")
params["scheduler"] = scheduler_params.get("scheduler")
# 2. Trace sampler input to find KSamplerSelect
sampler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sampler", "KSamplerSelect", max_depth=5)
if sampler_node_id and sampler_node_id in metadata.get(SAMPLING, {}):
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
params["sampler"] = sampler_params.get("sampler_name")
# 3. Trace guider input for CFGGuider and CLIPTextEncode
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
if guider_node_id and guider_node_id in prompt.original_prompt:
# Check if the guider node is a CFGGuider
if prompt.original_prompt[guider_node_id].get("class_type") == "CFGGuider":
# Extract cfg value from the CFGGuider
if guider_node_id in metadata.get(SAMPLING, {}):
cfg_params = metadata[SAMPLING][guider_node_id].get("parameters", {})
params["cfg_scale"] = cfg_params.get("cfg")
# Find CLIPTextEncode for positive prompt
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
# Find CLIPTextEncode for negative prompt
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", "CLIPTextEncode", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
else:
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
# For SamplerCustomAdvanced, use the new handler method
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
else:
# For standard samplers, match conditioning objects to prompts
@@ -401,6 +365,9 @@ class MetadataProcessor:
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
# For SamplerCustom, handle any additional parameters
MetadataProcessor.handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params)
# Size extraction is same for all sampler types
# Check if the sampler itself has size information (from latent_image)
@@ -454,3 +421,59 @@ class MetadataProcessor:
"""Convert metadata to JSON string"""
params = MetadataProcessor.to_dict(metadata, id)
return json.dumps(params, indent=4)
@staticmethod
def handle_custom_advanced_sampler(metadata, prompt, primary_sampler_id, params):
"""
Handle parameter extraction for SamplerCustomAdvanced nodes
Parameters:
- metadata: The workflow metadata
- prompt: The prompt object containing node connections
- primary_sampler_id: ID of the SamplerCustomAdvanced node
- params: Parameters dictionary to update
"""
if not prompt.original_prompt or primary_sampler_id not in prompt.original_prompt:
return
sampler_inputs = prompt.original_prompt[primary_sampler_id].get("inputs", {})
# 1. Trace sigmas input to find BasicScheduler (only if sigmas input exists)
if "sigmas" in sampler_inputs:
scheduler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sigmas", None, max_depth=5)
if scheduler_node_id and scheduler_node_id in metadata.get(SAMPLING, {}):
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
params["steps"] = scheduler_params.get("steps")
params["scheduler"] = scheduler_params.get("scheduler")
# 2. Trace sampler input to find KSamplerSelect (only if sampler input exists)
if "sampler" in sampler_inputs:
sampler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sampler", "KSamplerSelect", max_depth=5)
if sampler_node_id and sampler_node_id in metadata.get(SAMPLING, {}):
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
params["sampler"] = sampler_params.get("sampler_name")
# 3. Trace guider input for CFGGuider and CLIPTextEncode
if "guider" in sampler_inputs:
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
if guider_node_id and guider_node_id in prompt.original_prompt:
# Check if the guider node is a CFGGuider
if prompt.original_prompt[guider_node_id].get("class_type") == "CFGGuider":
# Extract cfg value from the CFGGuider
if guider_node_id in metadata.get(SAMPLING, {}):
cfg_params = metadata[SAMPLING][guider_node_id].get("parameters", {})
params["cfg_scale"] = cfg_params.get("cfg")
# Find CLIPTextEncode for positive prompt
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "positive", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
# Find CLIPTextEncode for negative prompt
negative_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "negative", "CLIPTextEncode", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
else:
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")

View File

@@ -642,6 +642,7 @@ NODE_EXTRACTORS = {
# Sampling
"KSampler": SamplerExtractor,
"KSamplerAdvanced": KSamplerAdvancedExtractor,
"SamplerCustom": KSamplerAdvancedExtractor,
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
@@ -652,9 +653,11 @@ NODE_EXTRACTORS = {
# Sampling Selectors
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
# Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor

View File

@@ -1,4 +1,5 @@
import logging
import re
from nodes import LoraLoader
from comfy.comfy_types import IO # type: ignore
from ..utils.utils import get_lora_info
@@ -17,7 +18,8 @@ class LoraManagerLoader:
"model": ("MODEL",),
# "clip": ("CLIP",),
"text": (IO.STRING, {
"multiline": True,
"multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
"placeholder": "LoRA syntax input: <lora:name:strength>"
@@ -128,4 +130,142 @@ class LoraManagerLoader:
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)

View File

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

View File

@@ -418,11 +418,15 @@ class SaveImage:
# Make sure the output directory exists
os.makedirs(self.output_dir, exist_ok=True)
# Ensure images is always a list of images
if len(images.shape) == 3: # Single image (height, width, channels)
images = [images]
else: # Multiple images (batch, height, width, channels)
images = [img for img in images]
# If images is already a list or array of images, do nothing; otherwise, convert to list
if isinstance(images, (list, np.ndarray)):
pass
else:
# Ensure images is always a list of images
if len(images.shape) == 3: # Single image (height, width, channels)
images = [images]
else: # Multiple images (batch, height, width, channels)
images = [img for img in images]
# Save all images
results = self.save_images(

View File

@@ -14,9 +14,11 @@ class WanVideoLoraSelect:
def INPUT_TYPES(cls):
return {
"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, {
"multiline": True,
"pysssss.autocomplete": False,
"dynamicPrompts": True,
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
"placeholder": "LoRA syntax input: <lora:name:strength>"
@@ -29,7 +31,7 @@ class WanVideoLoraSelect:
RETURN_NAMES = ("lora", "trigger_words", "active_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 = []
all_trigger_words = []
active_loras = []
@@ -38,6 +40,9 @@ class WanVideoLoraSelect:
prev_lora = kwargs.get('prev_lora', None)
if prev_lora is not None:
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
blocks = kwargs.get('blocks', {})
@@ -65,6 +70,7 @@ class WanVideoLoraSelect:
"blocks": selected_blocks,
"layer_filter": layer_filter,
"low_mem_load": low_mem_load,
"merge_loras": merge_loras,
}
# Add to list and collect active loras

View File

@@ -101,6 +101,11 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource.get("type", "lora") == "lora":
lora_hash = resource.get("hash", "")
# Skip LoRAs without proper identification (hash or modelVersionId)
if not lora_hash and not resource.get("modelVersionId"):
logger.debug(f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId")
continue
# Skip if we've already added this LoRA by hash
if lora_hash and lora_hash in added_loras:
continue

View File

@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
import asyncio
import os
import json
import logging
from aiohttp import web
@@ -10,6 +11,8 @@ import jinja2
from ..utils.routes_common import ModelRouteUtils
from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings
from ..utils.utils import calculate_relative_path_for_model
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..config import config
logger = logging.getLogger(__name__)
@@ -50,6 +53,8 @@ class BaseModelRoutes(ABC):
app.router.add_post(f'/api/{prefix}/verify-duplicates', self.verify_duplicates)
app.router.add_post(f'/api/{prefix}/move_model', self.move_model)
app.router.add_post(f'/api/{prefix}/move_models_bulk', self.move_models_bulk)
app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
app.router.add_get(f'/api/{prefix}/auto-organize-progress', self.get_auto_organize_progress)
# Common query routes
app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
@@ -57,8 +62,16 @@ class BaseModelRoutes(ABC):
app.router.add_get(f'/api/{prefix}/scan', self.scan_models)
app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots)
app.router.add_get(f'/api/{prefix}/folders', self.get_folders)
app.router.add_get(f'/api/{prefix}/folder-tree', self.get_folder_tree)
app.router.add_get(f'/api/{prefix}/unified-folder-tree', self.get_unified_folder_tree)
app.router.add_get(f'/api/{prefix}/find-duplicates', self.find_duplicate_models)
app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts)
app.router.add_get(f'/api/{prefix}/get-notes', self.get_model_notes)
app.router.add_get(f'/api/{prefix}/preview-url', self.get_model_preview_url)
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_model_civitai_url)
# Autocomplete route
app.router.add_get(f'/api/{prefix}/relative-paths', self.get_relative_paths)
# Common Download management
app.router.add_post(f'/api/download-model', self.download_model)
@@ -346,6 +359,43 @@ class BaseModelRoutes(ABC):
'error': str(e)
}, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response:
"""Get hierarchical folder tree structure for download modal"""
try:
model_root = request.query.get('model_root')
if not model_root:
return web.json_response({
'success': False,
'error': 'model_root parameter is required'
}, status=400)
folder_tree = await self.service.get_folder_tree(model_root)
return web.json_response({
'success': True,
'tree': folder_tree
})
except Exception as e:
logger.error(f"Error getting folder tree: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
"""Get unified folder tree across all model roots"""
try:
unified_tree = await self.service.get_unified_folder_tree()
return web.json_response({
'success': True,
'tree': unified_tree
})
except Exception as e:
logger.error(f"Error getting unified folder tree: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def find_duplicate_models(self, request: web.Request) -> web.Response:
"""Find models with duplicate SHA256 hashes"""
try:
@@ -696,4 +746,404 @@ class BaseModelRoutes(ABC):
})
except Exception as e:
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
return web.Response(text=str(e), status=500)
async def auto_organize_models(self, request: web.Request) -> web.Response:
"""Auto-organize all models based on current settings"""
try:
# Check if auto-organize is already running
if ws_manager.is_auto_organize_running():
return web.json_response({
'success': False,
'error': 'Auto-organize is already running. Please wait for it to complete.'
}, status=409)
# Acquire lock to prevent concurrent auto-organize operations
auto_organize_lock = await ws_manager.get_auto_organize_lock()
if auto_organize_lock.locked():
return web.json_response({
'success': False,
'error': 'Auto-organize is already running. Please wait for it to complete.'
}, status=409)
async with auto_organize_lock:
return await self._perform_auto_organize()
except Exception as e:
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
# Send error message via WebSocket and cleanup
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'error',
'error': str(e)
})
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def _perform_auto_organize(self) -> web.Response:
"""Perform the actual auto-organize operation"""
try:
# Get all models from cache
cache = await self.service.scanner.get_cached_data()
all_models = cache.raw_data
# Get model roots for this scanner
model_roots = self.service.get_model_roots()
if not model_roots:
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'error',
'error': 'No model roots configured'
})
return web.json_response({
'success': False,
'error': 'No model roots configured'
}, status=400)
# Check if flat structure is configured for this model type
path_template = settings.get_download_path_template(self.service.model_type)
is_flat_structure = not path_template
# Prepare results tracking
results = []
total_models = len(all_models)
processed = 0
success_count = 0
failure_count = 0
skipped_count = 0
# Send initial progress via WebSocket
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'started',
'total': total_models,
'processed': 0,
'success': 0,
'failures': 0,
'skipped': 0
})
# Process models in batches
for i in range(0, total_models, AUTO_ORGANIZE_BATCH_SIZE):
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch:
try:
file_path = model.get('file_path')
if not file_path:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "No file path found"
})
failure_count += 1
processed += 1
continue
# Find which model root this file belongs to
current_root = None
for root in model_roots:
# Normalize paths for comparison
normalized_root = os.path.normpath(root).replace(os.sep, '/')
normalized_file = os.path.normpath(file_path).replace(os.sep, '/')
if normalized_file.startswith(normalized_root):
current_root = root
break
if not current_root:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Model file not found in any configured root directory"
})
failure_count += 1
processed += 1
continue
# Handle flat structure case
if is_flat_structure:
current_dir = os.path.dirname(file_path)
# Check if already in root directory
if os.path.normpath(current_dir) == os.path.normpath(current_root):
skipped_count += 1
processed += 1
continue
# Move to root directory for flat structure
target_dir = current_root
else:
# Calculate new relative path based on settings
new_relative_path = calculate_relative_path_for_model(model, self.service.model_type)
# If no relative path calculated (insufficient metadata), skip
if not new_relative_path:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Skipped - insufficient metadata for organization"
})
skipped_count += 1
processed += 1
continue
# Calculate target directory
target_dir = os.path.join(current_root, new_relative_path).replace(os.sep, '/')
current_dir = os.path.dirname(file_path)
# Skip if already in correct location
if current_dir.replace(os.sep, '/') == target_dir.replace(os.sep, '/'):
skipped_count += 1
processed += 1
continue
# Check if target file would conflict
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_dir, file_name)
if os.path.exists(target_file_path):
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": f"Target file already exists: {target_file_path}"
})
failure_count += 1
processed += 1
continue
# Perform the move
success = await self.service.scanner.move_model(file_path, target_dir)
if success:
success_count += 1
else:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Failed to move model"
})
failure_count += 1
processed += 1
except Exception as e:
logger.error(f"Error processing model {model.get('model_name', 'Unknown')}: {e}", exc_info=True)
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": f"Error: {str(e)}"
})
failure_count += 1
processed += 1
# Send progress update after each batch
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'processing',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count
})
# Small delay between batches to prevent overwhelming the system
await asyncio.sleep(0.1)
# Send completion message
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'cleaning',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'message': 'Cleaning up empty directories...'
})
# Clean up empty directories after organizing
from ..utils.utils import remove_empty_dirs
cleanup_counts = {}
for root in model_roots:
removed = remove_empty_dirs(root)
cleanup_counts[root] = removed
# Send cleanup completed message
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'completed',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'cleanup': cleanup_counts
})
# Prepare response with limited details
response_data = {
'success': True,
'message': f'Auto-organize completed: {success_count} moved, {skipped_count} skipped, {failure_count} failed out of {total_models} total',
'summary': {
'total': total_models,
'success': success_count,
'skipped': skipped_count,
'failures': failure_count,
'organization_type': 'flat' if is_flat_structure else 'structured',
'cleaned_dirs': cleanup_counts
}
}
# Only include detailed results if under limit
if len(results) <= 100:
response_data['results'] = results
else:
response_data['results_truncated'] = True
response_data['sample_results'] = results[:50] # Show first 50 as sample
return web.json_response(response_data)
except Exception as e:
logger.error(f"Error in _perform_auto_organize: {e}", exc_info=True)
# Send error message via WebSocket
await ws_manager.broadcast_auto_organize_progress({
'type': 'auto_organize_progress',
'status': 'error',
'error': str(e)
})
raise e
async def get_auto_organize_progress(self, request: web.Request) -> web.Response:
"""Get current auto-organize progress for polling"""
try:
progress_data = ws_manager.get_auto_organize_progress()
if progress_data is None:
return web.json_response({
'success': False,
'error': 'No auto-organize operation in progress'
}, status=404)
return web.json_response({
'success': True,
'progress': progress_data
})
except Exception as e:
logger.error(f"Error getting auto-organize progress: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_model_notes(self, request: web.Request) -> web.Response:
"""Get notes for a specific model file"""
try:
model_name = request.query.get('name')
if not model_name:
return web.Response(text=f'{self.model_type.capitalize()} file name is required', status=400)
notes = await self.service.get_model_notes(model_name)
if notes is not None:
return web.json_response({
'success': True,
'notes': notes
})
else:
return web.json_response({
'success': False,
'error': f'{self.model_type.capitalize()} not found in cache'
}, status=404)
except Exception as e:
logger.error(f"Error getting {self.model_type} notes: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_model_preview_url(self, request: web.Request) -> web.Response:
"""Get the static preview URL for a model file"""
try:
model_name = request.query.get('name')
if not model_name:
return web.Response(text=f'{self.model_type.capitalize()} file name is required', status=400)
preview_url = await self.service.get_model_preview_url(model_name)
if preview_url:
return web.json_response({
'success': True,
'preview_url': preview_url
})
else:
return web.json_response({
'success': False,
'error': f'No preview URL found for the specified {self.model_type}'
}, status=404)
except Exception as e:
logger.error(f"Error getting {self.model_type} preview URL: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_model_civitai_url(self, request: web.Request) -> web.Response:
"""Get the Civitai URL for a model file"""
try:
model_name = request.query.get('name')
if not model_name:
return web.Response(text=f'{self.model_type.capitalize()} file name is required', status=400)
result = await self.service.get_model_civitai_url(model_name)
if result['civitai_url']:
return web.json_response({
'success': True,
**result
})
else:
return web.json_response({
'success': False,
'error': f'No Civitai data found for the specified {self.model_type}'
}, status=404)
except Exception as e:
logger.error(f"Error getting {self.model_type} Civitai URL: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_relative_paths(self, request: web.Request) -> web.Response:
"""Get model relative file paths for autocomplete functionality"""
try:
search = request.query.get('search', '').strip()
limit = min(int(request.query.get('limit', '15')), 50) # Max 50 items
matching_paths = await self.service.search_relative_paths(search, limit)
return web.json_response({
'success': True,
'relative_paths': matching_paths
})
except Exception as e:
logger.error(f"Error getting relative paths for autocomplete: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -2,6 +2,7 @@ import logging
from ..utils.example_images_download_manager import DownloadManager
from ..utils.example_images_processor import ExampleImagesProcessor
from ..utils.example_images_file_manager import ExampleImagesFileManager
from ..services.websocket_manager import ws_manager
logger = logging.getLogger(__name__)
@@ -20,6 +21,7 @@ class ExampleImagesRoutes:
app.router.add_get('/api/example-image-files', ExampleImagesRoutes.get_example_image_files)
app.router.add_get('/api/has-example-images', ExampleImagesRoutes.has_example_images)
app.router.add_post('/api/delete-example-image', ExampleImagesRoutes.delete_example_image)
app.router.add_post('/api/force-download-example-images', ExampleImagesRoutes.force_download_example_images)
@staticmethod
async def download_example_images(request):
@@ -64,4 +66,9 @@ class ExampleImagesRoutes:
@staticmethod
async def delete_example_image(request):
"""Delete a custom example image for a model"""
return await ExampleImagesProcessor.delete_custom_image(request)
return await ExampleImagesProcessor.delete_custom_image(request)
@staticmethod
async def force_download_example_images(request):
"""Force download example images for specific models"""
return await DownloadManager.start_force_download(request)

View File

@@ -43,11 +43,9 @@ class LoraRoutes(BaseModelRoutes):
"""Setup LoRA-specific routes"""
# LoRA-specific query routes
app.router.add_get(f'/api/{prefix}/letter-counts', self.get_letter_counts)
app.router.add_get(f'/api/{prefix}/get-notes', self.get_lora_notes)
app.router.add_get(f'/api/{prefix}/get-trigger-words', self.get_lora_trigger_words)
app.router.add_get(f'/api/{prefix}/preview-url', self.get_lora_preview_url)
app.router.add_get(f'/api/{prefix}/civitai-url', self.get_lora_civitai_url)
app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description)
app.router.add_get(f'/api/{prefix}/usage-tips-by-path', self.get_lora_usage_tips_by_path)
# CivitAI integration with LoRA-specific validation
app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora)
@@ -143,6 +141,26 @@ class LoraRoutes(BaseModelRoutes):
'error': str(e)
}, status=500)
async def get_lora_usage_tips_by_path(self, request: web.Request) -> web.Response:
"""Get usage tips for a LoRA by its relative path"""
try:
relative_path = request.query.get('relative_path')
if not relative_path:
return web.Response(text='Relative path is required', status=400)
usage_tips = await self.service.get_lora_usage_tips_by_relative_path(relative_path)
return web.json_response({
'success': True,
'usage_tips': usage_tips or ''
})
except Exception as e:
logger.error(f"Error getting lora usage tips by path: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_lora_preview_url(self, request: web.Request) -> web.Response:
"""Get the static preview URL for a LoRA file"""
try:

View File

@@ -1,4 +1,3 @@
import json
import logging
import os
import sys
@@ -183,16 +182,6 @@ class MiscRoutes:
if old_path != value:
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' 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
settings.set(key, value)
@@ -654,13 +643,13 @@ class MiscRoutes:
exists = False
model_type = None
if await lora_scanner.check_model_version_exists(model_id, model_version_id):
if await lora_scanner.check_model_version_exists(model_version_id):
exists = True
model_type = 'lora'
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_id, model_version_id):
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_version_id):
exists = True
model_type = 'checkpoint'
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_id, model_version_id):
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_version_id):
exists = True
model_type = 'embedding'

View File

@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from typing import Dict, List, Optional, Type
import logging
import os
from ..utils.models import BaseModelMetadata
from ..utils.constants import NSFW_LEVELS
@@ -272,4 +273,150 @@ class BaseModelService(ABC):
def get_model_roots(self) -> List[str]:
"""Get model root directories"""
return self.scanner.get_model_roots()
return self.scanner.get_model_roots()
async def get_folder_tree(self, model_root: str) -> Dict:
"""Get hierarchical folder tree for a specific model root"""
cache = await self.scanner.get_cached_data()
# Build tree structure from folders
tree = {}
for folder in cache.folders:
# Check if this folder belongs to the specified model root
folder_belongs_to_root = False
for root in self.scanner.get_model_roots():
if root == model_root:
folder_belongs_to_root = True
break
if not folder_belongs_to_root:
continue
# Split folder path into components
parts = folder.split('/') if folder else []
current_level = tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return tree
async def get_unified_folder_tree(self) -> Dict:
"""Get unified folder tree across all model roots"""
cache = await self.scanner.get_cached_data()
# Build unified tree structure by analyzing all relative paths
unified_tree = {}
# Get all model roots for path normalization
model_roots = self.scanner.get_model_roots()
for folder in cache.folders:
if not folder: # Skip empty folders
continue
# Find which root this folder belongs to by checking the actual file paths
# This is a simplified approach - we'll use the folder as-is since it should already be relative
relative_path = folder
# Split folder path into components
parts = relative_path.split('/')
current_level = unified_tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return unified_tree
async def get_model_notes(self, model_name: str) -> Optional[str]:
"""Get notes for a specific model file"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model['file_name'] == model_name:
return model.get('notes', '')
return None
async def get_model_preview_url(self, model_name: str) -> Optional[str]:
"""Get the static preview URL for a model file"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model['file_name'] == model_name:
preview_url = model.get('preview_url')
if preview_url:
from ..config import config
return config.get_preview_static_url(preview_url)
return None
async def get_model_civitai_url(self, model_name: str) -> Dict[str, Optional[str]]:
"""Get the Civitai URL for a model file"""
cache = await self.scanner.get_cached_data()
for model in cache.raw_data:
if model['file_name'] == model_name:
civitai_data = model.get('civitai', {})
model_id = civitai_data.get('modelId')
version_id = civitai_data.get('id')
if model_id:
civitai_url = f"https://civitai.com/models/{model_id}"
if version_id:
civitai_url += f"?modelVersionId={version_id}"
return {
'civitai_url': civitai_url,
'model_id': str(model_id),
'version_id': str(version_id) if version_id else None
}
return {'civitai_url': None, 'model_id': None, 'version_id': None}
async def search_relative_paths(self, search_term: str, limit: int = 15) -> List[str]:
"""Search model relative file paths for autocomplete functionality"""
cache = await self.scanner.get_cached_data()
matching_paths = []
search_lower = search_term.lower()
# Get model roots for path calculation
model_roots = self.scanner.get_model_roots()
for model in cache.raw_data:
file_path = model.get('file_path', '')
if not file_path:
continue
# Calculate relative path from model root
relative_path = None
for root in model_roots:
# Normalize paths for comparison
normalized_root = os.path.normpath(root).replace(os.sep, '/')
normalized_file = os.path.normpath(file_path).replace(os.sep, '/')
if normalized_file.startswith(normalized_root):
# Remove root and leading slash to get relative path
relative_path = normalized_file[len(normalized_root):].lstrip('/')
break
if relative_path and search_lower in relative_path.lower():
matching_paths.append(relative_path)
if len(matching_paths) >= limit * 2: # Get more for better sorting
break
# Sort by relevance (exact matches first, then by length)
matching_paths.sort(key=lambda x: (
not x.lower().startswith(search_lower), # Exact prefix matches first
len(x), # Then by length (shorter first)
x.lower() # Then alphabetically
))
return matching_paths[:limit]

View File

@@ -13,7 +13,7 @@ class CheckpointScanner(ModelScanner):
def __init__(self):
# Define supported file extensions
file_extensions = {'.safetensors', '.ckpt', '.pt', '.pth', '.sft', '.gguf'}
file_extensions = {'.ckpt', '.pt', '.pt2', '.bin', '.pth', '.safetensors', '.pkl', '.sft', '.gguf'}
super().__init__(
model_type="checkpoint",
model_class=CheckpointMetadata,

View File

@@ -33,8 +33,8 @@ class CivitaiClient:
}
self._session = None
self._session_created_at = None
# Set default buffer size to 1MB for higher throughput
self.chunk_size = 1024 * 1024
# Adjust chunk size based on storage type - consider making this configurable
self.chunk_size = 4 * 1024 * 1024 # 4MB chunks for better HDD throughput
@property
async def session(self) -> aiohttp.ClientSession:
@@ -49,8 +49,8 @@ class CivitaiClient:
enable_cleanup_closed=True
)
trust_env = True # Allow using system environment proxy settings
# Configure timeout parameters - increase read timeout for large files
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=120)
# Configure timeout parameters - increase read timeout for large files and remove sock_read timeout
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=None)
self._session = aiohttp.ClientSession(
connector=connector,
trust_env=trust_env,
@@ -102,7 +102,7 @@ class CivitaiClient:
return headers
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:
url: Download URL
@@ -113,73 +113,190 @@ class CivitaiClient:
Returns:
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()
try:
headers = self._get_request_headers()
# Add Range header to allow resumable downloads
headers['Accept-Encoding'] = 'identity' # Disable compression for better chunked downloads
logger.debug(f"Starting download from: {url}")
async with session.get(url, headers=headers, allow_redirects=True) as response:
if response.status != 200:
# Handle 401 unauthorized responses
if response.status == 401:
save_path = os.path.join(save_dir, default_filename)
part_path = save_path + '.part'
# Get existing file size for resume
resume_offset = 0
if os.path.exists(part_path):
resume_offset = os.path.getsize(part_path)
logger.info(f"Resuming download from offset {resume_offset} bytes")
total_size = 0
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)")
return False, "Invalid or missing CivitAI API key, or early access restriction."
# Handle other client errors that might be permission-related
if response.status == 403:
elif response.status == 403:
logger.warning(f"Forbidden access to resource: {url} (Status 403)")
return False, "Access forbidden: You don't have permission to download this file."
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
logger.error(f"Download failed for {url} with status {response.status}")
return False, f"Download failed with status {response.status}"
# Get total file size for progress calculation (if not set from Content-Range)
if total_size == 0:
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
content_disposition = response.headers.get('Content-Disposition')
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()
current_size = resume_offset
last_progress_report_time = datetime.now()
# Stream download to file with progress updates using larger buffer
with open(save_path, 'wb') as f:
async for chunk in response.content.iter_chunked(self.chunk_size):
if chunk:
f.write(chunk)
current_size += len(chunk)
# Limit progress update frequency to reduce overhead
now = datetime.now()
time_diff = (now - last_progress_report_time).total_seconds()
if progress_callback and total_size and time_diff >= 1.0:
progress = (current_size / total_size) * 100
await progress_callback(progress)
last_progress_report_time = now
# Ensure 100% progress is reported
if progress_callback:
await progress_callback(100)
# Stream download to file with progress updates using larger buffer
loop = asyncio.get_running_loop()
mode = 'ab' if resume_offset > 0 else 'wb'
with open(part_path, mode) as f:
async for chunk in response.content.iter_chunked(self.chunk_size):
if chunk:
# Run blocking file write in executor
await loop.run_in_executor(None, f.write, chunk)
current_size += len(chunk)
# Limit progress update frequency to reduce overhead
now = datetime.now()
time_diff = (now - last_progress_report_time).total_seconds()
if progress_callback and total_size and time_diff >= 1.0:
progress = (current_size / total_size) * 100
await progress_callback(progress)
last_progress_report_time = now
# 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:
logger.error(f"Network error during download: {e}")
return False, f"Network error: {str(e)}"
except Exception as e:
logger.error(f"Download error: {e}")
return False, str(e)
if retry_count <= max_retries:
# Calculate delay with exponential backoff
delay = base_delay * (2 ** (retry_count - 1))
logger.info(f"Retrying in {delay} seconds...")
await asyncio.sleep(delay)
# 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]:
try:
@@ -223,11 +340,11 @@ class CivitaiClient:
logger.error(f"Error fetching model versions: {e}")
return None
async def get_model_version(self, model_id: int, version_id: int = None) -> Optional[Dict]:
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]:
"""Get specific model version with additional metadata
Args:
model_id: The Civitai model ID
model_id: The Civitai model ID (optional if version_id is provided)
version_id: Optional specific version ID to retrieve
Returns:
@@ -235,37 +352,72 @@ class CivitaiClient:
"""
try:
session = await self._ensure_fresh_session()
# Step 1: Get model data to find version_id if not provided and get additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
model_versions = data.get('modelVersions', [])
# Step 2: Determine the version_id to use
target_version_id = version_id
if target_version_id is None:
target_version_id = model_versions[0].get('id')
# Step 3: Get detailed version info using the version_id
headers = self._get_request_headers()
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
if response.status != 200:
return None
# Case 1: Only version_id is provided
if model_id is None and version_id is not None:
# First get the version info to extract model_id
async with session.get(f"{self.base_url}/model-versions/{version_id}", headers=headers) as response:
if response.status != 200:
return None
version = await response.json()
model_id = version.get('modelId')
if not model_id:
logger.error(f"No modelId found in version {version_id}")
return None
version = await response.json()
# Now get the model data for additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return version # Return version without additional metadata
model_data = await response.json()
# Enrich version with model data
version['model']['description'] = model_data.get("description")
version['model']['tags'] = model_data.get("tags", [])
version['creator'] = model_data.get("creator")
return version
# Case 2: model_id is provided (with or without version_id)
elif model_id is not None:
# Step 1: Get model data to find version_id if not provided and get additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
model_versions = data.get('modelVersions', [])
# Step 2: Determine the version_id to use
target_version_id = version_id
if target_version_id is None:
target_version_id = model_versions[0].get('id')
# Step 4: Enrich version_info with model data
# Add description and tags from model data
version['model']['description'] = data.get("description")
version['model']['tags'] = data.get("tags", [])
# Add creator from model data
version['creator'] = data.get("creator")
return version
# Step 3: Get detailed version info using the version_id
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
if response.status != 200:
return None
version = await response.json()
# Step 4: Enrich version_info with model data
# Add description and tags from model data
version['model']['description'] = data.get("description")
version['model']['tags'] = data.get("tags", [])
# Add creator from model data
version['creator'] = data.get("creator")
return version
# Case 3: Neither model_id nor version_id provided
else:
logger.error("Either model_id or version_id must be provided")
return None
except Exception as e:
logger.error(f"Error fetching model version: {e}")

View File

@@ -54,15 +54,15 @@ class DownloadManager:
"""Get the checkpoint scanner from registry"""
return await ServiceRegistry.get_checkpoint_scanner()
async def download_from_civitai(self, model_id: int, model_version_id: int,
async def download_from_civitai(self, model_id: int = None, model_version_id: int = None,
save_dir: str = None, relative_path: str = '',
progress_callback=None, use_default_paths: bool = False,
download_id: str = None) -> Dict:
"""Download model from Civitai with task tracking and concurrency control
Args:
model_id: Civitai model ID
model_version_id: Civitai model version ID
model_id: Civitai model ID (optional if model_version_id is provided)
model_version_id: Civitai model version ID (optional if model_id is provided)
save_dir: Directory to save the model
relative_path: Relative path within save_dir
progress_callback: Callback function for progress updates
@@ -72,6 +72,10 @@ class DownloadManager:
Returns:
Dict with download result
"""
# Validate that at least one identifier is provided
if not model_id and not model_version_id:
return {'success': False, 'error': 'Either model_id or model_version_id must be provided'}
# Use provided download_id or generate new one
task_id = download_id or str(uuid.uuid4())
@@ -181,14 +185,19 @@ class DownloadManager:
# Check both scanners
lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
# Check lora scanner first
if await lora_scanner.check_model_version_exists(model_id, model_version_id):
if await lora_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in lora library'}
# Check checkpoint scanner
if await checkpoint_scanner.check_model_version_exists(model_id, model_version_id):
if await checkpoint_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in checkpoint library'}
# Check embedding scanner
if await embedding_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'}
# Get civitai client
civitai_client = await self._get_civitai_client()
@@ -211,23 +220,22 @@ class DownloadManager:
# Case 2: model_version_id was None, check after getting version_info
if model_version_id is None:
version_model_id = version_info.get('modelId')
version_id = version_info.get('id')
if model_type == 'lora':
# Check lora scanner
lora_scanner = await self._get_lora_scanner()
if await lora_scanner.check_model_version_exists(version_model_id, version_id):
if await lora_scanner.check_model_version_exists(version_id):
return {'success': False, 'error': 'Model version already exists in lora library'}
elif model_type == 'checkpoint':
# Check checkpoint scanner
checkpoint_scanner = await self._get_checkpoint_scanner()
if await checkpoint_scanner.check_model_version_exists(version_model_id, version_id):
if await checkpoint_scanner.check_model_version_exists(version_id):
return {'success': False, 'error': 'Model version already exists in checkpoint library'}
elif model_type == 'embedding':
# Embeddings are not checked in scanners, but we can still check if it exists
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
if await embedding_scanner.check_model_version_exists(version_model_id, version_id):
if await embedding_scanner.check_model_version_exists(version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'}
# Handle use_default_paths
@@ -250,7 +258,7 @@ class DownloadManager:
save_dir = default_path
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info)
relative_path = self._calculate_relative_path(version_info, model_type)
# Update save directory with relative path if provided
if relative_path:
@@ -266,9 +274,9 @@ class DownloadManager:
from datetime import datetime
date_obj = datetime.fromisoformat(early_access_date.replace('Z', '+00:00'))
formatted_date = date_obj.strftime('%Y-%m-%d')
early_access_msg = f"This model requires early access payment (until {formatted_date}). "
early_access_msg = f"This model requires payment (until {formatted_date}). "
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."
logger.warning(f"Early access model detected: {version_info.get('name', 'Unknown')}")
@@ -313,6 +321,10 @@ class DownloadManager:
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
except Exception as e:
@@ -323,17 +335,18 @@ class DownloadManager:
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
return {'success': False, 'error': str(e)}
def _calculate_relative_path(self, version_info: Dict) -> str:
def _calculate_relative_path(self, version_info: Dict, model_type: str = 'lora') -> str:
"""Calculate relative path using template from settings
Args:
version_info: Version info from Civitai API
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Relative path string
"""
# Get path template from settings, default to '{base_model}/{first_tag}'
path_template = settings.get('download_path_template', '{base_model}/{first_tag}')
# Get path template from settings for specific model type
path_template = settings.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure)
if not path_template:
@@ -342,6 +355,13 @@ class DownloadManager:
# Get base model name
base_model = version_info.get('baseModel', '')
# Get author from creator data
creator_info = version_info.get('creator')
if creator_info and isinstance(creator_info, dict):
author = creator_info.get('username') or 'Anonymous'
else:
author = 'Anonymous'
# Apply mapping if available
base_model_mappings = settings.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
@@ -364,6 +384,7 @@ class DownloadManager:
formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path
@@ -375,11 +396,13 @@ class DownloadManager:
try:
civitai_client = await self._get_civitai_client()
save_path = metadata.file_path
part_path = save_path + '.part'
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:
self._active_downloads[download_id]['file_path'] = save_path
self._active_downloads[download_id]['part_path'] = part_path
# Download preview image if available
images = version_info.get('images', [])
@@ -446,10 +469,22 @@ class DownloadManager:
)
if not success:
# Clean up files on failure
for path in [save_path, metadata_path, metadata.preview_url]:
# Clean up files on failure, but preserve .part file for resume
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):
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}
# 4. Update file information (size and modified time)
@@ -485,10 +520,18 @@ class DownloadManager:
except Exception as e:
logger.error(f"Error in _execute_download: {e}", exc_info=True)
# Clean up partial downloads
for path in [save_path, metadata_path]:
# Clean up partial downloads except .part file
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):
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)}
async def _handle_download_progress(self, file_progress: float, progress_callback):
@@ -530,35 +573,48 @@ class DownloadManager:
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
# Clean up partial downloads
# Clean up ALL files including .part when user cancels
download_info = self._active_downloads.get(download_id)
if download_info and 'file_path' in download_info:
# Delete the partial file
file_path = download_info['file_path']
if os.path.exists(file_path):
try:
os.unlink(file_path)
logger.debug(f"Deleted partial download: {file_path}")
except Exception as e:
logger.error(f"Error deleting partial file: {e}")
if download_info:
# Delete the main file
if 'file_path' in download_info:
file_path = download_info['file_path']
if os.path.exists(file_path):
try:
os.unlink(file_path)
logger.debug(f"Deleted cancelled download: {file_path}")
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
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
if os.path.exists(metadata_path):
try:
os.unlink(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):
if 'file_path' in download_info:
file_path = download_info['file_path']
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
if os.path.exists(metadata_path):
try:
os.unlink(preview_path)
logger.debug(f"Deleted preview file: {preview_path}")
os.unlink(metadata_path)
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'}
except Exception as e:

View File

@@ -147,16 +147,6 @@ class LoraService(BaseModelService):
return letters
async def get_lora_notes(self, lora_name: str) -> Optional[str]:
"""Get notes for a specific LoRA file"""
cache = await self.scanner.get_cached_data()
for lora in cache.raw_data:
if lora['file_name'] == lora_name:
return lora.get('notes', '')
return None
async def get_lora_trigger_words(self, lora_name: str) -> List[str]:
"""Get trigger words for a specific LoRA file"""
cache = await self.scanner.get_cached_data()
@@ -168,41 +158,21 @@ class LoraService(BaseModelService):
return []
async def get_lora_preview_url(self, lora_name: str) -> Optional[str]:
"""Get the static preview URL for a LoRA file"""
async def get_lora_usage_tips_by_relative_path(self, relative_path: str) -> Optional[str]:
"""Get usage tips for a LoRA by its relative path"""
cache = await self.scanner.get_cached_data()
for lora in cache.raw_data:
if lora['file_name'] == lora_name:
preview_url = lora.get('preview_url')
if preview_url:
return config.get_preview_static_url(preview_url)
file_path = lora.get('file_path', '')
if file_path:
# Convert to forward slashes and extract relative path
file_path_normalized = file_path.replace('\\', '/')
# Find the relative path part by looking for the relative_path in the full path
if file_path_normalized.endswith(relative_path) or relative_path in file_path_normalized:
return lora.get('usage_tips', '')
return None
async def get_lora_civitai_url(self, lora_name: str) -> Dict[str, Optional[str]]:
"""Get the Civitai URL for a LoRA file"""
cache = await self.scanner.get_cached_data()
for lora in cache.raw_data:
if lora['file_name'] == lora_name:
civitai_data = lora.get('civitai', {})
model_id = civitai_data.get('modelId')
version_id = civitai_data.get('id')
if model_id:
civitai_url = f"https://civitai.com/models/{model_id}"
if version_id:
civitai_url += f"?modelVersionId={version_id}"
return {
'civitai_url': civitai_url,
'model_id': str(model_id),
'version_id': str(version_id) if version_id else None
}
return {'civitai_url': None, 'model_id': None, 'version_id': None}
def find_duplicate_hashes(self) -> Dict:
"""Find LoRAs with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes()

View File

@@ -31,29 +31,34 @@ class ModelHashIndex:
if file_path not in self._duplicate_hashes.get(sha256, []):
self._duplicate_hashes.setdefault(sha256, []).append(file_path)
# Track duplicates by filename
# Track duplicates by filename - FIXED LOGIC
if filename in self._filename_to_hash:
old_hash = self._filename_to_hash[filename]
if old_hash != sha256: # Different models with the same name
old_path = self._hash_to_path.get(old_hash)
if old_path:
if filename not in self._duplicate_filenames:
self._duplicate_filenames[filename] = [old_path]
if file_path not in self._duplicate_filenames.get(filename, []):
self._duplicate_filenames.setdefault(filename, []).append(file_path)
existing_hash = self._filename_to_hash[filename]
existing_path = self._hash_to_path.get(existing_hash)
# If this is a different file with the same filename
if existing_path and existing_path != file_path:
# Initialize duplicates tracking if needed
if filename not in self._duplicate_filenames:
self._duplicate_filenames[filename] = [existing_path]
# Add current file to duplicates if not already present
if file_path not in self._duplicate_filenames[filename]:
self._duplicate_filenames[filename].append(file_path)
# Remove old path mapping if hash exists
if sha256 in self._hash_to_path:
old_path = self._hash_to_path[sha256]
old_filename = self._get_filename_from_path(old_path)
if old_filename in self._filename_to_hash:
if old_filename in self._filename_to_hash and self._filename_to_hash[old_filename] == sha256:
del self._filename_to_hash[old_filename]
# Remove old hash mapping if filename exists
# Remove old hash mapping if filename exists and points to different hash
if filename in self._filename_to_hash:
old_hash = self._filename_to_hash[filename]
if old_hash in self._hash_to_path:
del self._hash_to_path[old_hash]
if old_hash != sha256 and old_hash in self._hash_to_path:
# Don't delete the old hash mapping, just update filename mapping
pass
# Add new mappings
self._hash_to_path[sha256] = file_path

View File

@@ -302,6 +302,13 @@ class ModelScanner:
for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
# duplicate_filenames = self._hash_index.get_duplicate_filenames()
# if duplicate_filenames:
# logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
# for filename, paths in duplicate_filenames.items():
# logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache
self._cache.raw_data = raw_data
loop.run_until_complete(self._cache.resort())
@@ -367,6 +374,13 @@ class ModelScanner:
for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
# duplicate_filenames = self._hash_index.get_duplicate_filenames()
# if duplicate_filenames:
# logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
# for filename, paths in duplicate_filenames.items():
# logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache
self._cache = ModelCache(
raw_data=raw_data,
@@ -670,6 +684,14 @@ class ModelScanner:
if model_data.get('exclude', False):
self._excluded_models.append(model_data['file_path'])
return None
# Check for duplicate filename before adding to hash index
filename = os.path.splitext(os.path.basename(file_path))[0]
existing_hash = self._hash_index.get_hash_by_filename(filename)
if existing_hash and existing_hash != model_data.get('sha256', '').lower():
existing_path = self._hash_index.get_path(existing_hash)
if existing_path and existing_path != file_path:
logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
await self._fetch_missing_metadata(file_path, model_data)
rel_path = os.path.relpath(file_path, root_path)
@@ -1149,13 +1171,12 @@ class ModelScanner:
if len(self._hash_index._duplicate_filenames[file_name]) <= 1:
del self._hash_index._duplicate_filenames[file_name]
async def check_model_version_exists(self, model_id: int, model_version_id: int) -> bool:
async def check_model_version_exists(self, model_version_id: int) -> bool:
"""Check if a specific model version exists in the cache
Args:
model_id: Civitai model ID
model_version_id: Civitai model version ID
Returns:
bool: True if the model version exists, False otherwise
"""
@@ -1163,13 +1184,11 @@ class ModelScanner:
cache = await self.get_cached_data()
if not cache or not cache.raw_data:
return False
for item in cache.raw_data:
if (item.get('civitai') and
item['civitai'].get('modelId') == model_id and
item['civitai'].get('id') == model_version_id):
if item.get('civitai') and item['civitai'].get('id') == model_version_id:
return True
return False
except Exception as e:
logger.error(f"Error checking model version existence: {e}")

View File

@@ -9,6 +9,7 @@ class SettingsManager:
def __init__(self):
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
self.settings = self._load_settings()
self._migrate_download_path_template()
self._auto_set_default_roots()
self._check_environment_variables()
@@ -22,6 +23,24 @@ class SettingsManager:
logger.error(f"Error loading settings: {e}")
return self._get_default_settings()
def _migrate_download_path_template(self):
"""Migrate old download_path_template to new download_path_templates"""
old_template = self.settings.get('download_path_template')
templates = self.settings.get('download_path_templates')
# If old template exists and new templates don't exist, migrate
if old_template is not None and not templates:
logger.info("Migrating download_path_template to download_path_templates")
self.settings['download_path_templates'] = {
'lora': old_template,
'checkpoint': old_template,
'embedding': old_template
}
# Remove old setting
del self.settings['download_path_template']
self._save_settings()
logger.info("Migration completed")
def _auto_set_default_roots(self):
"""Auto set default root paths if only one folder is present and default is empty."""
folder_paths = self.settings.get('folder_paths', {})
@@ -81,4 +100,16 @@ class SettingsManager:
except Exception as e:
logger.error(f"Error saving settings: {e}")
def get_download_path_template(self, model_type: str) -> str:
"""Get download path template for specific model type
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding')
Returns:
Template string for the model type, defaults to '{base_model}/{first_tag}'
"""
templates = self.settings.get('download_path_templates', {})
return templates.get(model_type, '{base_model}/{first_tag}')
settings = SettingsManager()

View File

@@ -16,6 +16,9 @@ class WebSocketManager:
self._download_websockets: Dict[str, web.WebSocketResponse] = {} # New dict for download-specific clients
# Add progress tracking dictionary
self._download_progress: Dict[str, Dict] = {}
# Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict] = None
self._auto_organize_lock = asyncio.Lock()
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
"""Handle new WebSocket connection"""
@@ -134,6 +137,33 @@ class WebSocketManager:
except Exception as e:
logger.error(f"Error sending download progress: {e}")
async def broadcast_auto_organize_progress(self, data: Dict):
"""Broadcast auto-organize progress to connected clients"""
# Store progress data in memory
self._auto_organize_progress = data
# Broadcast via WebSocket
await self.broadcast(data)
def get_auto_organize_progress(self) -> Optional[Dict]:
"""Get current auto-organize progress"""
return self._auto_organize_progress
def cleanup_auto_organize_progress(self):
"""Clear auto-organize progress data"""
self._auto_organize_progress = None
def is_auto_organize_running(self) -> bool:
"""Check if auto-organize is currently running"""
if not self._auto_organize_progress:
return False
status = self._auto_organize_progress.get('status')
return status in ['started', 'processing', 'cleaning']
async def get_auto_organize_lock(self):
"""Get the auto-organize lock"""
return self._auto_organize_lock
def get_download_progress(self, download_id: str) -> Optional[Dict]:
"""Get progress information for a specific download"""
return self._download_progress.get(download_id)

View File

@@ -48,6 +48,9 @@ SUPPORTED_MEDIA_EXTENSIONS = {
# Valid Lora types
VALID_LORA_TYPES = ['lora', 'locon', 'dora']
# Auto-organize settings
AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming the system
# Civitai model tags in priority order for subfolder organization
CIVITAI_MODEL_TAGS = [
'character', 'style', 'concept', 'clothing',

View File

@@ -6,8 +6,10 @@ import time
import aiohttp
from aiohttp import web
from ..services.service_registry import ServiceRegistry
from ..utils.metadata_manager import MetadataManager
from .example_images_processor import ExampleImagesProcessor
from .example_images_metadata import MetadataUpdater
from ..services.websocket_manager import ws_manager # Add this import at the top
logger = logging.getLogger(__name__)
@@ -24,7 +26,8 @@ download_progress = {
'start_time': None,
'end_time': None,
'processed_models': set(), # Track models that have been processed
'refreshed_models': set() # Track models that had metadata refreshed
'refreshed_models': set(), # Track models that had metadata refreshed
'failed_models': set() # Track models that failed to download after metadata refresh
}
class DownloadManager:
@@ -50,6 +53,7 @@ class DownloadManager:
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({
'success': False,
@@ -91,12 +95,15 @@ class DownloadManager:
with open(progress_file, 'r', encoding='utf-8') as f:
saved_progress = json.load(f)
download_progress['processed_models'] = set(saved_progress.get('processed_models', []))
logger.debug(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed")
download_progress['failed_models'] = set(saved_progress.get('failed_models', []))
logger.debug(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed, {len(download_progress['failed_models'])} models marked as failed")
except Exception as e:
logger.error(f"Failed to load progress file: {e}")
download_progress['processed_models'] = set()
download_progress['failed_models'] = set()
else:
download_progress['processed_models'] = set()
download_progress['failed_models'] = set()
# Start the download task
is_downloading = True
@@ -113,6 +120,7 @@ class DownloadManager:
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({
'success': True,
@@ -136,6 +144,7 @@ class DownloadManager:
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({
'success': True,
@@ -299,6 +308,11 @@ class DownloadManager:
# Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already in failed models
if model_hash in download_progress['failed_models']:
logger.debug(f"Skipping known failed model: {model_name}")
return False
# Skip if already processed AND directory exists with files
if model_hash in download_progress['processed_models']:
model_dir = os.path.join(output_dir, model_hash)
@@ -307,7 +321,9 @@ class DownloadManager:
logger.debug(f"Skipping already processed model: {model_name}")
return False
else:
logger.debug(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
# Remove from processed models since we need to reprocess
download_progress['processed_models'].discard(model_hash)
# Create model directory
model_dir = os.path.join(output_dir, model_hash)
@@ -351,12 +367,23 @@ class DownloadManager:
success, _ = await ExampleImagesProcessor.download_model_images(
model_hash, model_name, updated_images, model_dir, optimize, independent_session
)
download_progress['refreshed_models'].add(model_hash)
# Only mark as processed if all images were downloaded successfully
# Mark as processed if successful, or as failed if unsuccessful after refresh
if success:
download_progress['processed_models'].add(model_hash)
else:
# If we refreshed metadata and still failed, mark as permanently failed
if model_hash in download_progress['refreshed_models']:
download_progress['failed_models'].add(model_hash)
logger.info(f"Marking model {model_name} as failed after metadata refresh")
return True # Return True to indicate a remote download happened
else:
# No civitai data or images available, mark as failed to avoid future attempts
download_progress['failed_models'].add(model_hash)
logger.debug(f"No civitai images available for model {model_name}, marking as failed")
# Save progress periodically
if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1:
@@ -391,6 +418,7 @@ class DownloadManager:
progress_data = {
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'failed_models': list(download_progress['failed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time()
@@ -405,4 +433,364 @@ class DownloadManager:
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump(progress_data, f, indent=2)
except Exception as e:
logger.error(f"Failed to save progress file: {e}")
logger.error(f"Failed to save progress file: {e}")
@staticmethod
async def start_force_download(request):
"""
Force download example images for specific models
Expects a JSON body with:
{
"model_hashes": ["hash1", "hash2", ...], # List of model hashes to download
"output_dir": "path/to/output", # Base directory to save example images
"optimize": true, # Whether to optimize images (default: true)
"model_types": ["lora", "checkpoint"], # Model types to process (default: both)
"delay": 1.0 # Delay between downloads (default: 1.0)
}
"""
global download_task, is_downloading, download_progress
if is_downloading:
return web.json_response({
'success': False,
'error': 'Download already in progress'
}, status=400)
try:
# Parse the request body
data = await request.json()
model_hashes = data.get('model_hashes', [])
output_dir = data.get('output_dir')
optimize = data.get('optimize', True)
model_types = data.get('model_types', ['lora', 'checkpoint'])
delay = float(data.get('delay', 0.2)) # Default to 0.2 seconds
if not model_hashes:
return web.json_response({
'success': False,
'error': 'Missing model_hashes parameter'
}, status=400)
if not output_dir:
return web.json_response({
'success': False,
'error': 'Missing output_dir parameter'
}, status=400)
# Create the output directory
os.makedirs(output_dir, exist_ok=True)
# Initialize progress tracking
download_progress['total'] = len(model_hashes)
download_progress['completed'] = 0
download_progress['current_model'] = ''
download_progress['status'] = 'running'
download_progress['errors'] = []
download_progress['last_error'] = None
download_progress['start_time'] = time.time()
download_progress['end_time'] = None
download_progress['processed_models'] = set()
download_progress['refreshed_models'] = set()
download_progress['failed_models'] = set()
# Set download status to downloading
is_downloading = True
# Execute the download function directly instead of creating a background task
result = await DownloadManager._download_specific_models_example_images_sync(
model_hashes,
output_dir,
optimize,
model_types,
delay
)
# Set download status to not downloading
is_downloading = False
return web.json_response({
'success': True,
'message': 'Force download completed',
'result': result
})
except Exception as e:
# Set download status to not downloading
is_downloading = False
logger.error(f"Failed during forced example images download: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def _download_specific_models_example_images_sync(model_hashes, output_dir, optimize, model_types, delay):
"""Download example images for specific models only - synchronous version"""
global download_progress
# Create independent download session
connector = aiohttp.TCPConnector(
ssl=True,
limit=3,
force_close=False,
enable_cleanup_closed=True
)
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=60)
independent_session = aiohttp.ClientSession(
connector=connector,
trust_env=True,
timeout=timeout
)
try:
# Get scanners
scanners = []
if 'lora' in model_types:
lora_scanner = await ServiceRegistry.get_lora_scanner()
scanners.append(('lora', lora_scanner))
if 'checkpoint' in model_types:
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
scanners.append(('checkpoint', checkpoint_scanner))
if 'embedding' in model_types:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(('embedding', embedding_scanner))
# Find the specified models
models_to_process = []
for scanner_type, scanner in scanners:
cache = await scanner.get_cached_data()
if cache and cache.raw_data:
for model in cache.raw_data:
if model.get('sha256') in model_hashes:
models_to_process.append((scanner_type, model, scanner))
# Update total count based on found models
download_progress['total'] = len(models_to_process)
logger.debug(f"Found {download_progress['total']} models to process")
# Send initial progress via WebSocket
await ws_manager.broadcast({
'type': 'example_images_progress',
'processed': 0,
'total': download_progress['total'],
'status': 'running',
'current_model': ''
})
# Process each model
success_count = 0
for i, (scanner_type, model, scanner) in enumerate(models_to_process):
# Force process this model regardless of previous status
was_successful = await DownloadManager._process_specific_model(
scanner_type, model, scanner,
output_dir, optimize, independent_session
)
if was_successful:
success_count += 1
# Update progress
download_progress['completed'] += 1
# Send progress update via WebSocket
await ws_manager.broadcast({
'type': 'example_images_progress',
'processed': download_progress['completed'],
'total': download_progress['total'],
'status': 'running',
'current_model': download_progress['current_model']
})
# Only add delay after remote download, and not after processing the last model
if was_successful and i < len(models_to_process) - 1 and download_progress['status'] == 'running':
await asyncio.sleep(delay)
# Mark as completed
download_progress['status'] = 'completed'
download_progress['end_time'] = time.time()
logger.debug(f"Forced example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
# Send final progress via WebSocket
await ws_manager.broadcast({
'type': 'example_images_progress',
'processed': download_progress['completed'],
'total': download_progress['total'],
'status': 'completed',
'current_model': ''
})
return {
'total': download_progress['total'],
'processed': download_progress['completed'],
'successful': success_count,
'errors': download_progress['errors']
}
except Exception as e:
error_msg = f"Error during forced example images download: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
download_progress['status'] = 'error'
download_progress['end_time'] = time.time()
# Send error status via WebSocket
await ws_manager.broadcast({
'type': 'example_images_progress',
'processed': download_progress['completed'],
'total': download_progress['total'],
'status': 'error',
'error': error_msg,
'current_model': ''
})
raise
finally:
# Close the independent session
try:
await independent_session.close()
except Exception as e:
logger.error(f"Error closing download session: {e}")
@staticmethod
async def _process_specific_model(scanner_type, model, scanner, output_dir, optimize, independent_session):
"""Process a specific model for forced download, ignoring previous download status"""
global download_progress
# Check if download is paused
while download_progress['status'] == 'paused':
await asyncio.sleep(1)
# Check if download should continue
if download_progress['status'] != 'running':
logger.info(f"Download stopped: {download_progress['status']}")
return False
model_hash = model.get('sha256', '').lower()
model_name = model.get('model_name', 'Unknown')
model_file_path = model.get('file_path', '')
model_file_name = model.get('file_name', '')
try:
# Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Create model directory
model_dir = os.path.join(output_dir, model_hash)
os.makedirs(model_dir, exist_ok=True)
# First check for local example images - local processing doesn't need delay
local_images_processed = await ExampleImagesProcessor.process_local_examples(
model_file_path, model_file_name, model_name, model_dir, optimize
)
# If we processed local images, update metadata
if local_images_processed:
await MetadataUpdater.update_metadata_from_local_examples(
model_hash, model, scanner_type, scanner, model_dir
)
download_progress['processed_models'].add(model_hash)
return False # Return False to indicate no remote download happened
# If no local images, try to download from remote
elif model.get('civitai') and model.get('civitai', {}).get('images'):
images = model.get('civitai', {}).get('images', [])
success, is_stale, failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, images, model_dir, optimize, independent_session
)
# If metadata is stale, try to refresh it
if is_stale and model_hash not in download_progress['refreshed_models']:
await MetadataUpdater.refresh_model_metadata(
model_hash, model_name, scanner_type, scanner
)
# Get the updated model data
updated_model = await MetadataUpdater.get_updated_model(
model_hash, scanner
)
if updated_model and updated_model.get('civitai', {}).get('images'):
# Retry download with updated metadata
updated_images = updated_model.get('civitai', {}).get('images', [])
success, _, additional_failed_images = await ExampleImagesProcessor.download_model_images_with_tracking(
model_hash, model_name, updated_images, model_dir, optimize, independent_session
)
# Combine failed images from both attempts
failed_images.extend(additional_failed_images)
download_progress['refreshed_models'].add(model_hash)
# For forced downloads, remove failed images from metadata
if failed_images:
# Create a copy of images excluding failed ones
await DownloadManager._remove_failed_images_from_metadata(
model_hash, model_name, failed_images, scanner
)
# Mark as processed
if success or failed_images: # Mark as processed if we successfully downloaded some images or removed failed ones
download_progress['processed_models'].add(model_hash)
return True # Return True to indicate a remote download happened
else:
logger.debug(f"No civitai images available for model {model_name}")
return False
except Exception as e:
error_msg = f"Error processing model {model.get('model_name')}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False # Return False on exception
@staticmethod
async def _remove_failed_images_from_metadata(model_hash, model_name, failed_images, scanner):
"""Remove failed images from model metadata"""
try:
# Get current model data
model_data = await MetadataUpdater.get_updated_model(model_hash, scanner)
if not model_data:
logger.warning(f"Could not find model data for {model_name} to remove failed images")
return
if not model_data.get('civitai', {}).get('images'):
logger.warning(f"No images in metadata for {model_name}")
return
# Get current images
current_images = model_data['civitai']['images']
# Filter out failed images
updated_images = [img for img in current_images if img.get('url') not in failed_images]
# If images were removed, update metadata
if len(updated_images) < len(current_images):
removed_count = len(current_images) - len(updated_images)
logger.info(f"Removing {removed_count} failed images from metadata for {model_name}")
# Update the images list
model_data['civitai']['images'] = updated_images
# Save metadata to file
file_path = model_data.get('file_path')
if file_path:
# Create a copy of model data without 'folder' field
model_copy = model_data.copy()
model_copy.pop('folder', None)
# Write metadata to file
await MetadataManager.save_metadata(file_path, model_copy)
logger.info(f"Saved updated metadata for {model_name} after removing failed images")
# Update the scanner cache
await scanner.update_single_model_cache(file_path, file_path, model_data)
except Exception as e:
logger.error(f"Error removing failed images from metadata for {model_name}: {e}", exc_info=True)

View File

@@ -102,6 +102,78 @@ class ExampleImagesProcessor:
return model_success, False # (success, is_metadata_stale)
@staticmethod
async def download_model_images_with_tracking(model_hash, model_name, model_images, model_dir, optimize, independent_session):
"""Download images for a single model with tracking of failed image URLs
Returns:
tuple: (success, is_stale_metadata, failed_images) - whether download was successful, whether metadata is stale, list of failed image URLs
"""
model_success = True
failed_images = []
for i, image in enumerate(model_images):
image_url = image.get('url')
if not image_url:
continue
# Get image filename from URL
image_filename = os.path.basename(image_url.split('?')[0])
image_ext = os.path.splitext(image_filename)[1].lower()
# Handle images and videos
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug(f"Skipping unsupported file type: {image_filename}")
continue
# Use 0-based indexing instead of 1-based indexing
save_filename = f"image_{i}{image_ext}"
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
if is_image and optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
save_filename = f"image_{i}.webp"
# Check if already downloaded
save_path = os.path.join(model_dir, save_filename)
if os.path.exists(save_path):
logger.debug(f"File already exists: {save_path}")
continue
# Download the file
try:
logger.debug(f"Downloading {save_filename} for {model_name}")
# Download directly using the independent session
async with independent_session.get(image_url, timeout=60) as response:
if response.status == 200:
with open(save_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
if chunk:
f.write(chunk)
elif response.status == 404:
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
model_success = False # Mark the model as failed due to 404 error
failed_images.append(image_url) # Track failed URL
# Return early to trigger metadata refresh attempt
return False, True, failed_images # (success, is_metadata_stale, failed_images)
else:
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
logger.warning(error_msg)
model_success = False # Mark the model as failed
failed_images.append(image_url) # Track failed URL
except Exception as e:
error_msg = f"Error downloading file {image_url}: {str(e)}"
logger.error(error_msg)
model_success = False # Mark the model as failed
failed_images.append(image_url) # Track failed URL
return model_success, False, failed_images # (success, is_metadata_stale, failed_images)
@staticmethod
async def process_local_examples(model_file_path, model_file_name, model_name, model_dir, optimize):
"""Process local example images

View File

@@ -580,16 +580,19 @@ class ModelRouteUtils:
})
# Check which identifier is provided and convert to int
try:
model_id = int(data.get('model_id'))
except (TypeError, ValueError):
return web.json_response({
'success': False,
'error': "Invalid model_id: Must be an integer"
}, status=400)
model_id = None
model_version_id = None
if data.get('model_id'):
try:
model_id = int(data.get('model_id'))
except (TypeError, ValueError):
return web.json_response({
'success': False,
'error': "Invalid model_id: Must be an integer"
}, status=400)
# Convert model_version_id to int if provided
model_version_id = None
if data.get('model_version_id'):
try:
model_version_id = int(data.get('model_version_id'))
@@ -599,11 +602,11 @@ class ModelRouteUtils:
'error': "Invalid model_version_id: Must be an integer"
}, status=400)
# Only model_id is required, model_version_id is optional
if not model_id:
# At least one identifier is required
if not model_id and not model_version_id:
return web.json_response({
'success': False,
'error': "Missing required parameter: Please provide 'model_id'"
'error': "Missing required parameter: Please provide either 'model_id' or 'model_version_id'"
}, status=400)
use_default_paths = data.get('use_default_paths', False)
@@ -625,15 +628,6 @@ class ModelRouteUtils:
if not result.get('success', False):
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({
'success': False,
'error': error_message,

View File

@@ -1,7 +1,10 @@
from difflib import SequenceMatcher
import os
from typing import Dict
from ..services.service_registry import ServiceRegistry
from ..config import config
from ..services.settings_manager import settings
from .constants import CIVITAI_MODEL_TAGS
import asyncio
def get_lora_info(lora_name):
@@ -128,3 +131,95 @@ def calculate_recipe_fingerprint(loras):
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
return fingerprint
def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str:
"""Calculate relative path for existing model using template from settings
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Relative path string (empty string for flat structure)
"""
# Get path template from settings for specific model type
path_template = settings.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure)
if not path_template:
return ''
# Get base model name from model metadata
civitai_data = model_data.get('civitai', {})
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
if civitai_data and civitai_data.get('id') is not None:
base_model = civitai_data.get('baseModel', '')
# Get author from civitai creator data
creator_info = civitai_data.get('creator') or {}
author = creator_info.get('username') or 'Anonymous'
else:
# Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '')
author = 'Anonymous' # Default for non-CivitAI models
model_tags = model_data.get('tags', [])
# Apply mapping if available
base_model_mappings = settings.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
# Find the first Civitai model tag that exists in model_tags
first_tag = ''
for civitai_tag in CIVITAI_MODEL_TAGS:
if civitai_tag in model_tags:
first_tag = civitai_tag
break
# If no Civitai model tag found, fallback to first tag
if not first_tag and model_tags:
first_tag = model_tags[0]
if not first_tag:
first_tag = 'no tags' # Default if no tags available
# Format the template with available data
formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path
def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path.
Args:
path (str): Root directory to start cleaning from
Returns:
int: Number of empty directories removed
"""
removed_count = 0
if not os.path.isdir(path):
return removed_count
# List all files in directory
files = os.listdir(path)
# Process all subdirectories first
for file in files:
full_path = os.path.join(path, file)
if os.path.isdir(full_path):
removed_count += remove_empty_dirs(full_path)
# Check if directory is now empty (after processing subdirectories)
if not os.listdir(path):
try:
os.rmdir(path)
removed_count += 1
except OSError:
pass
return removed_count

View File

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

View File

@@ -9,6 +9,10 @@
"checkpoints": [
"C:/path/to/your/checkpoints_folder",
"C:/path/to/another/checkpoints_folder"
],
"embeddings": [
"C:/path/to/your/embeddings_folder",
"C:/path/to/another/embeddings_folder"
]
}
}

View File

@@ -12,7 +12,9 @@
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
min-width: 300px;
min-width: 420px;
max-width: 900px;
width: auto;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
@@ -48,6 +50,8 @@
color: var(--text-color);
cursor: pointer;
font-size: 14px;
white-space: nowrap;
min-height: 36px;
display: flex;
align-items: center;
gap: 6px;
@@ -105,6 +109,8 @@
@media (max-width: 768px) {
.bulk-operations-panel {
width: calc(100% - 40px);
min-width: unset;
max-width: unset;
left: 20px;
transform: none;
border-radius: var(--border-radius-sm);

View File

@@ -1,197 +0,0 @@
/* Download Modal Styles */
.download-step {
margin: var(--space-2) 0;
}
.input-group {
margin-bottom: var(--space-2);
}
.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);
}
/* Version List Styles */
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
margin: 1px;
position: relative;
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
}
.version-item.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.version-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.version-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.version-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
flex: 1;
}
.version-content .version-info {
display: flex;
flex-wrap: wrap;
flex-direction: row !important;
gap: 8px;
align-items: center;
font-size: 0.9em;
}
.version-content .version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.version-meta {
display: flex;
gap: 12px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.version-meta span {
display: flex;
align-items: center;
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 {
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 Styles */
.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);
}
/* Dark theme adjustments */
[data-theme="dark"] .version-item {
background: var(--lora-surface);
}
[data-theme="dark"] .local-path {
background: var(--lora-surface);
border-color: var(--lora-border);
}
/* Enhance the local badge to make it more noticeable */
.version-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}

View File

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

View File

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

View File

@@ -0,0 +1,505 @@
/* Download Modal Styles */
.input-group {
margin-bottom: var(--space-2);
}
.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);
}
/* Version List Styles */
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
margin: 1px;
position: relative;
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
}
.version-item.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.version-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.version-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.version-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
flex: 1;
}
.version-content .version-info {
display: flex;
flex-wrap: wrap;
flex-direction: row !important;
gap: 8px;
align-items: center;
font-size: 0.9em;
}
.version-content .version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.version-meta {
display: flex;
gap: 12px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.version-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.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 Input Styles */
.path-input-container {
position: relative;
display: flex;
gap: 8px;
align-items: center;
}
.path-input-container input {
flex: 1;
}
.create-folder-btn {
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
}
.create-folder-btn:hover {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.path-suggestions {
position: absolute;
top: 46%;
left: 0;
right: 0;
z-index: 1000;
margin: 0 24px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-xs) var(--border-radius-xs);
max-height: 200px;
overflow-y: auto;
}
.path-suggestion {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--border-color);
}
.path-suggestion:last-child {
border-bottom: none;
}
.path-suggestion:hover {
background: var(--lora-surface);
}
.path-suggestion.active {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
}
/* Breadcrumb Navigation Styles */
.breadcrumb-nav {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: var(--space-2);
padding: var(--space-1);
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
overflow-x: auto;
white-space: nowrap;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s ease;
color: var(--text-color);
opacity: 0.7;
text-decoration: none;
}
.breadcrumb-item:hover {
background: var(--bg-color);
opacity: 1;
}
.breadcrumb-item.active {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
opacity: 1;
}
.breadcrumb-separator {
color: var(--text-color);
opacity: 0.5;
margin: 0 4px;
}
/* Folder Tree Styles */
.folder-tree-container {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
max-height: 300px;
overflow-y: auto;
}
.folder-tree {
padding: var(--space-1);
}
.tree-node {
user-select: none;
}
.tree-node-content {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
position: relative;
}
.tree-node-content:hover {
background: var(--lora-surface);
}
.tree-node-content.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
.tree-expand-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
transition: all 0.2s ease;
}
.tree-expand-icon:hover {
background: var(--lora-surface);
}
.tree-expand-icon.expanded {
transform: rotate(90deg);
}
.tree-folder-icon {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--lora-accent);
}
.tree-folder-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
}
.tree-children {
margin-left: 20px;
display: none;
}
.tree-children.expanded {
display: block;
}
.tree-node.has-children > .tree-node-content .tree-expand-icon {
opacity: 1;
}
.tree-node:not(.has-children) > .tree-node-content .tree-expand-icon {
opacity: 0;
pointer-events: none;
}
/* Create folder inline form */
.create-folder-form {
display: flex;
gap: 8px;
margin-left: 20px;
align-items: center;
height: 21px;
}
.create-folder-form input {
flex: 1;
padding: 4px 8px;
border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9em;
}
.create-folder-form button {
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
font-size: 0.8em;
transition: all 0.2s ease;
}
.create-folder-form button.confirm {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
.create-folder-form button:hover {
background: var(--lora-surface);
}
/* Path Preview Styles */
.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-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: var(--space-2);
}
.path-preview-header label {
margin: 0;
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);
}
/* Inline Toggle Styles */
.inline-toggle-container {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
position: relative;
}
.inline-toggle-label {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.9;
white-space: nowrap;
}
.inline-toggle-container .toggle-switch {
position: relative;
width: 36px;
height: 18px;
flex-shrink: 0;
}
.inline-toggle-container .toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.inline-toggle-container .toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: all 0.3s ease;
border-radius: 18px;
}
.inline-toggle-container .toggle-slider:before {
position: absolute;
content: "";
height: 12px;
width: 12px;
left: 3px;
bottom: 3px;
background-color: white;
transition: all 0.3s ease;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.inline-toggle-container .toggle-switch input:checked + .toggle-slider {
background-color: var(--lora-accent);
}
.inline-toggle-container .toggle-switch input:checked + .toggle-slider:before {
transform: translateX(18px);
}
/* Dark theme adjustments */
[data-theme="dark"] .version-item {
background: var(--lora-surface);
}
[data-theme="dark"] .local-path {
background: var(--lora-surface);
border-color: var(--lora-border);
}
[data-theme="dark"] .toggle-slider:before {
background-color: #f0f0f0;
}
/* Enhance the local badge to make it more noticeable */
.version-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.manual-path-selection.disabled {
opacity: 0.5;
pointer-events: none;
user-select: none;
}

View File

@@ -482,4 +482,99 @@ input:checked + .toggle-slider:before {
[data-theme="dark"] .base-model-select option {
background-color: #2d2d2d;
color: var(--text-color);
}
/* Template Configuration Styles */
.placeholder-info {
margin-top: var(--space-1);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.placeholder-tag {
display: inline-block;
background: var(--lora-accent);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
font-size: 1em;
font-weight: 500;
}
.template-custom-row {
margin-top: 8px;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.template-custom-input {
width: 96%;
padding: 6px 10px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
font-family: monospace;
height: 24px;
transition: border-color 0.2s;
}
.template-custom-input:focus {
border-color: var(--lora-accent);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 79, 70, 229), 0.1);
}
.template-custom-input::placeholder {
color: var(--text-color);
opacity: 0.5;
font-family: inherit;
}
.template-validation {
margin-top: 6px;
font-size: 0.85em;
display: flex;
align-items: center;
gap: 6px;
min-height: 20px;
}
.template-validation.valid {
color: var(--lora-success, #22c55e);
}
.template-validation.invalid {
color: var(--lora-error, #ef4444);
}
.template-validation i {
width: 12px;
}
/* Dark theme specific adjustments */
[data-theme="dark"] .template-custom-input {
background-color: rgba(30, 30, 30, 0.9);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.placeholder-info {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -16,7 +16,7 @@
@import 'components/modal/relink-civitai-modal.css';
@import 'components/modal/example-access-modal.css';
@import 'components/modal/support-modal.css';
@import 'components/download-modal.css';
@import 'components/modal/download-modal.css';
@import 'components/toast.css';
@import 'components/loading.css';
@import 'components/menu.css';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -83,6 +83,8 @@ export function getApiEndpoints(modelType) {
baseModels: `/api/${modelType}/base-models`,
roots: `/api/${modelType}/roots`,
folders: `/api/${modelType}/folders`,
folderTree: `/api/${modelType}/folder-tree`,
unifiedFolderTree: `/api/${modelType}/unified-folder-tree`,
duplicates: `/api/${modelType}/find-duplicates`,
conflicts: `/api/${modelType}/find-filename-conflicts`,
verify: `/api/${modelType}/verify-duplicates`,
@@ -163,7 +165,8 @@ export const DOWNLOAD_ENDPOINTS = {
download: '/api/download-model',
downloadGet: '/api/download-model-get',
cancelGet: '/api/cancel-download-get',
progress: '/api/download-progress'
progress: '/api/download-progress',
exampleImages: '/api/force-download-example-images' // New endpoint for downloading example images
};
// WebSocket endpoints

View File

@@ -1,6 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import { getStorageItem, getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js';
import {
getCompleteApiConfig,
getCurrentModelType,
@@ -435,7 +435,9 @@ export class BaseModelApiClient {
}
await operationComplete;
resetAndReload(false);
showToast('Metadata update complete', 'success');
} catch (error) {
console.error('Error fetching metadata:', error);
showToast('Failed to fetch metadata: ' + error.message, 'error');
@@ -586,7 +588,34 @@ export class BaseModelApiClient {
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
async fetchUnifiedFolderTree() {
try {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
if (!response.ok) {
throw new Error(`Failed to fetch unified folder tree`);
}
return await response.json();
} catch (error) {
console.error('Error fetching unified folder tree:', error);
throw error;
}
}
async fetchFolderTree(modelRoot) {
try {
const params = new URLSearchParams({ model_root: modelRoot });
const response = await fetch(`${this.apiConfig.endpoints.folderTree}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch folder tree for root: ${modelRoot}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching folder tree:', error);
throw error;
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId) {
try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST',
@@ -596,6 +625,7 @@ export class BaseModelApiClient {
model_version_id: versionId,
model_root: modelRoot,
relative_path: relativePath,
use_default_paths: useDefaultPaths,
download_id: downloadId
})
});
@@ -825,4 +855,102 @@ export class BaseModelApiClient {
state.loadingManager.hide();
}
}
async downloadExampleImages(modelHashes, modelTypes = null) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
try {
// Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type !== 'example_images_progress') return;
switch(data.status) {
case 'running':
const percent = ((data.processed / data.total) * 100).toFixed(1);
loading.setProgress(percent);
loading.setStatus(
`Processing (${data.processed}/${data.total}) ${data.current_model || ''}`
);
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
`Completed: Downloaded example images for ${data.processed} models`
);
resolve();
break;
case 'error':
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
reject(new Error('WebSocket error: ' + error.message));
};
});
// Wait for WebSocket connection to establish
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
// Get the output directory from storage
const outputDir = getStorageItem('example_images_path', '');
if (!outputDir) {
throw new Error('Please set the example images path in the settings first.');
}
// Determine optimize setting
const optimize = state.global?.settings?.optimizeExampleImages ?? true;
// Make the API request to start the download process
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
model_hashes: modelHashes,
output_dir: outputDir,
optimize: optimize,
model_types: modelTypes || [this.apiConfig.config.singularName]
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to download example images');
}
// Wait for the operation to complete via WebSocket
await operationComplete;
showToast('Successfully downloaded example images!', 'success');
return true;
} catch (error) {
console.error('Error downloading example images:', error);
showToast(`Failed to download example images: ${error.message}`, 'error');
throw error;
} finally {
if (ws) {
ws.close();
}
}
}, {
initialMessage: 'Starting example images download...',
completionMessage: 'Example images download complete'
});
}
}

View File

@@ -17,16 +17,16 @@ export function createModelApiClient(modelType) {
}
}
let _singletonClient = null;
let _singletonClients = new Map();
export function getModelApiClient() {
const currentType = state.currentPageType;
export function getModelApiClient(modelType = null) {
const targetType = modelType || state.currentPageType;
if (!_singletonClient || _singletonClient.modelType !== currentType) {
_singletonClient = createModelApiClient(currentType);
if (!_singletonClients.has(targetType)) {
_singletonClients.set(targetType, createModelApiClient(targetType));
}
return _singletonClient;
return _singletonClients.get(targetType);
}
export function resetAndReload(updateFolders = false) {

View File

@@ -1,6 +1,7 @@
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
@@ -202,6 +203,9 @@ export const ModelContextMenuMixin = {
case 'preview':
openExampleImagesFolder(this.currentCard.dataset.sha256);
return true;
case 'download-examples':
this.downloadExampleImages();
return true;
case 'civitai':
if (this.currentCard.dataset.from_civitai === 'true') {
if (this.currentCard.querySelector('.fa-globe')) {
@@ -222,5 +226,21 @@ export const ModelContextMenuMixin = {
default:
return false;
}
},
// Download example images method
async downloadExampleImages() {
const modelHash = this.currentCard.dataset.sha256;
if (!modelHash) {
showToast('Model hash not available', 'error');
return;
}
try {
const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([modelHash]);
} catch (error) {
console.error('Error downloading example images:', error);
}
}
};

View File

@@ -0,0 +1,585 @@
/**
* FolderTreeManager - Manages folder tree UI for download modal
*/
export class FolderTreeManager {
constructor() {
this.treeData = {};
this.selectedPath = '';
this.expandedNodes = new Set();
this.pathSuggestions = [];
this.onPathChangeCallback = null;
this.activeSuggestionIndex = -1;
this.elementsPrefix = '';
// Bind methods
this.handleTreeClick = this.handleTreeClick.bind(this);
this.handlePathInput = this.handlePathInput.bind(this);
this.handlePathSuggestionClick = this.handlePathSuggestionClick.bind(this);
this.handleCreateFolder = this.handleCreateFolder.bind(this);
this.handleBreadcrumbClick = this.handleBreadcrumbClick.bind(this);
this.handlePathKeyDown = this.handlePathKeyDown.bind(this);
}
/**
* Initialize the folder tree manager
* @param {Object} config - Configuration object
* @param {Function} config.onPathChange - Callback when path changes
* @param {string} config.elementsPrefix - Prefix for element IDs (e.g., 'move' for move modal)
*/
init(config = {}) {
this.onPathChangeCallback = config.onPathChange;
this.elementsPrefix = config.elementsPrefix || '';
this.setupEventHandlers();
}
setupEventHandlers() {
const pathInput = document.getElementById(this.getElementId('folderPath'));
const createFolderBtn = document.getElementById(this.getElementId('createFolderBtn'));
const folderTree = document.getElementById(this.getElementId('folderTree'));
const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav'));
const pathSuggestions = document.getElementById(this.getElementId('pathSuggestions'));
if (pathInput) {
pathInput.addEventListener('input', this.handlePathInput);
pathInput.addEventListener('keydown', this.handlePathKeyDown);
}
if (createFolderBtn) {
createFolderBtn.addEventListener('click', this.handleCreateFolder);
}
if (folderTree) {
folderTree.addEventListener('click', this.handleTreeClick);
}
if (breadcrumbNav) {
breadcrumbNav.addEventListener('click', this.handleBreadcrumbClick);
}
if (pathSuggestions) {
pathSuggestions.addEventListener('click', this.handlePathSuggestionClick);
}
// Hide suggestions when clicking outside
document.addEventListener('click', (e) => {
const pathInput = document.getElementById(this.getElementId('folderPath'));
const suggestions = document.getElementById(this.getElementId('pathSuggestions'));
if (pathInput && suggestions &&
!pathInput.contains(e.target) &&
!suggestions.contains(e.target)) {
suggestions.style.display = 'none';
this.activeSuggestionIndex = -1;
}
});
}
/**
* Get element ID with prefix
*/
getElementId(elementName) {
return this.elementsPrefix ? `${this.elementsPrefix}${elementName.charAt(0).toUpperCase()}${elementName.slice(1)}` : elementName;
}
/**
* Handle path input key events with enhanced keyboard navigation
*/
handlePathKeyDown(event) {
const suggestions = document.getElementById(this.getElementId('pathSuggestions'));
const isVisible = suggestions && suggestions.style.display !== 'none';
if (isVisible) {
const suggestionItems = suggestions.querySelectorAll('.path-suggestion');
const maxIndex = suggestionItems.length - 1;
switch (event.key) {
case 'Escape':
event.preventDefault();
event.stopPropagation();
this.hideSuggestions();
this.activeSuggestionIndex = -1;
break;
case 'ArrowDown':
event.preventDefault();
this.activeSuggestionIndex = Math.min(this.activeSuggestionIndex + 1, maxIndex);
this.updateActiveSuggestion(suggestionItems);
break;
case 'ArrowUp':
event.preventDefault();
this.activeSuggestionIndex = Math.max(this.activeSuggestionIndex - 1, -1);
this.updateActiveSuggestion(suggestionItems);
break;
case 'Enter':
event.preventDefault();
if (this.activeSuggestionIndex >= 0 && suggestionItems[this.activeSuggestionIndex]) {
const path = suggestionItems[this.activeSuggestionIndex].dataset.path;
this.selectPath(path);
this.hideSuggestions();
} else {
this.selectCurrentInput();
}
break;
}
} else if (event.key === 'Enter') {
event.preventDefault();
this.selectCurrentInput();
}
}
/**
* Update active suggestion highlighting
*/
updateActiveSuggestion(suggestionItems) {
suggestionItems.forEach((item, index) => {
item.classList.toggle('active', index === this.activeSuggestionIndex);
if (index === this.activeSuggestionIndex) {
item.scrollIntoView({ block: 'nearest' });
}
});
}
/**
* Load and render folder tree data
* @param {Object} treeData - Hierarchical tree data
*/
async loadTree(treeData) {
this.treeData = treeData;
this.pathSuggestions = this.extractAllPaths(treeData);
this.renderTree();
}
/**
* Extract all paths from tree data for autocomplete
*/
extractAllPaths(treeData, currentPath = '') {
const paths = [];
for (const [folderName, children] of Object.entries(treeData)) {
const newPath = currentPath ? `${currentPath}/${folderName}` : folderName;
paths.push(newPath);
if (Object.keys(children).length > 0) {
paths.push(...this.extractAllPaths(children, newPath));
}
}
return paths.sort();
}
/**
* Render the complete folder tree
*/
renderTree() {
const folderTree = document.getElementById(this.getElementId('folderTree'));
if (!folderTree) return;
// Show placeholder if treeData is empty
if (!this.treeData || Object.keys(this.treeData).length === 0) {
folderTree.innerHTML = `
<div class="folder-tree-placeholder" style="padding:24px;text-align:center;color:var(--text-color);opacity:0.7;">
<i class="fas fa-folder-open" style="font-size:2em;opacity:0.5;"></i>
<div>No folders found.<br/>You can create a new folder using the button above.</div>
</div>
`;
return;
}
folderTree.innerHTML = this.renderTreeNode(this.treeData, '');
}
/**
* Render a single tree node
*/
renderTreeNode(nodeData, basePath) {
const entries = Object.entries(nodeData);
if (entries.length === 0) return '';
return entries.map(([folderName, children]) => {
const currentPath = basePath ? `${basePath}/${folderName}` : folderName;
const hasChildren = Object.keys(children).length > 0;
const isExpanded = this.expandedNodes.has(currentPath);
const isSelected = this.selectedPath === currentPath;
return `
<div class="tree-node ${hasChildren ? 'has-children' : ''}" data-path="${currentPath}">
<div class="tree-node-content ${isSelected ? 'selected' : ''}">
<div class="tree-expand-icon ${isExpanded ? 'expanded' : ''}"
style="${hasChildren ? '' : 'opacity: 0; pointer-events: none;'}">
<i class="fas fa-chevron-right"></i>
</div>
<div class="tree-folder-icon">
<i class="fas fa-folder"></i>
</div>
<div class="tree-folder-name">${folderName}</div>
</div>
${hasChildren ? `
<div class="tree-children ${isExpanded ? 'expanded' : ''}">
${this.renderTreeNode(children, currentPath)}
</div>
` : ''}
</div>
`;
}).join('');
}
/**
* Handle tree node clicks
*/
handleTreeClick(event) {
const expandIcon = event.target.closest('.tree-expand-icon');
const nodeContent = event.target.closest('.tree-node-content');
if (expandIcon) {
// Toggle expand/collapse
const treeNode = expandIcon.closest('.tree-node');
const path = treeNode.dataset.path;
const children = treeNode.querySelector('.tree-children');
if (this.expandedNodes.has(path)) {
this.expandedNodes.delete(path);
expandIcon.classList.remove('expanded');
if (children) children.classList.remove('expanded');
} else {
this.expandedNodes.add(path);
expandIcon.classList.add('expanded');
if (children) children.classList.add('expanded');
}
} else if (nodeContent) {
// Select folder
const treeNode = nodeContent.closest('.tree-node');
const path = treeNode.dataset.path;
this.selectPath(path);
}
}
/**
* Handle path input changes
*/
handlePathInput(event) {
const input = event.target;
const query = input.value.toLowerCase();
this.activeSuggestionIndex = -1; // Reset active suggestion
if (query.length === 0) {
this.hideSuggestions();
return;
}
const matches = this.pathSuggestions.filter(path =>
path.toLowerCase().includes(query)
).slice(0, 10); // Limit to 10 suggestions
this.showSuggestions(matches, query);
}
/**
* Show path suggestions
*/
showSuggestions(suggestions, query) {
const suggestionsEl = document.getElementById(this.getElementId('pathSuggestions'));
if (!suggestionsEl) return;
if (suggestions.length === 0) {
this.hideSuggestions();
return;
}
suggestionsEl.innerHTML = suggestions.map(path => {
const highlighted = this.highlightMatch(path, query);
return `<div class="path-suggestion" data-path="${path}">${highlighted}</div>`;
}).join('');
suggestionsEl.style.display = 'block';
this.activeSuggestionIndex = -1; // Reset active index
}
/**
* Hide path suggestions
*/
hideSuggestions() {
const suggestionsEl = document.getElementById(this.getElementId('pathSuggestions'));
if (suggestionsEl) {
suggestionsEl.style.display = 'none';
}
}
/**
* Highlight matching text in suggestions
*/
highlightMatch(text, query) {
const index = text.toLowerCase().indexOf(query.toLowerCase());
if (index === -1) return text;
return text.substring(0, index) +
`<strong>${text.substring(index, index + query.length)}</strong>` +
text.substring(index + query.length);
}
/**
* Handle suggestion clicks
*/
handlePathSuggestionClick(event) {
const suggestion = event.target.closest('.path-suggestion');
if (suggestion) {
const path = suggestion.dataset.path;
this.selectPath(path);
this.hideSuggestions();
}
}
/**
* Handle create folder button click
*/
handleCreateFolder() {
const currentPath = this.selectedPath;
this.showCreateFolderForm(currentPath);
}
/**
* Show inline create folder form
*/
showCreateFolderForm(parentPath) {
// Find the parent node in the tree
const parentNode = parentPath ?
document.querySelector(`[data-path="${parentPath}"]`) :
document.getElementById(this.getElementId('folderTree'));
if (!parentNode) return;
// Check if form already exists
if (parentNode.querySelector('.create-folder-form')) return;
const form = document.createElement('div');
form.className = 'create-folder-form';
form.innerHTML = `
<input type="text" placeholder="New folder name" class="new-folder-input" />
<button type="button" class="confirm">✓</button>
<button type="button" class="cancel">✗</button>
`;
const input = form.querySelector('.new-folder-input');
const confirmBtn = form.querySelector('.confirm');
const cancelBtn = form.querySelector('.cancel');
confirmBtn.addEventListener('click', () => {
const folderName = input.value.trim();
if (folderName) {
this.createFolder(parentPath, folderName);
}
form.remove();
});
cancelBtn.addEventListener('click', () => {
form.remove();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
confirmBtn.click();
} else if (e.key === 'Escape') {
cancelBtn.click();
}
});
if (parentPath) {
// Add to children area
const childrenEl = parentNode.querySelector('.tree-children');
if (childrenEl) {
childrenEl.appendChild(form);
} else {
parentNode.appendChild(form);
}
} else {
// Add to root
parentNode.appendChild(form);
}
input.focus();
}
/**
* Create new folder
*/
createFolder(parentPath, folderName) {
const newPath = parentPath ? `${parentPath}/${folderName}` : folderName;
// Add to tree data
const pathParts = newPath.split('/');
let current = this.treeData;
for (const part of pathParts) {
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
// Update suggestions
this.pathSuggestions = this.extractAllPaths(this.treeData);
// Expand parent if needed
if (parentPath) {
this.expandedNodes.add(parentPath);
}
// Re-render tree
this.renderTree();
// Select the new folder
this.selectPath(newPath);
}
/**
* Handle breadcrumb navigation clicks
*/
handleBreadcrumbClick(event) {
const breadcrumbItem = event.target.closest('.breadcrumb-item');
if (breadcrumbItem) {
const path = breadcrumbItem.dataset.path;
this.selectPath(path);
}
}
/**
* Select a path and update UI
*/
selectPath(path) {
this.selectedPath = path;
// Update path input
const pathInput = document.getElementById(this.getElementId('folderPath'));
if (pathInput) {
pathInput.value = path;
}
// Update tree selection
const treeContainer = document.getElementById(this.getElementId('folderTree'));
if (treeContainer) {
treeContainer.querySelectorAll('.tree-node-content').forEach(node => {
node.classList.remove('selected');
});
const selectedNode = treeContainer.querySelector(`[data-path="${path}"] .tree-node-content`);
if (selectedNode) {
selectedNode.classList.add('selected');
// Expand parents to show selection
this.expandPathParents(path);
}
}
// Update breadcrumbs
this.updateBreadcrumbs(path);
// Trigger callback
if (this.onPathChangeCallback) {
this.onPathChangeCallback(path);
}
}
/**
* Expand all parent nodes of a given path
*/
expandPathParents(path) {
const parts = path.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
this.expandedNodes.add(currentPath);
}
this.renderTree();
}
/**
* Update breadcrumb navigation
*/
updateBreadcrumbs(path) {
const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav'));
if (!breadcrumbNav) return;
const parts = path ? path.split('/') : [];
let currentPath = '';
const breadcrumbs = [`
<span class="breadcrumb-item ${!path ? 'active' : ''}" data-path="">
<i class="fas fa-home"></i> Root
</span>
`];
parts.forEach((part, index) => {
currentPath = currentPath ? `${currentPath}/${part}` : part;
const isLast = index === parts.length - 1;
if (index > 0) {
breadcrumbs.push(`<span class="breadcrumb-separator">/</span>`);
}
breadcrumbs.push(`
<span class="breadcrumb-item ${isLast ? 'active' : ''}" data-path="${currentPath}">
${part}
</span>
`);
});
breadcrumbNav.innerHTML = breadcrumbs.join('');
}
/**
* Select current input value as path
*/
selectCurrentInput() {
const pathInput = document.getElementById(this.getElementId('folderPath'));
if (pathInput) {
const path = pathInput.value.trim();
this.selectPath(path);
}
}
/**
* Get the currently selected path
*/
getSelectedPath() {
return this.selectedPath;
}
/**
* Clear selection
*/
clearSelection() {
this.selectPath('');
}
/**
* Clean up event handlers
*/
destroy() {
const pathInput = document.getElementById(this.getElementId('folderPath'));
const createFolderBtn = document.getElementById(this.getElementId('createFolderBtn'));
const folderTree = document.getElementById(this.getElementId('folderTree'));
const breadcrumbNav = document.getElementById(this.getElementId('breadcrumbNav'));
const pathSuggestions = document.getElementById(this.getElementId('pathSuggestions'));
if (pathInput) {
pathInput.removeEventListener('input', this.handlePathInput);
pathInput.removeEventListener('keydown', this.handlePathKeyDown);
}
if (createFolderBtn) {
createFolderBtn.removeEventListener('click', this.handleCreateFolder);
}
if (folderTree) {
folderTree.removeEventListener('click', this.handleTreeClick);
}
if (breadcrumbNav) {
breadcrumbNav.removeEventListener('click', this.handleBreadcrumbClick);
}
if (pathSuggestions) {
pathSuggestions.removeEventListener('click', this.handlePathSuggestionClick);
}
}
}

View File

@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js';
import { resetAndReload} from '../api/modelApiFactory.js';
import { LoadingManager } from '../managers/LoadingManager.js';
import { getShowDuplicatesNotification, setShowDuplicatesNotification } from '../utils/storageHelpers.js';
export class ModelDuplicatesManager {
constructor(pageManager, modelType = 'loras') {
@@ -12,13 +12,21 @@ export class ModelDuplicatesManager {
this.inDuplicateMode = false;
this.selectedForDeletion = new Set();
this.modelType = modelType; // Use the provided modelType or default to 'loras'
// Verification tracking
this.verifiedGroups = new Set(); // Track which groups have been verified
this.mismatchedFiles = new Map(); // Map file paths to actual hashes for mismatched files
// Loading manager for verification process
this.loadingManager = new LoadingManager();
// Badge visibility preference
this.showBadge = getShowDuplicatesNotification(); // Default to true (show badge)
// Event handler references for cleanup
this.badgeToggleHandler = null;
this.helpTooltipHandlers = {
mouseenter: null,
mouseleave: null,
click: null
};
// Bind methods
this.renderModelCard = this.renderModelCard.bind(this);
@@ -66,7 +74,16 @@ export class ModelDuplicatesManager {
const badge = document.getElementById('duplicatesBadge');
if (!badge) return;
// Check if badge should be hidden based on user preference
if (!this.showBadge && !this.inDuplicateMode) {
badge.style.display = 'none';
badge.textContent = '';
badge.classList.remove('pulse');
return;
}
if (count > 0) {
badge.style.display = 'inline-flex';
badge.textContent = count;
badge.classList.add('pulse');
} else {
@@ -136,6 +153,9 @@ export class ModelDuplicatesManager {
// Setup help tooltip behavior
this.setupHelpTooltip();
// Setup badge toggle control
this.setupBadgeToggle();
}
// Disable virtual scrolling if active
@@ -173,6 +193,9 @@ export class ModelDuplicatesManager {
const pageState = getCurrentPageState();
pageState.duplicatesMode = false;
// Clean up event handlers before hiding banner
this.cleanupEventHandlers();
// Hide duplicates banner
const banner = document.getElementById('duplicatesBanner');
if (banner) {
@@ -672,7 +695,11 @@ export class ModelDuplicatesManager {
if (!helpIcon || !helpTooltip) return;
helpIcon.addEventListener('mouseenter', (e) => {
// Clean up existing handlers first
this.cleanupHelpTooltipHandlers();
// Create new handler functions and store references
this.helpTooltipHandlers.mouseenter = (e) => {
// Get the container's positioning context
const bannerContent = helpIcon.closest('.banner-content');
@@ -693,18 +720,22 @@ export class ModelDuplicatesManager {
// Reposition relative to container if too close to right edge
helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`;
}
});
};
// Rest of the event listeners remain unchanged
helpIcon.addEventListener('mouseleave', () => {
this.helpTooltipHandlers.mouseleave = () => {
helpTooltip.style.display = 'none';
});
};
document.addEventListener('click', (e) => {
this.helpTooltipHandlers.click = (e) => {
if (!helpIcon.contains(e.target)) {
helpTooltip.style.display = 'none';
}
});
};
// Add event listeners
helpIcon.addEventListener('mouseenter', this.helpTooltipHandlers.mouseenter);
helpIcon.addEventListener('mouseleave', this.helpTooltipHandlers.mouseleave);
document.addEventListener('click', this.helpTooltipHandlers.click);
}
// Handle verify hashes button click
@@ -719,7 +750,7 @@ export class ModelDuplicatesManager {
}
// Show loading state
this.loadingManager.showSimpleLoading('Verifying hashes...');
state.loadingManager.showSimpleLoading('Verifying hashes...');
// Get file paths for all models in the group
const filePaths = group.models.map(model => model.file_path);
@@ -772,7 +803,87 @@ export class ModelDuplicatesManager {
showToast('Failed to verify hashes: ' + error.message, 'error');
} finally {
// Hide loading state
this.loadingManager.hide();
state.loadingManager.hide();
}
}
// Add this new method for badge toggle setup
setupBadgeToggle() {
const toggleControl = document.getElementById('badgeToggleControl');
const toggleInput = document.getElementById('badgeToggleInput');
if (!toggleControl || !toggleInput) return;
// Clean up existing handler first
this.cleanupBadgeToggleHandler();
// Set initial state based on stored preference (default to true/checked)
toggleInput.checked = this.showBadge;
// Create and store the handler function
this.badgeToggleHandler = (e) => {
this.showBadge = e.target.checked;
setShowDuplicatesNotification(this.showBadge);
// Update badge visibility immediately if not in duplicate mode
if (!this.inDuplicateMode) {
this.updateDuplicatesBadge(this.duplicateGroups.length);
}
showToast(
this.showBadge ? 'Duplicates notification will be shown' : 'Duplicates notification will be hidden',
'info'
);
};
// Add change event listener
toggleInput.addEventListener('change', this.badgeToggleHandler);
}
// Clean up all event handlers
cleanupEventHandlers() {
this.cleanupBadgeToggleHandler();
this.cleanupHelpTooltipHandlers();
}
// Clean up badge toggle event handler
cleanupBadgeToggleHandler() {
if (this.badgeToggleHandler) {
const toggleInput = document.getElementById('badgeToggleInput');
if (toggleInput) {
toggleInput.removeEventListener('change', this.badgeToggleHandler);
}
this.badgeToggleHandler = null;
}
}
// Clean up help tooltip event handlers
cleanupHelpTooltipHandlers() {
const helpIcon = document.getElementById('duplicatesHelp');
if (helpIcon && this.helpTooltipHandlers.mouseenter) {
helpIcon.removeEventListener('mouseenter', this.helpTooltipHandlers.mouseenter);
}
if (helpIcon && this.helpTooltipHandlers.mouseleave) {
helpIcon.removeEventListener('mouseleave', this.helpTooltipHandlers.mouseleave);
}
if (this.helpTooltipHandlers.click) {
document.removeEventListener('click', this.helpTooltipHandlers.click);
}
// Reset handler references
this.helpTooltipHandlers = {
mouseenter: null,
mouseleave: null,
click: null
};
// Hide tooltip if it's visible
const helpTooltip = document.getElementById('duplicatesHelpTooltip');
if (helpTooltip) {
helpTooltip.style.display = 'none';
}
}
}

View File

@@ -2,7 +2,6 @@
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { updateRecipeCard } from '../utils/cardUpdater.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
class RecipeModal {
@@ -879,7 +878,7 @@ class RecipeModal {
// Model identifiers
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
modelVersionId: civitaiInfo.id || lora.modelVersionId,
id: civitaiInfo.id || lora.modelVersionId,
// Metadata
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',

View File

@@ -273,18 +273,27 @@ function showExampleAccessModal(card, modelType) {
if (hasRemoteExamples) {
downloadBtn.classList.remove('disabled');
downloadBtn.removeAttribute('title');
downloadBtn.onclick = () => {
downloadBtn.onclick = async () => {
// Get the model hash
const modelHash = card.dataset.sha256;
if (!modelHash) {
showToast('Missing model hash information.', 'error');
return;
}
// Close the modal
modalManager.closeModal('exampleAccessModal');
// Open settings modal and scroll to example images section
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
modalManager.showModal('settingsModal');
setTimeout(() => {
const exampleSection = settingsModal.querySelector('.settings-section:nth-child(7)');
if (exampleSection) {
exampleSection.scrollIntoView({ behavior: 'smooth' });
}
}, 300);
try {
// Use the appropriate model API client to download examples
const apiClient = getModelApiClient(modelType);
await apiClient.downloadExampleImages([modelHash]);
// Open the example images folder if successful
openExampleImagesFolder(modelHash);
} catch (error) {
console.error('Error downloading example images:', error);
// Error already shown by the API client
}
};
} else {

View File

@@ -183,7 +183,7 @@ export function setupBaseModelEditing(filePath) {
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.UNKNOWN
BASE_MODELS.QWEN, BASE_MODELS.UNKNOWN
]
};
@@ -426,4 +426,4 @@ export function setupFileNameEditing(filePath) {
fileNameWrapper.classList.remove('editing');
editBtn.classList.remove('visible');
}
}
}

View File

@@ -62,6 +62,12 @@ class BannerService {
*/
registerBanner(id, bannerConfig) {
this.banners.set(id, bannerConfig);
// If already initialized, render the banner immediately
if (this.initialized && !this.isBannerDismissed(id) && this.container) {
this.renderBanner(bannerConfig);
this.updateContainerVisibility();
}
}
/**
@@ -88,6 +94,12 @@ class BannerService {
// Remove banner from DOM
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
if (bannerElement) {
// Call onRemove callback if provided
const banner = this.banners.get(bannerId);
if (banner && typeof banner.onRemove === 'function') {
banner.onRemove(bannerElement);
}
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => {
bannerElement.remove();
@@ -122,12 +134,16 @@ class BannerService {
bannerElement.className = 'banner-item';
bannerElement.setAttribute('data-banner-id', banner.id);
const actionsHtml = banner.actions ? banner.actions.map(action =>
`<a href="${action.url}" target="_blank" class="banner-action banner-action-${action.type}" rel="noopener noreferrer">
const actionsHtml = banner.actions ? banner.actions.map(action => {
const actionAttribute = action.action ? `data-action="${action.action}"` : '';
const href = action.url ? `href="${action.url}"` : '#';
const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : '';
return `<a ${href ? `href="${href}"` : ''} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
<i class="${action.icon}"></i>
<span>${action.text}</span>
</a>`
).join('') : '';
</a>`;
}).join('') : '';
const dismissButtonHtml = banner.dismissible ?
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}')" title="Dismiss">
@@ -148,6 +164,11 @@ class BannerService {
`;
this.container.appendChild(bannerElement);
// Call onRegister callback if provided
if (typeof banner.onRegister === 'function') {
banner.onRegister(bannerElement);
}
}
/**

View File

@@ -203,7 +203,6 @@ export class BulkManager {
toggleCardSelection(card) {
const filepath = card.dataset.filepath;
const pageState = getCurrentPageState();
if (card.classList.contains('selected')) {
card.classList.remove('selected');

View File

@@ -1,8 +1,10 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
export class DownloadManager {
constructor() {
@@ -15,8 +17,10 @@ export class DownloadManager {
this.initialized = false;
this.selectedFolder = '';
this.apiClient = null;
this.useDefaultPath = false;
this.loadingManager = new LoadingManager();
this.folderTreeManager = new FolderTreeManager();
this.folderClickHandler = null;
this.updateTargetPath = this.updateTargetPath.bind(this);
@@ -27,6 +31,7 @@ export class DownloadManager {
this.handleBackToUrl = this.backToUrl.bind(this);
this.handleBackToVersions = this.backToVersions.bind(this);
this.handleCloseModal = this.closeModal.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
}
showDownloadModal() {
@@ -71,6 +76,9 @@ export class DownloadManager {
document.getElementById('backToUrlBtn').addEventListener('click', this.handleBackToUrl);
document.getElementById('backToVersionsBtn').addEventListener('click', this.handleBackToVersions);
document.getElementById('closeDownloadModal').addEventListener('click', this.handleCloseModal);
// Default path toggle handler
document.getElementById('useDefaultPath').addEventListener('change', this.handleToggleDefaultPath);
}
updateModalLabels() {
@@ -106,9 +114,10 @@ export class DownloadManager {
document.getElementById('modelUrl').value = '';
document.getElementById('urlError').textContent = '';
const newFolderInput = document.getElementById('newFolder');
if (newFolderInput) {
newFolderInput.value = '';
// Clear folder path input
const folderPathInput = document.getElementById('folderPath');
if (folderPathInput) {
folderPathInput.value = '';
}
this.currentVersion = null;
@@ -118,11 +127,14 @@ export class DownloadManager {
this.modelVersionId = null;
this.selectedFolder = '';
const folderBrowser = document.getElementById('folderBrowser');
if (folderBrowser) {
folderBrowser.querySelectorAll('.folder-item').forEach(f =>
f.classList.remove('selected'));
// Clear folder tree selection
if (this.folderTreeManager) {
this.folderTreeManager.clearSelection();
}
// Reset default path toggle
this.loadDefaultPathSetting();
}
async validateAndFetchVersions() {
@@ -285,8 +297,6 @@ export class DownloadManager {
document.getElementById('locationStep').style.display = 'block';
try {
const config = this.apiClient.apiConfig.config;
// Fetch model roots
const rootsData = await this.apiClient.fetchModelRoots();
const modelRoot = document.getElementById('modelRoot');
@@ -295,7 +305,8 @@ export class DownloadManager {
).join('');
// Set default root if available
const defaultRootKey = `default_${this.apiClient.modelType}_root`;
const singularType = this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
console.log('Available roots:', rootsData.roots);
@@ -304,20 +315,86 @@ export class DownloadManager {
modelRoot.value = defaultRoot;
}
// Fetch folders
const foldersData = await this.apiClient.fetchModelFolders();
const folderBrowser = document.getElementById('folderBrowser');
folderBrowser.innerHTML = foldersData.folders.map(folder =>
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
).join('');
// Set autocomplete="off" on folderPath input
const folderPathInput = document.getElementById('folderPath');
if (folderPathInput) {
folderPathInput.setAttribute('autocomplete', 'off');
}
this.initializeFolderBrowser();
// Initialize folder tree
await this.initializeFolderTree();
// Setup folder tree manager
this.folderTreeManager.init({
onPathChange: (path) => {
this.selectedFolder = path;
this.updateTargetPath();
}
});
// Setup model root change handler
modelRoot.addEventListener('change', async () => {
await this.initializeFolderTree();
this.updateTargetPath();
});
// Load default path setting for current model type
this.loadDefaultPathSetting();
this.updateTargetPath();
} catch (error) {
showToast(error.message, 'error');
}
}
loadDefaultPathSetting() {
const modelType = this.apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
this.useDefaultPath = getStorageItem(storageKey, false);
const toggleInput = document.getElementById('useDefaultPath');
if (toggleInput) {
toggleInput.checked = this.useDefaultPath;
this.updatePathSelectionUI();
}
}
toggleDefaultPath(event) {
this.useDefaultPath = event.target.checked;
// Save to localStorage per model type
const modelType = this.apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
setStorageItem(storageKey, this.useDefaultPath);
this.updatePathSelectionUI();
this.updateTargetPath();
}
updatePathSelectionUI() {
const manualSelection = document.getElementById('manualPathSelection');
// Always show manual path selection, but disable/enable based on useDefaultPath
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;
});
}
// Always update the main path display
this.updateTargetPath();
}
backToUrl() {
document.getElementById('versionStep').style.display = 'none';
document.getElementById('urlStep').style.display = 'block';
@@ -329,12 +406,15 @@ export class DownloadManager {
}
closeModal() {
// Clean up folder tree manager
if (this.folderTreeManager) {
this.folderTreeManager.destroy();
}
modalManager.closeModal('downloadModal');
}
async startDownload() {
const modelRoot = document.getElementById('modelRoot').value;
const newFolder = document.getElementById('newFolder').value.trim();
const config = this.apiClient.apiConfig.config;
if (!modelRoot) {
@@ -342,14 +422,15 @@ export class DownloadManager {
return;
}
// Construct relative path
// Determine target folder and use_default_paths parameter
let targetFolder = '';
if (this.selectedFolder) {
targetFolder = this.selectedFolder;
}
if (newFolder) {
targetFolder = targetFolder ?
`${targetFolder}/${newFolder}` : newFolder;
let useDefaultPaths = false;
if (this.useDefaultPath) {
useDefaultPaths = true;
targetFolder = ''; // Not needed when using default paths
} else {
targetFolder = this.folderTreeManager.getSelectedPath();
}
try {
@@ -389,12 +470,13 @@ export class DownloadManager {
console.error('WebSocket error:', error);
};
// Start download
// Start download with use_default_paths parameter
await this.apiClient.downloadModel(
this.modelId,
this.currentVersion.id,
modelRoot,
targetFolder,
useDefaultPaths,
downloadId
);
@@ -405,19 +487,22 @@ export class DownloadManager {
// Update state and trigger reload
const pageState = this.apiClient.getPageState();
pageState.activeFolder = targetFolder;
// Save the active folder preference
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
// Update UI folder selection
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
const isActive = tag.dataset.folder === targetFolder;
tag.classList.toggle('active', isActive);
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
if (!useDefaultPaths) {
pageState.activeFolder = targetFolder;
// Save the active folder preference
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
// Update UI folder selection
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
const isActive = tag.dataset.folder === targetFolder;
tag.classList.toggle('active', isActive);
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}
await resetAndReload(true);
@@ -428,6 +513,24 @@ export class DownloadManager {
}
}
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');
}
}
initializeFolderBrowser() {
const folderBrowser = document.getElementById('folderBrowser');
if (!folderBrowser) return;
@@ -481,17 +584,28 @@ export class DownloadManager {
updateTargetPath() {
const pathDisplay = document.getElementById('targetPathDisplay');
const modelRoot = document.getElementById('modelRoot').value;
const newFolder = document.getElementById('newFolder').value.trim();
const config = this.apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName} root directory`;
if (modelRoot) {
if (this.selectedFolder) {
fullPath += '/' + this.selectedFolder;
}
if (newFolder) {
fullPath += '/' + newFolder;
if (this.useDefaultPath) {
// Show actual template path
try {
const singularType = this.apiClient.modelType.replace(/s$/, '');
const templates = state.global.settings.download_path_templates;
const template = templates[singularType];
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;
}
}
}

View File

@@ -4,8 +4,13 @@ import { ImportStepManager } from './import/ImportStepManager.js';
import { ImageProcessor } from './import/ImageProcessor.js';
import { RecipeDataManager } from './import/RecipeDataManager.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 { 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 {
constructor() {
@@ -20,6 +25,8 @@ export class ImportManager {
this.downloadableLoRAs = [];
this.recipeId = null;
this.importMode = 'url'; // Default mode: 'url' or 'upload'
this.useDefaultPath = false;
this.apiClient = null;
// Initialize sub-managers
this.loadingManager = new LoadingManager();
@@ -27,10 +34,12 @@ export class ImportManager {
this.imageProcessor = new ImageProcessor(this);
this.recipeDataManager = new RecipeDataManager(this);
this.downloadManager = new DownloadManager(this);
this.folderBrowser = new FolderBrowser(this);
this.folderTreeManager = new FolderTreeManager();
// Bind methods
this.formatFileSize = formatFileSize;
this.updateTargetPath = this.updateTargetPath.bind(this);
this.handleToggleDefaultPath = this.toggleDefaultPath.bind(this);
}
showImportModal(recipeData = null, recipeId = null) {
@@ -40,9 +49,13 @@ export class ImportManager {
console.error('Import modal element not found');
return;
}
this.initializeEventHandlers();
this.initialized = true;
}
// Get API client for LoRAs
this.apiClient = getModelApiClient(MODEL_TYPES.LORA);
// Reset state
this.resetSteps();
if (recipeData) {
@@ -52,14 +65,12 @@ export class ImportManager {
// Show modal
modalManager.showModal('importModal', null, () => {
this.folderBrowser.cleanup();
this.cleanupFolderBrowser();
this.stepManager.removeInjectedStyles();
});
// Verify visibility and focus on URL input
setTimeout(() => {
this.ensureModalVisible();
setTimeout(() => {
// Ensure URL option is selected and focus on the input
this.toggleImportMode('url');
const urlInput = document.getElementById('imageUrlInput');
@@ -69,6 +80,14 @@ export class ImportManager {
}, 50);
}
initializeEventHandlers() {
// Default path toggle handler
const useDefaultPathToggle = document.getElementById('importUseDefaultPath');
if (useDefaultPathToggle) {
useDefaultPathToggle.addEventListener('change', this.handleToggleDefaultPath);
}
}
resetSteps() {
// Clear UI state
this.stepManager.removeInjectedStyles();
@@ -93,6 +112,12 @@ export class ImportManager {
const tagsContainer = document.getElementById('tagsContainer');
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
this.recipeImage = null;
this.recipeData = null;
@@ -100,33 +125,19 @@ export class ImportManager {
this.recipeTags = [];
this.missingLoras = [];
this.downloadableLoRAs = [];
this.selectedFolder = '';
// Reset import mode
this.importMode = 'url';
this.toggleImportMode('url');
// Reset folder browser
this.selectedFolder = '';
const folderBrowser = document.getElementById('importFolderBrowser');
if (folderBrowser) {
folderBrowser.querySelectorAll('.folder-item').forEach(f =>
f.classList.remove('selected'));
// Clear folder tree selection
if (this.folderTreeManager) {
this.folderTreeManager.clearSelection();
}
// Clear missing LoRAs list
const missingLorasList = document.getElementById('missingLorasList');
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 default path toggle
this.loadDefaultPathSetting();
// Reset duplicate related properties
this.duplicateRecipes = [];
@@ -204,7 +215,54 @@ export class ImportManager {
}
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() {
@@ -234,25 +292,107 @@ export class ImportManager {
await this.downloadManager.saveRecipe();
}
updateTargetPath() {
this.folderBrowser.updateTargetPath();
loadDefaultPathSetting() {
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() {
const importModal = document.getElementById('importModal');
if (!importModal) {
console.error('Import modal element not found');
return false;
toggleDefaultPath(event) {
this.useDefaultPath = event.target.checked;
// Save to localStorage for LoRAs
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
const modalDisplay = window.getComputedStyle(importModal).display;
if (modalDisplay !== 'block') {
console.error('Import modal is not visible, display: ' + modalDisplay);
return false;
// Always update the main path display
this.updateTargetPath();
}
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

@@ -4,48 +4,31 @@ import { modalManager } from './ModalManager.js';
import { bulkManager } from './BulkManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
class MoveManager {
constructor() {
this.currentFilePath = null;
this.bulkFilePaths = null;
this.modal = document.getElementById('moveModal');
this.modelRootSelect = document.getElementById('moveModelRoot');
this.folderBrowser = document.getElementById('moveFolderBrowser');
this.newFolderInput = document.getElementById('moveNewFolder');
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
this.modalTitle = document.getElementById('moveModalTitle');
this.rootLabel = document.getElementById('moveRootLabel');
this.initializeEventListeners();
this.folderTreeManager = new FolderTreeManager();
this.initialized = false;
// Bind methods
this.updateTargetPath = this.updateTargetPath.bind(this);
}
initializeEventListeners() {
if (this.initialized) return;
const modelRootSelect = document.getElementById('moveModelRoot');
// Initialize model root directory selector
this.modelRootSelect.addEventListener('change', () => this.updatePathPreview());
// Folder selection event
this.folderBrowser.addEventListener('click', (e) => {
const folderItem = e.target.closest('.folder-item');
if (!folderItem) return;
// If clicking already selected folder, deselect it
if (folderItem.classList.contains('selected')) {
folderItem.classList.remove('selected');
} else {
// Deselect other folders
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
item.classList.remove('selected');
});
// Select current folder
folderItem.classList.add('selected');
}
this.updatePathPreview();
modelRootSelect.addEventListener('change', async () => {
await this.initializeFolderTree();
this.updateTargetPath();
});
// New folder input event
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
this.initialized = true;
}
async showMoveModal(filePath, modelType = null) {
@@ -65,31 +48,30 @@ class MoveManager {
return;
}
this.bulkFilePaths = selectedPaths;
this.modalTitle.textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`;
document.getElementById('moveModalTitle').textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`;
} else {
// Single file mode
this.currentFilePath = filePath;
this.modalTitle.textContent = `Move ${modelConfig.displayName}`;
document.getElementById('moveModalTitle').textContent = `Move ${modelConfig.displayName}`;
}
// Update UI labels based on model type
this.rootLabel.textContent = `Select ${modelConfig.displayName} Root:`;
this.pathDisplay.querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`;
document.getElementById('moveRootLabel').textContent = `Select ${modelConfig.displayName} Root:`;
document.getElementById('moveTargetPathDisplay').querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`;
// Clear previous selections
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
item.classList.remove('selected');
});
this.newFolderInput.value = '';
// Clear folder path input
const folderPathInput = document.getElementById('moveFolderPath');
if (folderPathInput) {
folderPathInput.value = '';
}
try {
// Fetch model roots
const modelRootSelect = document.getElementById('moveModelRoot');
let rootsData;
if (modelType) {
// For checkpoints, use the specific API method that considers modelType
rootsData = await apiClient.fetchModelRoots(modelType);
} else {
// For other model types, use the generic method
rootsData = await apiClient.fetchModelRoots();
}
@@ -98,27 +80,38 @@ class MoveManager {
}
// Populate model root selector
this.modelRootSelect.innerHTML = rootsData.roots.map(root =>
modelRootSelect.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>`
).join('');
// Set default root if available
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; // Remove 's' from plural
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`;
const defaultRoot = getStorageItem('settings', {})[settingsKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
this.modelRootSelect.value = defaultRoot;
modelRootSelect.value = defaultRoot;
}
// Fetch folders dynamically
const foldersData = await apiClient.fetchModelFolders();
// Initialize event listeners
this.initializeEventListeners();
// Update folder browser with dynamic content
this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
).join('');
// Setup folder tree manager
this.folderTreeManager.init({
onPathChange: (path) => {
this.updateTargetPath();
},
elementsPrefix: 'move'
});
// Initialize folder tree
await this.initializeFolderTree();
this.updatePathPreview();
modalManager.showModal('moveModal');
this.updateTargetPath();
modalManager.showModal('moveModal', null, () => {
// Cleanup on modal close
if (this.folderTreeManager) {
this.folderTreeManager.destroy();
}
});
} catch (error) {
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
@@ -126,36 +119,60 @@ class MoveManager {
}
}
updatePathPreview() {
const selectedRoot = this.modelRootSelect.value;
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
const newFolder = this.newFolderInput.value.trim();
let targetPath = selectedRoot;
if (selectedFolder) {
targetPath = `${targetPath}/${selectedFolder}`;
async initializeFolderTree() {
try {
const apiClient = getModelApiClient();
// Fetch unified folder tree
const treeData = await 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');
}
if (newFolder) {
targetPath = `${targetPath}/${newFolder}`;
}
updateTargetPath() {
const pathDisplay = document.getElementById('moveTargetPathDisplay');
const modelRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient();
const config = apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
if (modelRoot) {
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
if (selectedPath) {
fullPath += '/' + selectedPath;
}
}
this.pathDisplay.querySelector('.path-text').textContent = targetPath;
pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`;
}
async moveModel() {
const selectedRoot = this.modelRootSelect.value;
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
const newFolder = this.newFolderInput.value.trim();
let targetPath = selectedRoot;
if (selectedFolder) {
targetPath = `${targetPath}/${selectedFolder}`;
}
if (newFolder) {
targetPath = `${targetPath}/${newFolder}`;
}
const selectedRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient();
const config = apiClient.apiConfig.config;
if (!selectedRoot) {
showToast(`Please select a ${config.displayName.toLowerCase()} root directory`, 'error');
return;
}
// Get selected folder path from folder tree manager
const targetFolder = this.folderTreeManager.getSelectedPath();
let targetPath = selectedRoot;
if (targetFolder) {
targetPath = `${targetPath}/${targetFolder}`;
}
try {
if (this.bulkFilePaths) {

View File

@@ -3,7 +3,7 @@ import { showToast } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js';
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js';
export class SettingsManager {
constructor() {
@@ -73,11 +73,30 @@ export class SettingsManager {
// We can delete the old setting, but keeping it for backwards compatibility
}
// Set default for download path template if undefined
if (state.global.settings.download_path_template === undefined) {
state.global.settings.download_path_template = DOWNLOAD_PATH_TEMPLATES.BASE_MODEL_TAG.value;
// Migrate legacy download_path_template to new structure
if (state.global.settings.download_path_template && !state.global.settings.download_path_templates) {
const legacyTemplate = state.global.settings.download_path_template;
state.global.settings.download_path_templates = {
lora: legacyTemplate,
checkpoint: legacyTemplate,
embedding: legacyTemplate
};
delete state.global.settings.download_path_template;
setStorageItem('settings', state.global.settings);
}
// Set default for download path templates if undefined
if (state.global.settings.download_path_templates === undefined) {
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
}
// Ensure all model types have templates
Object.keys(DEFAULT_PATH_TEMPLATES).forEach(modelType => {
if (typeof state.global.settings.download_path_templates[modelType] === 'undefined') {
state.global.settings.download_path_templates[modelType] = DEFAULT_PATH_TEMPLATES[modelType];
}
});
// Set default for base model path mappings if undefined
if (state.global.settings.base_model_path_mappings === undefined) {
state.global.settings.base_model_path_mappings = {};
@@ -105,7 +124,7 @@ export class SettingsManager {
'default_checkpoint_root',
'default_embedding_root',
'base_model_path_mappings',
'download_path_template'
'download_path_templates'
];
// Build payload for syncing
@@ -113,11 +132,7 @@ export class SettingsManager {
fieldsToSync.forEach(key => {
if (localSettings[key] !== undefined) {
if (key === 'base_model_path_mappings') {
payload[key] = JSON.stringify(localSettings[key]);
} else {
payload[key] = localSettings[key];
}
payload[key] = localSettings[key];
}
});
@@ -164,6 +179,30 @@ export class SettingsManager {
document.querySelectorAll('.toggle-visibility').forEach(button => {
button.addEventListener('click', () => this.toggleInputVisibility(button));
});
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (customInput) {
customInput.addEventListener('input', (e) => {
const template = e.target.value;
settingsManager.validateTemplate(modelType, template);
settingsManager.updateTemplatePreview(modelType, template);
});
customInput.addEventListener('blur', (e) => {
const template = e.target.value;
if (settingsManager.validateTemplate(modelType, template)) {
settingsManager.updateTemplate(modelType, template);
}
});
customInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.target.blur();
}
});
}
});
this.initialized = true;
}
@@ -211,12 +250,8 @@ export class SettingsManager {
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false;
}
// Set download path template setting
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate');
if (downloadPathTemplateSelect) {
downloadPathTemplateSelect.value = state.global.settings.download_path_template || '';
this.updatePathTemplatePreview();
}
// Load download path templates
this.loadDownloadPathTemplates();
// Set include trigger words setting
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
@@ -507,7 +542,7 @@ export class SettingsManager {
'Content-Type': 'application/json',
},
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
})
});
@@ -529,19 +564,184 @@ export class SettingsManager {
}
}
updatePathTemplatePreview() {
const templateSelect = document.getElementById('downloadPathTemplate');
const previewElement = document.getElementById('pathTemplatePreview');
if (!templateSelect || !previewElement) return;
const template = templateSelect.value;
const templateInfo = Object.values(DOWNLOAD_PATH_TEMPLATES).find(t => t.value === template);
loadDownloadPathTemplates() {
const templates = state.global.settings.download_path_templates || DEFAULT_PATH_TEMPLATES;
if (templateInfo) {
previewElement.textContent = templateInfo.example;
previewElement.style.display = 'block';
Object.keys(templates).forEach(modelType => {
this.loadTemplateForModelType(modelType, templates[modelType]);
});
}
loadTemplateForModelType(modelType, template) {
const presetSelect = document.getElementById(`${modelType}TemplatePreset`);
const customRow = document.getElementById(`${modelType}CustomRow`);
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (!presetSelect) return;
// Find matching preset
const matchingPreset = this.findMatchingPreset(template);
if (matchingPreset !== null) {
presetSelect.value = matchingPreset;
if (customRow) customRow.style.display = 'none';
} else {
previewElement.style.display = 'none';
// Custom template
presetSelect.value = 'custom';
if (customRow) customRow.style.display = 'block';
if (customInput) {
customInput.value = template;
this.validateTemplate(modelType, template);
}
}
this.updateTemplatePreview(modelType, template);
}
findMatchingPreset(template) {
const presetValues = Object.values(DOWNLOAD_PATH_TEMPLATES)
.map(t => t.value)
.filter(v => v !== 'custom');
return presetValues.includes(template) ? template : null;
}
updateTemplatePreset(modelType, value) {
const customRow = document.getElementById(`${modelType}CustomRow`);
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (value === 'custom') {
if (customRow) customRow.style.display = 'block';
if (customInput) customInput.focus();
return;
} else {
if (customRow) customRow.style.display = 'none';
}
// Update template
this.updateTemplate(modelType, value);
}
updateTemplate(modelType, template) {
// Validate template if it's custom
if (document.getElementById(`${modelType}TemplatePreset`).value === 'custom') {
if (!this.validateTemplate(modelType, template)) {
return; // Don't save invalid templates
}
}
// Update state
if (!state.global.settings.download_path_templates) {
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
}
state.global.settings.download_path_templates[modelType] = template;
// Update preview
this.updateTemplatePreview(modelType, template);
// Save settings
this.saveDownloadPathTemplates();
}
validateTemplate(modelType, template) {
const validationElement = document.getElementById(`${modelType}Validation`);
if (!validationElement) return true;
// Reset validation state
validationElement.innerHTML = '';
validationElement.className = 'template-validation';
if (!template) {
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid (flat structure)';
validationElement.classList.add('valid');
return true;
}
// Check for invalid characters
const invalidChars = /[<>:"|?*]/;
if (invalidChars.test(template)) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Invalid characters detected';
validationElement.classList.add('invalid');
return false;
}
// Check for double slashes
if (template.includes('//')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Double slashes not allowed';
validationElement.classList.add('invalid');
return false;
}
// Check if it starts or ends with slash
if (template.startsWith('/') || template.endsWith('/')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Cannot start or end with slash';
validationElement.classList.add('invalid');
return false;
}
// Extract placeholders
const placeholderRegex = /\{([^}]+)\}/g;
const matches = template.match(placeholderRegex) || [];
// Check for invalid placeholders
const invalidPlaceholders = matches.filter(match =>
!PATH_TEMPLATE_PLACEHOLDERS.includes(match)
);
if (invalidPlaceholders.length > 0) {
validationElement.innerHTML = `<i class="fas fa-times"></i> Invalid placeholder: ${invalidPlaceholders[0]}`;
validationElement.classList.add('invalid');
return false;
}
// Template is valid
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid template';
validationElement.classList.add('valid');
return true;
}
updateTemplatePreview(modelType, template) {
const previewElement = document.getElementById(`${modelType}Preview`);
if (!previewElement) return;
if (!template) {
previewElement.textContent = 'model-name.safetensors';
} else {
// Generate example preview
const exampleTemplate = template
.replace('{base_model}', 'Flux.1 D')
.replace('{author}', 'authorname')
.replace('{first_tag}', 'style');
previewElement.textContent = `${exampleTemplate}/model-name.safetensors`;
}
previewElement.style.display = 'block';
}
async saveDownloadPathTemplates() {
try {
// Save to localStorage
setStorageItem('settings', state.global.settings);
// Save to backend
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
download_path_templates: state.global.settings.download_path_templates
})
});
if (!response.ok) {
throw new Error('Failed to save download path templates');
}
showToast('Download path templates updated', 'success');
} catch (error) {
console.error('Error saving download path templates:', error);
showToast('Failed to save download path templates: ' + error.message, 'error');
}
}
@@ -651,9 +851,6 @@ export class SettingsManager {
state.global.settings.compactMode = (value !== 'default');
} else if (settingKey === 'card_info_display') {
state.global.settings.cardInfoDisplay = value;
} else if (settingKey === 'download_path_template') {
state.global.settings.download_path_template = value;
this.updatePathTemplatePreview();
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;
@@ -664,9 +861,13 @@ export class SettingsManager {
try {
// For backend settings, make API call
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_template') {
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_templates') {
const payload = {};
payload[settingKey] = value;
if (settingKey === 'download_path_templates') {
payload[settingKey] = state.global.settings.download_path_templates;
} else {
payload[settingKey] = value;
}
const response = await fetch('/api/settings', {
method: 'POST',

View File

@@ -1,5 +1,13 @@
import { modalManager } from './ModalManager.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import {
getStorageItem,
setStorageItem,
getStoredVersionInfo,
setStoredVersionInfo,
isVersionMatch,
resetDismissedBanner
} from '../utils/storageHelpers.js';
import { bannerService } from './BannerService.js';
export class UpdateService {
constructor() {
@@ -17,6 +25,8 @@ export class UpdateService {
this.lastCheckTime = parseInt(getStorageItem('last_update_check') || '0');
this.isUpdating = false;
this.nightlyMode = getStorageItem('nightly_updates', false);
this.currentVersionInfo = null;
this.versionMismatch = false;
}
initialize() {
@@ -59,6 +69,9 @@ export class UpdateService {
// Immediately update modal content with current values (even if from default)
this.updateModalContent();
// Check version info for mismatch after loading basic info
this.checkVersionInfo();
}
updateNightlyWarning() {
@@ -424,6 +437,110 @@ export class UpdateService {
// Ensure badge visibility is updated after manual check
this.updateBadgeVisibility();
}
async checkVersionInfo() {
try {
// Call API to get current version info
const response = await fetch('/api/version-info');
const data = await response.json();
if (data.success) {
this.currentVersionInfo = data.version;
// Check if version matches stored version
this.versionMismatch = !isVersionMatch(this.currentVersionInfo);
if (this.versionMismatch) {
console.log('Version mismatch detected:', {
current: this.currentVersionInfo,
stored: getStoredVersionInfo()
});
// Reset dismissed status for version mismatch banner
resetDismissedBanner('version-mismatch');
// Register and show the version mismatch banner
this.registerVersionMismatchBanner();
}
}
} catch (error) {
console.error('Failed to check version info:', error);
}
}
registerVersionMismatchBanner() {
// Get stored and current version for display
const storedVersion = getStoredVersionInfo() || 'unknown';
const currentVersion = this.currentVersionInfo || 'unknown';
bannerService.registerBanner('version-mismatch', {
id: 'version-mismatch',
title: 'Application Update Detected',
content: `Your browser is running an outdated version of LoRA Manager (${storedVersion}). The server has been updated to version ${currentVersion}. Please refresh to ensure proper functionality.`,
actions: [
{
text: 'Refresh Now',
icon: 'fas fa-sync',
action: 'hardRefresh',
type: 'primary'
}
],
dismissible: false,
priority: 10,
countdown: 15,
onRegister: (bannerElement) => {
// Add countdown element
const countdownEl = document.createElement('div');
countdownEl.className = 'banner-countdown';
countdownEl.innerHTML = `<span>Refreshing in <strong>15</strong> seconds...</span>`;
bannerElement.querySelector('.banner-content').appendChild(countdownEl);
// Start countdown
let seconds = 15;
const countdownInterval = setInterval(() => {
seconds--;
const strongEl = countdownEl.querySelector('strong');
if (strongEl) strongEl.textContent = seconds;
if (seconds <= 0) {
clearInterval(countdownInterval);
this.performHardRefresh();
}
}, 1000);
// Store interval ID for cleanup
bannerElement.dataset.countdownInterval = countdownInterval;
// Add action button event handler
const actionBtn = bannerElement.querySelector('.banner-action[data-action="hardRefresh"]');
if (actionBtn) {
actionBtn.addEventListener('click', (e) => {
e.preventDefault();
clearInterval(countdownInterval);
this.performHardRefresh();
});
}
},
onRemove: (bannerElement) => {
// Clear any existing interval
const intervalId = bannerElement.dataset.countdownInterval;
if (intervalId) {
clearInterval(parseInt(intervalId));
}
}
});
}
performHardRefresh() {
// Update stored version info before refreshing
setStoredVersionInfo(this.currentVersionInfo);
// Force a hard refresh by adding cache-busting parameter
const cacheBuster = new Date().getTime();
window.location.href = window.location.pathname +
(window.location.search ? window.location.search + '&' : '?') +
`cache=${cacheBuster}`;
}
}
// Create and export singleton instance

View File

@@ -1,4 +1,7 @@
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
export class DownloadManager {
constructor(importManager) {
@@ -118,14 +121,9 @@ export class DownloadManager {
}
// Build target path
let targetPath = loraRoot;
let targetPath = '';
if (this.importManager.selectedFolder) {
targetPath += '/' + this.importManager.selectedFolder;
}
const newFolder = document.getElementById('importNewFolder')?.value?.trim();
if (newFolder) {
targetPath += '/' + newFolder;
targetPath = this.importManager.selectedFolder;
}
// Generate a unique ID for this batch download
@@ -187,6 +185,8 @@ export class DownloadManager {
}
}
};
const useDefaultPaths = getStorageItem('use_default_path_loras', false);
for (let i = 0; i < this.importManager.downloadableLoRAs.length; i++) {
const lora = this.importManager.downloadableLoRAs[i];
@@ -200,38 +200,26 @@ export class DownloadManager {
try {
// Download the LoRA with download ID
const response = await fetch('/api/download-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: lora.modelId,
model_version_id: lora.id,
model_root: loraRoot,
relative_path: targetPath.replace(loraRoot + '/', ''),
download_id: batchDownloadId
})
});
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel(
lora.modelId,
lora.id,
loraRoot,
targetPath.replace(loraRoot + '/', ''),
useDefaultPaths,
batchDownloadId
);
if (!response.ok) {
const errorText = await response.text();
console.error(`Failed to download LoRA ${lora.name}: ${errorText}`);
// Check if this is an early access error (status 401 is the key indicator)
if (response.status === 401) {
accessFailures++;
this.importManager.loadingManager.setStatus(
`Failed to download ${lora.name}: Access restricted`
);
}
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`);
failedDownloads++;
// Continue with next download
} else {
completedDownloads++;
// Update progress to show completion of current LoRA
updateProgress(100, completedDownloads, '');
if (completedDownloads + failedDownloads < this.importManager.downloadableLoRAs.length) {
this.importManager.loadingManager.setStatus(
`Completed ${completedDownloads}/${this.importManager.downloadableLoRAs.length} LoRAs. Starting next download...`

View File

@@ -35,6 +35,7 @@ export const BASE_MODELS = {
ILLUSTRIOUS: "Illustrious",
PONY: "Pony",
HIDREAM: "HiDream",
QWEN: "Qwen",
// Video models
SVD: "SVD",
@@ -93,6 +94,7 @@ export const BASE_MODEL_CLASSES = {
[BASE_MODELS.ILLUSTRIOUS]: "il",
[BASE_MODELS.PONY]: "pony",
[BASE_MODELS.HIDREAM]: "hidream",
[BASE_MODELS.QWEN]: "qwen",
// Default
[BASE_MODELS.UNKNOWN]: "unknown"
@@ -112,6 +114,12 @@ export const DOWNLOAD_PATH_TEMPLATES = {
description: 'Organize by base model type',
example: 'Flux.1 D/model-name.safetensors'
},
AUTHOR: {
value: '{author}',
label: 'By Author',
description: 'Organize by model author',
example: 'authorname/model-name.safetensors'
},
FIRST_TAG: {
value: '{first_tag}',
label: 'By First Tag',
@@ -123,9 +131,48 @@ export const DOWNLOAD_PATH_TEMPLATES = {
label: 'Base Model + First Tag',
description: 'Organize by base model and primary tag',
example: 'Flux.1 D/style/model-name.safetensors'
},
BASE_MODEL_AUTHOR: {
value: '{base_model}/{author}',
label: 'Base Model + Author',
description: 'Organize by base model and author',
example: 'Flux.1 D/authorname/model-name.safetensors'
},
AUTHOR_TAG: {
value: '{author}/{first_tag}',
label: 'Author + First Tag',
description: 'Organize by author and primary tag',
example: 'authorname/style/model-name.safetensors'
},
CUSTOM: {
value: 'custom',
label: 'Custom Template',
description: 'Create your own path structure',
example: 'Enter custom template...'
}
};
// Valid placeholders for path templates
export const PATH_TEMPLATE_PLACEHOLDERS = [
'{base_model}',
'{author}',
'{first_tag}'
];
// Default templates for each model type
export const DEFAULT_PATH_TEMPLATES = {
lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}',
embedding: '{first_tag}'
};
// Model type labels for UI
export const MODEL_TYPE_LABELS = {
lora: 'LoRA Models',
checkpoint: 'Checkpoint Models',
embedding: 'Embedding Models'
};
// Base models available for path mapping (for UI selection)
export const MAPPABLE_BASE_MODELS = Object.values(BASE_MODELS).sort();
@@ -161,4 +208,4 @@ export const NODE_TYPE_ICONS = {
};
// Default ComfyUI node color when bgcolor is null
export const DEFAULT_NODE_COLOR = "#353535";
export const DEFAULT_NODE_COLOR = "#353535";

View File

@@ -213,4 +213,61 @@ export function getMapFromStorage(key) {
console.error(`Error loading Map from localStorage (${key}):`, error);
return new Map();
}
}
/**
* Get stored version info from localStorage
* @returns {string|null} The stored version string or null if not found
*/
export function getStoredVersionInfo() {
return getStorageItem('version_info', null);
}
/**
* Store version info to localStorage
* @param {string} versionInfo - The version info string to store
*/
export function setStoredVersionInfo(versionInfo) {
setStorageItem('version_info', versionInfo);
}
/**
* Check if version info matches between stored and current
* @param {string} currentVersionInfo - The current version info from server
* @returns {boolean} True if versions match or no stored version exists
*/
export function isVersionMatch(currentVersionInfo) {
const storedVersion = getStoredVersionInfo();
// If we have no stored version yet, consider it a match
if (storedVersion === null) {
setStoredVersionInfo(currentVersionInfo);
return true;
}
return storedVersion === currentVersionInfo;
}
/**
* Reset the dismissed status of a specific banner
* @param {string} bannerId - The ID of the banner to un-dismiss
*/
export function resetDismissedBanner(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
const updatedBanners = dismissedBanners.filter(id => id !== bannerId);
setStorageItem('dismissed_banners', updatedBanners);
}
/**
* Get the show duplicates notification preference
* @returns {boolean} True if notification should be shown (default: true)
*/
export function getShowDuplicatesNotification() {
return getStorageItem('show_duplicates_notification', true);
}
/**
* Set the show duplicates notification preference
* @param {boolean} show - Whether to show the notification
*/
export function setShowDuplicatesNotification(show) {
setStorageItem('show_duplicates_notification', show);
}

View File

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

View File

@@ -23,6 +23,9 @@
<div class="context-menu-item" data-action="preview">
<i class="fas fa-folder-open"></i> Open Examples Folder
</div>
<div class="context-menu-item" data-action="download-examples">
<i class="fas fa-download"></i> Download Example Images
</div>
<div class="context-menu-item" data-action="replace-preview">
<i class="fas fa-image"></i> Replace Preview
</div>

View File

@@ -0,0 +1,27 @@
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
<div class="banner-content">
<i class="fas fa-exclamation-triangle"></i>
<span id="duplicatesCount">Found 0 duplicate groups</span>
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
<div class="banner-actions">
<div class="setting-contro" id="badgeToggleControl">
<span>Show Duplicates Notification:</span>
<label class="toggle-switch">
<input type="checkbox" id="badgeToggleInput">
<span class="toggle-slider"></span>
</label>
</div>
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
</button>
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
<i class="fas fa-times"></i> Exit Mode
</button>
</div>
</div>
<div class="help-tooltip" id="duplicatesHelpTooltip">
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
</div>
</div>

View File

@@ -1,7 +1,9 @@
<div id="importModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('importModal')">&times;</button>
<h2>Import Recipe</h2>
<div class="modal-header">
<button class="close" onclick="modalManager.closeModal('importModal')">&times;</button>
<h2>Import Recipe</h2>
</div>
<!-- Step 1: Upload Image or Input URL -->
<div class="import-step" id="uploadStep">
@@ -99,42 +101,59 @@
<!-- Step 3: Download Location (if needed) -->
<div class="import-step" id="locationStep" style="display: none;">
<div class="location-selection">
<!-- Improved missing LoRAs summary section -->
<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 -->
<!-- Path preview with inline toggle -->
<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">
<span class="path-text">Select a LoRA root directory</span>
</div>
</div>
<!-- Model Root Selection -->
<div class="input-group">
<label>Select LoRA Root:</label>
<label for="importLoraRoot">Select LoRA Root:</label>
<select id="importLoraRoot"></select>
</div>
<div class="input-group">
<label>Target Folder:</label>
<div class="folder-browser" id="importFolderBrowser">
<!-- Folders will be populated here -->
<!-- Manual Path Selection -->
<div class="manual-path-selection" id="importManualPathSelection">
<!-- Path input with autocomplete -->
<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 class="input-group">
<label for="importNewFolder">New Folder (optional):</label>
<input type="text" id="importNewFolder" placeholder="Enter folder name">
</div>
</div>

View File

@@ -1,8 +1,10 @@
<!-- Unified Download Modal for all model types -->
<div id="downloadModal" class="modal">
<div class="modal-content">
<button class="close" id="closeDownloadModal">&times;</button>
<h2 id="downloadModalTitle">Download Model from URL</h2>
<div class="modal-header">
<button class="close" id="closeDownloadModal">&times;</button>
<h2 id="downloadModalTitle">Download Model from URL</h2>
</div>
<!-- Step 1: URL Input -->
<div class="download-step" id="urlStep">
@@ -30,27 +32,59 @@
<!-- Step 3: Location Selection -->
<div class="download-step" id="locationStep" style="display: none;">
<div class="location-selection">
<!-- Path preview -->
<!-- Path preview with inline toggle -->
<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="useDefaultPath">
<label for="useDefaultPath" class="toggle-slider"></label>
</div>
</div>
</div>
<div class="path-display" id="targetPathDisplay">
<span class="path-text">Select a root directory</span>
</div>
</div>
<!-- Model Root Selection (always visible) -->
<div class="input-group">
<label for="modelRoot" id="modelRootLabel">Select Model Root:</label>
<select id="modelRoot"></select>
</div>
<div class="input-group">
<label>Target Folder:</label>
<div class="folder-browser" id="folderBrowser">
<!-- Folders will be loaded dynamically -->
<!-- Manual Path Selection (hidden when using default path) -->
<div class="manual-path-selection" id="manualPathSelection">
<!-- Path input with autocomplete -->
<div class="input-group">
<label for="folderPath">Target Folder Path:</label>
<div class="path-input-container">
<input type="text" id="folderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" />
<button type="button" id="createFolderBtn" class="create-folder-btn" title="Create new folder">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="path-suggestions" id="pathSuggestions" style="display: none;"></div>
</div>
<!-- Breadcrumb navigation -->
<div class="breadcrumb-nav" id="breadcrumbNav">
<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="folderTree">
<!-- Tree will be loaded dynamically -->
</div>
</div>
</div>
</div>
<div class="input-group">
<label for="newFolder">New Folder (optional):</label>
<input type="text" id="newFolder" placeholder="Enter folder name" />
</div>
</div>
<div class="modal-actions">

View File

@@ -6,6 +6,7 @@
<span class="close" onclick="modalManager.closeModal('moveModal')">&times;</span>
</div>
<div class="location-selection">
<!-- Path preview -->
<div class="path-preview">
<label>Target Location Preview:</label>
<div class="path-display" id="moveTargetPathDisplay">
@@ -14,18 +15,37 @@
</div>
<div class="input-group">
<label id="moveRootLabel">Select Model Root:</label>
<label for="moveModelRoot" id="moveRootLabel">Select Model Root:</label>
<select id="moveModelRoot"></select>
</div>
<!-- Path input with autocomplete -->
<div class="input-group">
<label>Target Folder:</label>
<div class="folder-browser" id="moveFolderBrowser">
<!-- Folders will be loaded dynamically -->
<label for="moveFolderPath">Target Folder Path:</label>
<div class="path-input-container">
<input type="text" id="moveFolderPath" placeholder="Type folder path or select from tree below..." autocomplete="off" />
<button type="button" id="moveCreateFolderBtn" class="create-folder-btn" title="Create new folder">
<i class="fas fa-plus"></i>
</button>
</div>
<div class="path-suggestions" id="movePathSuggestions" style="display: none;"></div>
</div>
<!-- Breadcrumb navigation -->
<div class="breadcrumb-nav" id="moveBreadcrumbNav">
<span class="breadcrumb-item root" data-path="">
<i class="fas fa-home"></i> Root
</span>
</div>
<!-- Hierarchical folder tree -->
<div class="input-group">
<label for="moveNewFolder">New Folder (optional):</label>
<input type="text" id="moveNewFolder" placeholder="Enter folder name" />
<label>Browse Folders:</label>
<div class="folder-tree-container">
<div class="folder-tree" id="moveFolderTree">
<!-- Tree will be loaded dynamically -->
</div>
</div>
</div>
</div>
<div class="modal-actions">

View File

@@ -90,6 +90,57 @@
</div>
</div>
</div>
<!-- Add Layout Settings Section -->
<div class="settings-section">
<h3>Layout Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="displayDensity">Display Density</label>
</div>
<div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
<option value="default">Default</option>
<option value="medium">Medium</option>
<option value="compact">Compact</option>
</select>
</div>
</div>
<div class="input-help">
Choose how many cards to display per row:
<ul class="list-description">
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
</ul>
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
</div>
</div>
<!-- Add Card Info Display setting -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label>
</div>
<div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')">
<option value="always">Always Visible</option>
<option value="hover">Reveal on Hover</option>
</select>
</div>
</div>
<div class="input-help">
Choose when to display model information and action buttons:
<ul class="list-description">
<li><strong>Always Visible:</strong> Headers and footers are always visible</li>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li>
</ul>
</div>
</div>
</div>
<!-- Add Folder Settings Section -->
<div class="settings-section">
@@ -149,108 +200,121 @@
<!-- Default Path Customization Section -->
<div class="settings-section">
<h3>Default Path Customization</h3>
<h3>Download Path Templates</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadPathTemplate">Download Path Template</label>
</div>
<div class="setting-control select-control">
<select id="downloadPathTemplate" onchange="settingsManager.saveSelectSetting('downloadPathTemplate', 'download_path_template')">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
</select>
</div>
</div>
<div class="input-help">
Configure path structure for default download locations
<ul class="list-description">
<li><strong>Flat:</strong> All models in root folder</li>
<li><strong>Base Model:</strong> Organized by model type (e.g., Flux.1 D, SDXL)</li>
<li><strong>First Tag:</strong> Organized by primary tag (e.g., style, character)</li>
<li><strong>Base Model + Tag:</strong> Two-level organization for better structure</li>
</ul>
Configure folder structures for different model types when downloading from Civitai.
<div class="placeholder-info">
<strong>Available placeholders:</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
</div>
</div>
<div id="pathTemplatePreview" class="template-preview"></div>
</div>
<!-- LoRA Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Base Model Path Mappings</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div>
</div>
</div>
</div>
<!-- Add Layout Settings Section -->
<div class="settings-section">
<h3>Layout Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="displayDensity">Display Density</label>
<label for="loraTemplatePreset">LoRA</label>
</div>
<div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
<option value="default">Default</option>
<option value="medium">Medium</option>
<option value="compact">Compact</option>
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="input-help">
Choose how many cards to display per row:
<ul class="list-description">
<li><strong>Default:</strong> 5 (1080p), 6 (2K), 8 (4K)</li>
<li><strong>Medium:</strong> 6 (1080p), 7 (2K), 9 (4K)</li>
<li><strong>Compact:</strong> 7 (1080p), 8 (2K), 10 (4K)</li>
</ul>
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<!-- Add Card Info Display setting -->
<!-- Checkpoint Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label>
<label for="checkpointTemplatePreset">Checkpoint</label>
</div>
<div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')">
<option value="always">Always Visible</option>
<option value="hover">Reveal on Hover</option>
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="input-help">
Choose when to display model information and action buttons:
<ul class="list-description">
<li><strong>Always Visible:</strong> Headers and footers are always visible</li>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li>
</ul>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<!-- Embedding Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">Embedding</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Base Model Path Mappings</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div>
</div>
</div>
<!-- Add Example Images Settings Section -->
<div class="settings-section">
<h3>Example Images</h3>

View File

@@ -18,6 +18,7 @@
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> Download Example Images</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
<div class="context-menu-separator"></div>
@@ -29,27 +30,7 @@
{% block content %}
{% include 'components/controls.html' %}
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
<div class="banner-content">
<i class="fas fa-exclamation-triangle"></i>
<span id="duplicatesCount">Found 0 duplicate groups</span>
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
<div class="banner-actions">
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
</button>
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
<i class="fas fa-times"></i> Exit Mode
</button>
</div>
</div>
<div class="help-tooltip" id="duplicatesHelpTooltip">
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
</div>
</div>
{% include 'components/duplicates_banner.html' %}
<!-- Embedding cards container -->
<div class="card-grid" id="modelGrid">

View File

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

466
web/comfyui/autocomplete.js Normal file
View File

@@ -0,0 +1,466 @@
import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
class AutoComplete {
constructor(inputElement, modelType = 'loras', options = {}) {
this.inputElement = inputElement;
this.modelType = modelType;
this.options = {
maxItems: 15,
minChars: 1,
debounceDelay: 200,
showPreview: true,
...options
};
this.dropdown = null;
this.selectedIndex = -1;
this.items = [];
this.debounceTimer = null;
this.isVisible = false;
this.currentSearchTerm = '';
this.previewTooltip = null;
// Initialize TextAreaCaretHelper
this.helper = new TextAreaCaretHelper(inputElement, () => app.canvas.ds.scale);
this.init();
}
init() {
this.createDropdown();
this.bindEvents();
}
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'comfy-autocomplete-dropdown';
// Apply new color scheme
this.dropdown.style.cssText = `
position: absolute;
z-index: 10000;
overflow-y: visible;
background-color: rgba(40, 44, 52, 0.95);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: none;
font-family: Arial, sans-serif;
font-size: 14px;
min-width: 200px;
width: auto;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
`;
// Custom scrollbar styles with new color scheme
const style = document.createElement('style');
style.textContent = `
.comfy-autocomplete-dropdown::-webkit-scrollbar {
width: 8px;
}
.comfy-autocomplete-dropdown::-webkit-scrollbar-track {
background: rgba(40, 44, 52, 0.3);
border-radius: 4px;
}
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb {
background: rgba(226, 232, 240, 0.2);
border-radius: 4px;
}
.comfy-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(226, 232, 240, 0.4);
}
`;
document.head.appendChild(style);
// Append to body to avoid overflow issues
document.body.appendChild(this.dropdown);
// Initialize preview tooltip if needed
if (this.options.showPreview && this.modelType === 'loras') {
this.initPreviewTooltip();
}
}
initPreviewTooltip() {
// Dynamically import and create preview tooltip
import('./loras_widget_components.js').then(module => {
this.previewTooltip = new module.PreviewTooltip();
}).catch(err => {
console.warn('Failed to load preview tooltip:', err);
});
}
bindEvents() {
// Handle input changes
this.inputElement.addEventListener('input', (e) => {
this.handleInput(e.target.value);
});
// Handle keyboard navigation
this.inputElement.addEventListener('keydown', (e) => {
this.handleKeyDown(e);
});
// Handle focus out to hide dropdown
this.inputElement.addEventListener('blur', (e) => {
// Delay hiding to allow for clicks on dropdown items
setTimeout(() => {
this.hide();
}, 150);
});
// Handle clicks outside to hide dropdown
document.addEventListener('click', (e) => {
if (!this.dropdown.contains(e.target) && e.target !== this.inputElement) {
this.hide();
}
});
}
handleInput(value = '') {
// Clear previous debounce timer
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
// Get the search term (text after last comma)
const searchTerm = this.getSearchTerm(value);
if (searchTerm.length < this.options.minChars) {
this.hide();
return;
}
// Debounce the search
this.debounceTimer = setTimeout(() => {
this.search(searchTerm);
}, this.options.debounceDelay);
}
getSearchTerm(value) {
// Use helper to get text before cursor for more accurate positioning
const beforeCursor = this.helper.getBeforeCursor();
if (!beforeCursor) {
return '';
}
// 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 = '') {
try {
this.currentSearchTerm = term;
const response = await api.fetchApi(`/${this.modelType}/relative-paths?search=${encodeURIComponent(term)}&limit=${this.options.maxItems}`);
const data = await response.json();
if (data.success && data.relative_paths && data.relative_paths.length > 0) {
this.items = data.relative_paths;
this.render();
this.show();
} else {
this.items = [];
this.hide();
}
} catch (error) {
console.error('Autocomplete search error:', error);
this.items = [];
this.hide();
}
}
render() {
this.dropdown.innerHTML = '';
this.selectedIndex = -1;
// Early return if no items to prevent empty dropdown
if (!this.items || this.items.length === 0) {
return;
}
this.items.forEach((relativePath, index) => {
const item = document.createElement('div');
item.className = 'comfy-autocomplete-item';
// Create highlighted content
const highlightedContent = this.highlightMatch(relativePath, this.currentSearchTerm);
item.innerHTML = highlightedContent;
// Apply item styles with new color scheme
item.style.cssText = `
padding: 8px 12px;
cursor: pointer;
color: rgba(226, 232, 240, 0.8);
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
transition: all 0.2s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: relative;
`;
// Hover and selection handlers
item.addEventListener('mouseenter', () => {
this.selectItem(index);
this.showPreviewForItem(relativePath, item);
});
item.addEventListener('mouseleave', () => {
this.hidePreview();
});
// Click handler
item.addEventListener('click', () => {
this.insertSelection(relativePath);
});
this.dropdown.appendChild(item);
});
// Remove border from last item
if (this.dropdown.lastChild) {
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) {
if (!searchTerm) return text;
const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<span style="background-color: rgba(66, 153, 225, 0.3); color: white; padding: 1px 2px; border-radius: 2px;">$1</span>');
}
showPreviewForItem(relativePath, itemElement) {
if (!this.previewTooltip) return;
// Extract filename without extension for preview
const fileName = relativePath.split('/').pop();
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
// Get item position for tooltip positioning
const rect = itemElement.getBoundingClientRect();
const x = rect.right + 10;
const y = rect.top;
this.previewTooltip.show(loraName, x, y);
}
hidePreview() {
if (this.previewTooltip) {
this.previewTooltip.hide();
}
}
show() {
if (!this.items || this.items.length === 0) {
this.hide();
return;
}
// Position dropdown at cursor position using TextAreaCaretHelper
this.positionAtCursor();
this.dropdown.style.display = 'block';
this.isVisible = true;
}
positionAtCursor() {
const position = this.helper.getCursorOffset();
this.dropdown.style.left = (position.left ?? 0) + "px";
this.dropdown.style.top = (position.top ?? 0) + "px";
this.dropdown.style.maxHeight = (window.innerHeight - position.top) + "px";
// Adjust width to fit content
// Temporarily show the dropdown to measure content width
const originalDisplay = this.dropdown.style.display;
this.dropdown.style.display = 'block';
this.dropdown.style.visibility = 'hidden';
// Measure the content width
let maxWidth = 200; // minimum width
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
items.forEach(item => {
const itemWidth = item.scrollWidth + 24; // Add padding
maxWidth = Math.max(maxWidth, itemWidth);
});
// Set the width and restore visibility
this.dropdown.style.width = Math.min(maxWidth, 400) + 'px'; // Cap at 400px
this.dropdown.style.visibility = 'visible';
this.dropdown.style.display = originalDisplay;
}
getCaretPosition() {
return this.inputElement.selectionStart || 0;
}
hide() {
this.dropdown.style.display = 'none';
this.isVisible = false;
this.selectedIndex = -1;
// Hide preview tooltip
this.hidePreview();
// Clear selection styles from all items
const items = this.dropdown.querySelectorAll('.comfy-autocomplete-item');
items.forEach(item => {
item.classList.remove('comfy-autocomplete-item-selected');
item.style.backgroundColor = '';
});
}
selectItem(index) {
// Remove previous selection
const prevSelected = this.dropdown.querySelector('.comfy-autocomplete-item-selected');
if (prevSelected) {
prevSelected.classList.remove('comfy-autocomplete-item-selected');
prevSelected.style.backgroundColor = '';
}
// Add new selection
if (index >= 0 && index < this.items.length) {
this.selectedIndex = index;
const item = this.dropdown.children[index];
item.classList.add('comfy-autocomplete-item-selected');
item.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
// Scroll into view if needed
item.scrollIntoView({ block: 'nearest' });
// Show preview for selected item
if (this.options.showPreview) {
this.showPreviewForItem(this.items[index], item);
}
}
}
handleKeyDown(e) {
if (!this.isVisible) {
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
this.selectItem(Math.max(this.selectedIndex - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
this.insertSelection(this.items[this.selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
this.hide();
break;
}
}
async insertSelection(relativePath) {
// Extract just the filename for LoRA name
const fileName = relativePath.split('/').pop().replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
// Get usage tips and extract strength
let strength = 1.0; // Default strength
try {
const response = await api.fetchApi(`/loras/usage-tips-by-path?relative_path=${encodeURIComponent(relativePath)}`);
if (response.ok) {
const data = await response.json();
if (data.success && data.usage_tips) {
// Parse JSON string and extract strength
try {
const usageTips = JSON.parse(data.usage_tips);
if (usageTips.strength && typeof usageTips.strength === 'number') {
strength = usageTips.strength;
}
} catch (parseError) {
console.warn('Failed to parse usage tips JSON:', parseError);
}
}
}
} catch (error) {
console.warn('Failed to fetch usage tips:', error);
}
// Format the LoRA code with strength
const loraCode = `<lora:${fileName}:${strength}>, `;
const currentValue = this.inputElement.value;
const caretPos = this.getCaretPosition();
const lastCommaIndex = currentValue.lastIndexOf(',', caretPos - 1);
let newValue;
let newCaretPos;
if (lastCommaIndex === -1) {
// No comma found before cursor, replace from start or current search term start
const searchTerm = this.getSearchTerm(currentValue.substring(0, caretPos));
const searchStartPos = caretPos - searchTerm.length;
newValue = currentValue.substring(0, searchStartPos) + loraCode + currentValue.substring(caretPos);
newCaretPos = searchStartPos + loraCode.length;
} else {
// Replace text after last comma before cursor
const afterCommaPos = lastCommaIndex + 1;
// Skip whitespace after comma
let insertPos = afterCommaPos;
while (insertPos < caretPos && /\s/.test(currentValue[insertPos])) {
insertPos++;
}
newValue = currentValue.substring(0, insertPos) + loraCode + currentValue.substring(caretPos);
newCaretPos = insertPos + loraCode.length;
}
this.inputElement.value = newValue;
// Trigger input event to notify about the change
const event = new Event('input', { bubbles: true });
this.inputElement.dispatchEvent(event);
this.hide();
// Focus back to input and position cursor
this.inputElement.focus();
this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
}
destroy() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.previewTooltip) {
this.previewTooltip.cleanup();
}
if (this.dropdown && this.dropdown.parentNode) {
this.dropdown.parentNode.removeChild(this.dropdown);
}
// Remove event listeners would be added here if we tracked them
}
}
export { AutoComplete };

View File

@@ -1,203 +1,225 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import {
LORA_PATTERN,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback,
mergeLoras
import {
LORA_PATTERN,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback,
mergeLoras,
setupInputWidgetWithAutocomplete,
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
app.registerExtension({
name: "LoraManager.LoraLoader",
setup() {
// Add message handler to listen for messages from Python
api.addEventListener("lora_code_update", (event) => {
const { id, lora_code, mode } = event.detail;
this.handleLoraCodeUpdate(id, lora_code, mode);
name: "LoraManager.LoraLoader",
setup() {
// Add message handler to listen for messages from Python
api.addEventListener("lora_code_update", (event) => {
const { id, lora_code, mode } = event.detail;
this.handleLoraCodeUpdate(id, lora_code, mode);
});
},
// Handle lora code updates from Python
handleLoraCodeUpdate(id, loraCode, mode) {
// Handle broadcast mode (for Desktop/non-browser support)
if (id === -1) {
// Find all Lora Loader nodes in the current graph
const loraLoaderNodes = [];
for (const nodeId in app.graph._nodes_by_id) {
const node = app.graph._nodes_by_id[nodeId];
if (node.comfyClass === "Lora Loader (LoraManager)") {
loraLoaderNodes.push(node);
}
}
// Update each Lora Loader node found
if (loraLoaderNodes.length > 0) {
loraLoaderNodes.forEach((node) => {
this.updateNodeLoraCode(node, loraCode, mode);
});
},
// 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);
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
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
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);
},
}
).widget;
// 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);
}
},
// Update input widget callback
const inputWidget = this.widgets[0];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass == "Lora Loader (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Enable widget serialization
this.serialize_widgets = true;
const originalCallback = (value) => {
if (isUpdating) return;
isUpdating = true;
this.addInput("clip", "CLIP", {
shape: 7,
});
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.addInput("lora_stack", "LORA_STACK", {
shape: 7, // 7 is the shape of the optional input
});
this.lorasWidget.value = mergedLoras;
} finally {
isUpdating = false;
}
};
// Restore saved value if exists
let existingLoras = [];
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
);
// Setup input widget with autocomplete
inputWidget.callback = setupInputWidgetWithAutocomplete(
this,
inputWidget,
originalCallback
);
// Add flag to prevent callback loops
let isUpdating = false;
// Get the widget object directly from the returned object
this.lorasWidget = addLorasWidget(
this,
"loras",
{
defaultVal: mergedLoras, // Pass object directly
// Register this node with the backend
this.registerNode = async () => {
try {
await fetch("/api/register-node", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
(value) => {
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(this);
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);
}
};
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(this, allActiveLoraNames);
// Ensure the node is registered after creation
// Call registration
// setTimeout(() => {
// this.registerNode();
// }, 0);
});
}
},
// 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 and trim
newText = newText.replace(/\s+/g, " ").trim();
inputWidget.value = newText;
} finally {
isUpdating = false;
}
}
).widget;
// Update input widget callback
const inputWidget = this.widgets[0];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.lorasWidget.value = mergedLoras;
} finally {
isUpdating = false;
}
};
// 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);
});
async nodeCreated(node) {
if (node.comfyClass == "Lora Loader (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;
});
}
},
});

View File

@@ -1,160 +1,185 @@
import { app } from "../../scripts/app.js";
import {
LORA_PATTERN,
getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback,
mergeLoras
import {
LORA_PATTERN,
getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback,
mergeLoras,
setupInputWidgetWithAutocomplete,
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
app.registerExtension({
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;
name: "LoraManager.LoraStacker",
this.addInput("lora_stack", 'LORA_STACK', {
"shape": 7 // 7 is the shape of the optional input
});
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "Lora Stacker (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", async function () {
// Enable widget serialization
this.serialize_widgets = true;
// Restore saved value if exists
let existingLoras = [];
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 and trim
newText = newText.replace(/\s+/g, ' ').trim();
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;
this.addInput("lora_stack", "LORA_STACK", {
shape: 7, // 7 is the shape of the optional input
});
// Update input widget callback
const inputWidget = this.widgets[0];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
inputWidget.callback = (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;
}
};
// Add flag to prevent callback loops
let isUpdating = false;
// 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);
}
};
const result = addLorasWidget(this, "loras", {}, (value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
// Call registration
// setTimeout(() => {
// this.registerNode();
// }, 0);
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
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
function updateDownstreamLoaders(startNode, visited = new Set()) {
if (visited.has(startNode.id)) return;
visited.add(startNode.id);
// Check each output link
if (startNode.outputs) {
for (const output of startNode.outputs) {
if (output.links) {
for (const linkId of output.links) {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
// If target is a Lora Loader, collect all active loras in the chain and update
if (targetNode && targetNode.comfyClass === "Lora Loader (LoraManager)") {
const allActiveLoraNames = collectActiveLorasFromChain(targetNode);
updateConnectedTriggerWords(targetNode, allActiveLoraNames);
}
// If target is another Lora Stacker, recursively check its outputs
else if (targetNode && targetNode.comfyClass === "Lora Stacker (LoraManager)") {
updateDownstreamLoaders(targetNode, visited);
}
}
}
if (visited.has(startNode.id)) return;
visited.add(startNode.id);
// Check each output link
if (startNode.outputs) {
for (const output of startNode.outputs) {
if (output.links) {
for (const linkId of output.links) {
const link = app.graph.links[linkId];
if (link) {
const targetNode = app.graph.getNodeById(link.target_id);
// If target is a Lora Loader, collect all active loras in the chain and update
if (
targetNode &&
targetNode.comfyClass === "Lora Loader (LoraManager)"
) {
const allActiveLoraNames =
collectActiveLorasFromChain(targetNode);
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

@@ -675,25 +675,9 @@ export function addLorasWidget(node, name, opts, callback) {
// Add the current 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
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)
};
}
const updatedValue = uniqueValue.map(lora => {
// For new loras, default clip strength to model strength and expanded to false
// unless clipStrength is already different from strength
const clipStrength = lora.clipStrength || lora.strength;

View File

@@ -219,18 +219,26 @@ export class PreviewTooltip {
display: 'none',
overflow: 'hidden',
maxWidth: '300px',
pointerEvents: 'none', // Prevent interference with autocomplete
});
document.body.appendChild(this.element);
this.hideTimeout = null;
this.isFromAutocomplete = false;
// Add global click event to hide tooltip
document.addEventListener('click', () => this.hide());
// Modified event listeners for autocomplete compatibility
this.globalClickHandler = (e) => {
// Don't hide if click is on autocomplete dropdown
if (!e.target.closest('.comfy-autocomplete-dropdown')) {
this.hide();
}
};
document.addEventListener('click', this.globalClickHandler);
// Add scroll event listener
document.addEventListener('scroll', () => this.hide(), true);
this.globalScrollHandler = () => this.hide();
document.addEventListener('scroll', this.globalScrollHandler, true);
}
async show(loraName, x, y) {
async show(loraName, x, y, fromAutocomplete = false) {
try {
// Clear previous hide timer
if (this.hideTimeout) {
@@ -238,8 +246,12 @@ export class PreviewTooltip {
this.hideTimeout = null;
}
// Track if this is from autocomplete
this.isFromAutocomplete = fromAutocomplete;
// Don't redisplay the same lora preview
if (this.element.style.display === 'block' && this.currentLora === loraName) {
this.position(x, y);
return;
}
@@ -300,7 +312,7 @@ export class PreviewTooltip {
left: '0',
right: '0',
padding: '8px',
color: 'rgba(255, 255, 255, 0.95)',
color: 'white',
fontSize: '13px',
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
@@ -349,6 +361,10 @@ export class PreviewTooltip {
top = y - rect.height - 10;
}
// Ensure minimum distance from edges
left = Math.max(10, Math.min(left, viewportWidth - rect.width - 10));
top = Math.max(10, Math.min(top, viewportHeight - rect.height - 10));
Object.assign(this.element.style, {
left: `${left}px`,
top: `${top}px`
@@ -362,6 +378,7 @@ export class PreviewTooltip {
this.hideTimeout = setTimeout(() => {
this.element.style.display = 'none';
this.currentLora = null;
this.isFromAutocomplete = false;
// Stop video playback
const video = this.element.querySelector('video');
if (video) {
@@ -376,9 +393,9 @@ export class PreviewTooltip {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
// Remove all event listeners
document.removeEventListener('click', () => this.hide());
document.removeEventListener('scroll', () => this.hide(), true);
// Remove event listeners properly
document.removeEventListener('click', this.globalClickHandler);
document.removeEventListener('scroll', this.globalScrollHandler, true);
this.element.remove();
}
}

View File

@@ -0,0 +1,332 @@
/*
https://github.com/component/textarea-caret-position
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const getCaretCoordinates = (function () {
// We'll copy the properties below into the mirror div.
// Note that some browsers, such as Firefox, do not concatenate properties
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
// so we have to list every single property explicitly.
var properties = [
"direction", // RTL support
"boxSizing",
"width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
"height",
"overflowX",
"overflowY", // copy the scrollbar for IE
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
"borderStyle",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
"fontStyle",
"fontVariant",
"fontWeight",
"fontStretch",
"fontSize",
"fontSizeAdjust",
"lineHeight",
"fontFamily",
"textAlign",
"textTransform",
"textIndent",
"textDecoration", // might not make a difference, but better be safe
"letterSpacing",
"wordSpacing",
"tabSize",
"MozTabSize",
];
var isBrowser = typeof window !== "undefined";
var isFirefox = isBrowser && window.mozInnerScreenX != null;
return function getCaretCoordinates(element, position, options) {
if (!isBrowser) {
throw new Error("textarea-caret-position#getCaretCoordinates should only be called in a browser");
}
var debug = (options && options.debug) || false;
if (debug) {
var el = document.querySelector("#input-textarea-caret-position-mirror-div");
if (el) el.parentNode.removeChild(el);
}
// The mirror div will replicate the textarea's style
var div = document.createElement("div");
div.id = "input-textarea-caret-position-mirror-div";
document.body.appendChild(div);
var style = div.style;
var computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
var isInput = element.nodeName === "INPUT";
// Default textarea styles
style.whiteSpace = "pre-wrap";
if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
// Position off-screen
style.position = "absolute"; // required to return coordinates properly
if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
// Transfer the element's properties to the div
properties.forEach(function (prop) {
if (isInput && prop === "lineHeight") {
// Special case for <input>s because text is rendered centered and line height may be != height
if (computed.boxSizing === "border-box") {
var height = parseInt(computed.height);
var outerHeight =
parseInt(computed.paddingTop) +
parseInt(computed.paddingBottom) +
parseInt(computed.borderTopWidth) +
parseInt(computed.borderBottomWidth);
var targetHeight = outerHeight + parseInt(computed.lineHeight);
if (height > targetHeight) {
style.lineHeight = height - outerHeight + "px";
} else if (height === targetHeight) {
style.lineHeight = computed.lineHeight;
} else {
style.lineHeight = 0;
}
} else {
style.lineHeight = computed.height;
}
} else {
style[prop] = computed[prop];
}
});
if (isFirefox) {
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
if (element.scrollHeight > parseInt(computed.height)) style.overflowY = "scroll";
} else {
style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
}
div.textContent = element.value.substring(0, position);
// The second special handling for input type="text" vs textarea:
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
var span = document.createElement("span");
// Wrapping must be replicated *exactly*, including when a long word gets
// onto the next line, with whitespace at the end of the line before (#7).
// The *only* reliable way to do that is to copy the *entire* rest of the
// textarea's content into the <span> created at the caret position.
// For inputs, just '.' would be enough, but no need to bother.
span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
div.appendChild(span);
var coordinates = {
top: span.offsetTop + parseInt(computed["borderTopWidth"]),
left: span.offsetLeft + parseInt(computed["borderLeftWidth"]),
height: parseInt(computed["lineHeight"]),
};
if (debug) {
span.style.backgroundColor = "#aaa";
} else {
document.body.removeChild(div);
}
return coordinates;
};
})();
/*
Key functions from:
https://github.com/yuku/textcomplete
© Yuku Takahashi - This software is licensed under the MIT license.
The MIT License (MIT)
Copyright (c) 2015 Jonathan Ong me@jongleberry.com
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const CHAR_CODE_ZERO = "0".charCodeAt(0);
const CHAR_CODE_NINE = "9".charCodeAt(0);
export class TextAreaCaretHelper {
constructor(el, getScale) {
this.el = el;
this.getScale = getScale;
}
#calculateElementOffset() {
const rect = this.el.getBoundingClientRect();
const owner = this.el.ownerDocument;
if (owner == null) {
throw new Error("Given element does not belong to document");
}
const { defaultView, documentElement } = owner;
if (defaultView == null) {
throw new Error("Given element does not belong to window");
}
const offset = {
top: rect.top + defaultView.pageYOffset,
left: rect.left + defaultView.pageXOffset,
};
if (documentElement) {
offset.top -= documentElement.clientTop;
offset.left -= documentElement.clientLeft;
}
return offset;
}
#isDigit(charCode) {
return CHAR_CODE_ZERO <= charCode && charCode <= CHAR_CODE_NINE;
}
#getLineHeightPx() {
const computedStyle = getComputedStyle(this.el);
const lineHeight = computedStyle.lineHeight;
// If the char code starts with a digit, it is either a value in pixels,
// or unitless, as per:
// https://drafts.csswg.org/css2/visudet.html#propdef-line-height
// https://drafts.csswg.org/css2/cascade.html#computed-value
if (this.#isDigit(lineHeight.charCodeAt(0))) {
const floatLineHeight = parseFloat(lineHeight);
// In real browsers the value is *always* in pixels, even for unit-less
// line-heights. However, we still check as per the spec.
return this.#isDigit(lineHeight.charCodeAt(lineHeight.length - 1))
? floatLineHeight * parseFloat(computedStyle.fontSize)
: floatLineHeight;
}
// Otherwise, the value is "normal".
// If the line-height is "normal", calculate by font-size
return this.#calculateLineHeightPx(this.el.nodeName, computedStyle);
}
/**
* Returns calculated line-height of the given node in pixels.
*/
#calculateLineHeightPx(nodeName, computedStyle) {
const body = document.body;
if (!body) return 0;
const tempNode = document.createElement(nodeName);
tempNode.innerHTML = "&nbsp;";
Object.assign(tempNode.style, {
fontSize: computedStyle.fontSize,
fontFamily: computedStyle.fontFamily,
padding: "0",
position: "absolute",
});
body.appendChild(tempNode);
// Make sure textarea has only 1 row
if (tempNode instanceof HTMLTextAreaElement) {
tempNode.rows = 1;
}
// Assume the height of the element is the line-height
const height = tempNode.offsetHeight;
body.removeChild(tempNode);
return height;
}
getCursorOffset() {
const scale = this.getScale();
const elOffset = this.#calculateElementOffset();
const elScroll = this.#getElScroll();
const cursorPosition = this.#getCursorPosition();
const lineHeight = this.#getLineHeightPx();
const top = elOffset.top - (elScroll.top * scale) + (cursorPosition.top + lineHeight) * scale;
const left = elOffset.left - elScroll.left + cursorPosition.left;
const clientTop = this.el.getBoundingClientRect().top;
if (this.el.dir !== "rtl") {
return { top, left, lineHeight, clientTop };
} else {
const right = document.documentElement ? document.documentElement.clientWidth - left : 0;
return { top, right, lineHeight, clientTop };
}
}
#getElScroll() {
return { top: this.el.scrollTop, left: this.el.scrollLeft };
}
#getCursorPosition() {
return getCaretCoordinates(this.el, this.el.selectionEnd);
}
getBeforeCursor() {
return this.el.selectionStart !== this.el.selectionEnd ? null : this.el.value.substring(0, this.el.selectionEnd);
}
getAfterCursor() {
return this.el.value.substring(this.el.selectionEnd);
}
insertAtCursor(value, offset, finalOffset) {
if (this.el.selectionStart != null) {
const startPos = this.el.selectionStart;
const endPos = this.el.selectionEnd;
// Move selection to beginning of offset
this.el.selectionStart = this.el.selectionStart + offset;
// Using execCommand to support undo, but since it's officially
// 'deprecated' we need a backup solution, but it won't support undo :(
let pasted = true;
try {
if (!document.execCommand("insertText", false, value)) {
pasted = false;
}
} catch (e) {
console.error("Error caught during execCommand:", e);
pasted = false;
}
if (!pasted) {
console.error(
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
textarea.setRangeText(modifiedText, this.el.selectionStart, this.el.selectionEnd, 'end');
}
this.el.selectionEnd = this.el.selectionStart = startPos + value.length + offset + (finalOffset ?? 0);
} else {
// Using execCommand to support undo, but since it's officially
// 'deprecated' we need a backup solution, but it won't support undo :(
let pasted = true;
try {
if (!document.execCommand("insertText", false, value)) {
pasted = false;
}
} catch (e) {
console.error("Error caught during execCommand:", e);
pasted = false;
}
if (!pasted) {
console.error(
"execCommand unsuccessful; not supported. Adding text manually, no undo support.");
this.el.value += value;
}
}
}
}

View File

@@ -1,4 +1,5 @@
export const CONVERTED_TYPE = 'converted-widget';
import { AutoComplete } from "./autocomplete.js";
export function chainCallback(object, property, callback) {
if (object == undefined) {
@@ -68,21 +69,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
export async function getLorasWidgetModule() {
return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js");
@@ -207,6 +193,7 @@ export function mergeLoras(lorasText, lorasArr) {
name: lora.name,
strength: lora.strength !== undefined ? lora.strength : parsedLoras[lora.name].strength,
active: lora.active !== undefined ? lora.active : true,
expanded: lora.expanded !== undefined ? lora.expanded : false,
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
});
usedNames.add(lora.name);
@@ -226,4 +213,58 @@ export function mergeLoras(lorasText, lorasArr) {
}
return result;
}
/**
* Initialize autocomplete for an input widget and setup cleanup
* @param {Object} node - The node instance
* @param {Object} inputWidget - The input widget to add autocomplete to
* @param {Function} originalCallback - The original callback function
* @returns {Function} Enhanced callback function with autocomplete
*/
export function setupInputWidgetWithAutocomplete(node, inputWidget, originalCallback) {
let autocomplete = null;
// Enhanced callback that initializes autocomplete and calls original callback
const enhancedCallback = (value) => {
// Initialize autocomplete on first callback if not already done
if (!autocomplete && inputWidget.inputEl) {
autocomplete = new AutoComplete(inputWidget.inputEl, 'loras', {
maxItems: 15,
minChars: 1,
debounceDelay: 200
});
// Store reference for cleanup
node.autocomplete = autocomplete;
}
// Call the original callback
if (originalCallback) {
originalCallback(value);
}
};
// Setup cleanup on node removal
setupAutocompleteCleanup(node);
return enhancedCallback;
}
/**
* Setup autocomplete cleanup when node is removed
* @param {Object} node - The node instance
*/
export function setupAutocompleteCleanup(node) {
// Override onRemoved to cleanup autocomplete
const originalOnRemoved = node.onRemoved;
node.onRemoved = function() {
if (this.autocomplete) {
this.autocomplete.destroy();
this.autocomplete = null;
}
if (originalOnRemoved) {
originalOnRemoved.call(this);
}
};
}

View File

@@ -1,103 +1,121 @@
import { app } from "../../scripts/app.js";
import {
LORA_PATTERN,
getActiveLorasFromNode,
updateConnectedTriggerWords,
chainCallback,
mergeLoras
import {
LORA_PATTERN,
getActiveLorasFromNode,
updateConnectedTriggerWords,
chainCallback,
mergeLoras,
setupInputWidgetWithAutocomplete,
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
app.registerExtension({
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;
name: "LoraManager.WanVideoLoraSelect",
// Add optional inputs
this.addInput("prev_lora", 'WANVIDLORA', {
"shape": 7 // 7 is the shape of the optional input
});
this.addInput("blocks", 'SELECTEDBLOCKS', {
"shape": 7 // 7 is the shape of the optional input
});
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;
// Restore saved value if exists
let existingLoras = [];
if (this.widgets_values && this.widgets_values.length > 0) {
// 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 and trim
newText = newText.replace(/\s+/g, ' ').trim();
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;
// Add optional inputs
this.addInput("prev_lora", "WANVIDLORA", {
shape: 7, // 7 is the shape of the optional input
});
// Update input widget callback
const inputWidget = this.widgets[1];
inputWidget.options.getMaxHeight = () => 100;
this.inputWidget = inputWidget;
inputWidget.callback = (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;
}
};
this.addInput("blocks", "SELECTEDBLOCKS", {
shape: 7, // 7 is the shape of the optional input
});
// Add flag to prevent callback loops
let isUpdating = false;
const result = addLorasWidget(this, "loras", {}, (value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = this.widgets[2];
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
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;
});
}
},
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 162 KiB