mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66575c719a | ||
|
|
677a239d53 | ||
|
|
3b96bfe5af | ||
|
|
83be5cfa64 | ||
|
|
6b834c2362 | ||
|
|
7abfc49e08 | ||
|
|
65d5f50088 | ||
|
|
4f1f4ffe3d | ||
|
|
b0c2027a1c | ||
|
|
33c83358b0 | ||
|
|
31223f0526 | ||
|
|
92daadb92c | ||
|
|
fae2e274fd | ||
|
|
342a722991 | ||
|
|
65ec6aacb7 | ||
|
|
9387470c69 | ||
|
|
31f6edf8f0 | ||
|
|
487b062175 | ||
|
|
d8e13de096 | ||
|
|
e8a30088ef | ||
|
|
bf7b07ba74 | ||
|
|
28fe3e7b7a | ||
|
|
c0eff2bb5e | ||
|
|
848c1741fe | ||
|
|
1370b8e8c1 | ||
|
|
82a068e610 | ||
|
|
32f42bafaa | ||
|
|
4081b7f022 | ||
|
|
a5808193a6 | ||
|
|
854ca322c1 | ||
|
|
c1d9b5137a | ||
|
|
f33d5745b3 |
65
README.md
65
README.md
@@ -34,6 +34,25 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.8.25
|
||||
* **LoRA List Reordering**
|
||||
- Drag & Drop: Easily rearrange LoRA entries using the drag handle.
|
||||
- Keyboard Shortcuts:
|
||||
- Arrow keys: Navigate between LoRAs
|
||||
- Ctrl/Cmd + Arrow: Move selected LoRA up/down
|
||||
- Ctrl/Cmd + Home/End: Move selected LoRA to top/bottom
|
||||
- Delete/Backspace: Remove selected LoRA
|
||||
- Context Menu: Right-click for quick actions like Move Up, Move Down, Move to Top, Move to Bottom.
|
||||
* **Bulk Operations for Checkpoints & Embeddings**
|
||||
- Bulk Mode: Select multiple checkpoints or embeddings for batch actions.
|
||||
- Bulk Refresh: Update Civitai metadata for selected models.
|
||||
- Bulk Delete: Remove multiple models at once.
|
||||
- Bulk Move (Embeddings): Move selected embeddings to a different folder.
|
||||
* **New Setting: Auto Download Example Images**
|
||||
- Automatically fetch example images for models missing previews (requires download location to be set). Enabled by default.
|
||||
* **General Improvements**
|
||||
- Various user experience enhancements and stability fixes.
|
||||
|
||||
### v0.8.22
|
||||
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
|
||||
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
|
||||
@@ -70,52 +89,6 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
||||
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
|
||||
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
|
||||
|
||||
### v0.8.16
|
||||
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
|
||||
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart
|
||||
* **Customizable Display Density** - Replaced compact mode with adjustable display density settings for personalized layout customization
|
||||
* **Model Creator Information** - Added creator details to model information panels for better attribution
|
||||
* **Improved WebP Support** - Enhanced Save Image node with workflow embedding capability for WebP format images
|
||||
* **Direct Example Access** - Added "Open Example Images Folder" button to card interfaces for convenient browsing of downloaded model examples
|
||||
* **Enhanced Compatibility** - Full ComfyUI Desktop support for "Send lora or recipe to workflow" functionality
|
||||
* **Cache Management** - Added settings to clear existing cache files when needed
|
||||
* **Bug Fixes & Stability** - Various improvements for overall reliability and performance
|
||||
|
||||
### v0.8.15
|
||||
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
|
||||
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
|
||||
* **Improved LoRA Loader Controls** - Added header drag functionality for proportional strength adjustment of all LoRAs simultaneously (including CLIP strengths when expanded)
|
||||
* **Keyboard Navigation Support** - Implemented Page Up/Down for page scrolling, Home key to jump to top, and End key to jump to bottom for faster browsing through large collections
|
||||
|
||||
### v0.8.14
|
||||
* **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience
|
||||
* **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K)
|
||||
* **Enhanced LoRA Node Functionality** - Comprehensive improvements to LoRA loader/stacker nodes including real-time trigger word updates (reflecting any change anywhere in the LoRA chain for precise updates) and expanded context menu with "Copy Notes" and "Copy Trigger Words" options for faster workflow
|
||||
|
||||
### v0.8.13
|
||||
* **Enhanced Recipe Management** - Added "Find duplicates" feature to identify and batch delete duplicate recipes with duplicate detection notifications during imports
|
||||
* **Improved Source Tracking** - Source URLs are now saved with recipes imported via URL, allowing users to view original content with one click or manually edit links
|
||||
* **Advanced LoRA Control** - Double-click LoRAs in Loader/Stacker nodes to access expanded CLIP strength controls for more precise adjustments of model and CLIP strength separately
|
||||
* **Lycoris Model Support** - Added compatibility with Lycoris models for expanded creative options
|
||||
* **Bug Fixes & UX Improvements** - Resolved various issues and enhanced overall user experience with numerous optimizations
|
||||
|
||||
### v0.8.12
|
||||
* **Enhanced Model Discovery** - Added alphabetical navigation bar to LoRAs page for faster browsing through large collections
|
||||
* **Optimized Example Images** - Improved download logic to automatically refresh stale metadata before fetching example images
|
||||
* **Model Exclusion System** - New right-click option to exclude specific LoRAs or checkpoints from management
|
||||
* **Improved Showcase Experience** - Enhanced interaction in LoRA and checkpoint showcase areas for better usability
|
||||
|
||||
### v0.8.11
|
||||
* **Offline Image Support** - Added functionality to download and save all model example images locally, ensuring access even when offline or if images are removed from CivitAI or the site is down
|
||||
* **Resilient Download System** - Implemented pause/resume capability with checkpoint recovery that persists through restarts or unexpected exits
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.10
|
||||
* **Standalone Mode** - Run LoRA Manager independently from ComfyUI for a lightweight experience that works even with other stable diffusion interfaces
|
||||
* **Portable Edition** - New one-click portable version for easy startup and updates in standalone mode
|
||||
* **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module
|
||||
* **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections
|
||||
|
||||
[View Update History](./update_logs.md)
|
||||
|
||||
---
|
||||
|
||||
21
py/config.py
21
py/config.py
@@ -59,6 +59,9 @@ class Config:
|
||||
|
||||
if self.checkpoints_roots and len(self.checkpoints_roots) == 1 and "default_checkpoint_root" not in settings:
|
||||
settings["default_checkpoint_root"] = self.checkpoints_roots[0]
|
||||
|
||||
if self.embeddings_roots and len(self.embeddings_roots) == 1 and "default_embedding_root" not in settings:
|
||||
settings["default_embedding_root"] = self.embeddings_roots[0]
|
||||
|
||||
# Save settings
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
@@ -201,16 +204,20 @@ class Config:
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
unet_map[real_path] = unet_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
|
||||
|
||||
# Merge both maps and deduplicate by real path
|
||||
merged_map = {}
|
||||
for real_path, orig_path in {**checkpoint_map, **unet_map}.items():
|
||||
if real_path not in merged_map:
|
||||
merged_map[real_path] = orig_path
|
||||
|
||||
# Now sort and use only the deduplicated real paths
|
||||
unique_checkpoint_paths = sorted(checkpoint_map.values(), key=lambda p: p.lower())
|
||||
unique_unet_paths = sorted(unet_map.values(), key=lambda p: p.lower())
|
||||
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
||||
|
||||
# Store individual paths in class properties
|
||||
self.checkpoints_roots = unique_checkpoint_paths
|
||||
self.unet_roots = unique_unet_paths
|
||||
# Split back into checkpoints and unet roots for class properties
|
||||
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_map.values()]
|
||||
self.unet_roots = [p for p in unique_paths if p in unet_map.values()]
|
||||
|
||||
# Combine all checkpoint-related paths for return value
|
||||
all_paths = unique_checkpoint_paths + unique_unet_paths
|
||||
all_paths = unique_paths
|
||||
|
||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
||||
|
||||
|
||||
@@ -146,52 +146,40 @@ class MetadataHook:
|
||||
# Store the original _async_map_node_over_list function
|
||||
original_map_node_over_list = getattr(execution, map_node_func_name)
|
||||
|
||||
# Define the wrapped async function - NOTE: Updated signature with prompt_id and unique_id!
|
||||
async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None):
|
||||
# Wrapped async function, compatible with both stable and nightly
|
||||
async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None, *args, **kwargs):
|
||||
hidden_inputs = kwargs.get('hidden_inputs', None)
|
||||
# Only collect metadata when calling the main function of nodes
|
||||
if func == obj.FUNCTION and hasattr(obj, '__class__'):
|
||||
try:
|
||||
# Get the current prompt_id from the registry
|
||||
registry = MetadataRegistry()
|
||||
# We now have prompt_id directly from the function parameters
|
||||
|
||||
if prompt_id is not None:
|
||||
# Get node class type
|
||||
class_type = obj.__class__.__name__
|
||||
|
||||
# Use the passed unique_id parameter instead of trying to extract it
|
||||
node_id = unique_id
|
||||
|
||||
# Record inputs before execution
|
||||
if node_id is not None:
|
||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
||||
except Exception as e:
|
||||
print(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||
|
||||
# Execute the original async function with ALL parameters in the correct order
|
||||
results = await original_map_node_over_list(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb)
|
||||
# Call original function with all args/kwargs
|
||||
results = await original_map_node_over_list(
|
||||
prompt_id, unique_id, obj, input_data_all, func,
|
||||
allow_interrupt, execution_block_cb, pre_execute_cb, *args, **kwargs
|
||||
)
|
||||
|
||||
# After execution, collect outputs for relevant nodes
|
||||
if func == obj.FUNCTION and hasattr(obj, '__class__'):
|
||||
try:
|
||||
# Get the current prompt_id from the registry
|
||||
registry = MetadataRegistry()
|
||||
|
||||
if prompt_id is not None:
|
||||
# Get node class type
|
||||
class_type = obj.__class__.__name__
|
||||
|
||||
# Use the passed unique_id parameter
|
||||
node_id = unique_id
|
||||
|
||||
# Record outputs after execution
|
||||
if node_id is not None:
|
||||
registry.update_node_execution(node_id, class_type, results)
|
||||
except Exception as e:
|
||||
print(f"Error collecting metadata (post-execution): {str(e)}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Also hook the execute function to track the current prompt_id
|
||||
original_execute = execution.execute
|
||||
|
||||
|
||||
@@ -119,10 +119,10 @@ class RecipeMetadataParser(ABC):
|
||||
# Check if exists locally
|
||||
if recipe_scanner and lora_entry['hash']:
|
||||
lora_scanner = recipe_scanner._lora_scanner
|
||||
exists_locally = lora_scanner.has_lora_hash(lora_entry['hash'])
|
||||
exists_locally = lora_scanner.has_hash(lora_entry['hash'])
|
||||
if exists_locally:
|
||||
try:
|
||||
local_path = lora_scanner.get_lora_path_by_hash(lora_entry['hash'])
|
||||
local_path = lora_scanner.get_path_by_hash(lora_entry['hash'])
|
||||
lora_entry['existsLocally'] = True
|
||||
lora_entry['localPath'] = local_path
|
||||
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
|
||||
|
||||
@@ -181,13 +181,30 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# First use Civitai resources if available (more reliable source)
|
||||
if metadata.get("civitai_resources"):
|
||||
for resource in metadata.get("civitai_resources", []):
|
||||
# --- Added: Parse 'air' field if present ---
|
||||
air = resource.get("air")
|
||||
if air:
|
||||
# Format: urn:air:sdxl:lora:civitai:1221007@1375651
|
||||
# Or: urn:air:sdxl:checkpoint:civitai:623891@2019115
|
||||
air_pattern = r"urn:air:[^:]+:(?P<type>[^:]+):civitai:(?P<modelId>\d+)@(?P<modelVersionId>\d+)"
|
||||
air_match = re.match(air_pattern, air)
|
||||
if air_match:
|
||||
air_type = air_match.group("type")
|
||||
air_modelId = int(air_match.group("modelId"))
|
||||
air_modelVersionId = int(air_match.group("modelVersionId"))
|
||||
# checkpoint/lycoris/lora/hypernet
|
||||
resource["type"] = air_type
|
||||
resource["modelId"] = air_modelId
|
||||
resource["modelVersionId"] = air_modelVersionId
|
||||
# --- End added ---
|
||||
|
||||
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
'id': resource.get("modelVersionId", 0),
|
||||
'modelId': resource.get("modelId", 0),
|
||||
'name': resource.get("modelName", "Unknown LoRA"),
|
||||
'version': resource.get("modelVersionName", ""),
|
||||
'version': resource.get("modelVersionName", resource.get("versionName", "")),
|
||||
'type': resource.get("type", "lora"),
|
||||
'weight': round(float(resource.get("weight", 1.0)), 2),
|
||||
'existsLocally': False,
|
||||
|
||||
@@ -153,10 +153,6 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
# Process civitaiResources array
|
||||
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
||||
for resource in metadata["civitaiResources"]:
|
||||
# Skip resources that aren't LoRAs or LyCORIS
|
||||
if resource.get("type") not in ["lora", "lycoris"] and "type" not in resource:
|
||||
continue
|
||||
|
||||
# Get unique identifier for deduplication
|
||||
version_id = str(resource.get("modelVersionId", ""))
|
||||
|
||||
@@ -275,6 +271,66 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Check for LoRA info in the format "Lora_0 Model hash", "Lora_0 Model name", etc.
|
||||
lora_index = 0
|
||||
while f"Lora_{lora_index} Model hash" in metadata and f"Lora_{lora_index} Model name" in metadata:
|
||||
lora_hash = metadata[f"Lora_{lora_index} Model hash"]
|
||||
lora_name = metadata[f"Lora_{lora_index} Model name"]
|
||||
lora_strength_model = float(metadata.get(f"Lora_{lora_index} Strength model", 1.0))
|
||||
|
||||
# Skip if we've already added this LoRA by hash
|
||||
if lora_hash and lora_hash in added_loras:
|
||||
lora_index += 1
|
||||
continue
|
||||
|
||||
lora_entry = {
|
||||
'name': lora_name,
|
||||
'type': "lora",
|
||||
'weight': lora_strength_model,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Try to get info from Civitai if hash is available
|
||||
if lora_entry['hash'] and civitai_client:
|
||||
try:
|
||||
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
lora_index += 1
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
# If we have a version ID from Civitai, track it for deduplication
|
||||
if 'id' in lora_entry and lora_entry['id']:
|
||||
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}")
|
||||
|
||||
# Track by hash if we have it
|
||||
if lora_hash:
|
||||
added_loras[lora_hash] = len(result["loras"])
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
lora_index += 1
|
||||
|
||||
# If base model wasn't found earlier, use the most common one from LoRAs
|
||||
if not result["base_model"] and base_model_counts:
|
||||
result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||
|
||||
@@ -55,7 +55,7 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
# Check if this LoRA exists locally by SHA256 hash
|
||||
if lora.get('hash') and recipe_scanner:
|
||||
lora_scanner = recipe_scanner._lora_scanner
|
||||
exists_locally = lora_scanner.has_lora_hash(lora['hash'])
|
||||
exists_locally = lora_scanner.has_hash(lora['hash'])
|
||||
if exists_locally:
|
||||
lora_cache = await lora_scanner.get_cached_data()
|
||||
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||
|
||||
@@ -48,6 +48,8 @@ class BaseModelRoutes(ABC):
|
||||
app.router.add_post(f'/api/{prefix}/rename', self.rename_model)
|
||||
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
|
||||
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)
|
||||
|
||||
# Common query routes
|
||||
app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
|
||||
@@ -408,7 +410,7 @@ class BaseModelRoutes(ABC):
|
||||
group["models"].append(await self.service.format_response(model))
|
||||
|
||||
# Find the model from the main index too
|
||||
hash_val = self.service.scanner._hash_index.get_hash_by_filename(filename)
|
||||
hash_val = self.service.scanner.get_hash_by_filename(filename)
|
||||
if hash_val:
|
||||
main_path = self.service.get_path_by_hash(hash_val)
|
||||
if main_path and main_path not in paths:
|
||||
@@ -616,4 +618,81 @@ class BaseModelRoutes(ABC):
|
||||
# This will be implemented by subclasses as they need CivitAI client access
|
||||
return web.json_response({
|
||||
"error": "Not implemented in base class"
|
||||
}, status=501)
|
||||
}, status=501)
|
||||
|
||||
# Common model move handlers
|
||||
async def move_model(self, request: web.Request) -> web.Response:
|
||||
"""Handle model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
target_path = data.get('target_path')
|
||||
if not file_path or not target_path:
|
||||
return web.Response(text='File path and target path are required', status=400)
|
||||
import os
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
if os.path.exists(target_file_path):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Target file already exists: {target_file_path}"
|
||||
}, status=409)
|
||||
success = await self.service.scanner.move_model(file_path, target_path)
|
||||
if success:
|
||||
return web.json_response({'success': True, 'new_file_path': target_file_path})
|
||||
else:
|
||||
return web.Response(text='Failed to move model', status=500)
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
||||
"""Handle bulk model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_paths = data.get('file_paths', [])
|
||||
target_path = data.get('target_path')
|
||||
if not file_paths or not target_path:
|
||||
return web.Response(text='File paths and target path are required', status=400)
|
||||
results = []
|
||||
import os
|
||||
for file_path in file_paths:
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": True,
|
||||
"message": "Source and target directories are the same"
|
||||
})
|
||||
continue
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
if os.path.exists(target_file_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": False,
|
||||
"message": f"Target file already exists: {target_file_path}"
|
||||
})
|
||||
continue
|
||||
success = await self.service.scanner.move_model(file_path, target_path)
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": success,
|
||||
"message": "Success" if success else "Failed to move model"
|
||||
})
|
||||
success_count = sum(1 for r in results if r["success"])
|
||||
failure_count = len(results) - success_count
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||
'results': results,
|
||||
'success_count': success_count,
|
||||
'failure_count': failure_count
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
@@ -4,6 +4,7 @@ from aiohttp import web
|
||||
from .base_model_routes import BaseModelRoutes
|
||||
from ..services.checkpoint_service import CheckpointService
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,6 +42,10 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
|
||||
# Checkpoint info by name
|
||||
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_checkpoint_info)
|
||||
|
||||
# Checkpoint roots and Unet roots
|
||||
app.router.add_get(f'/api/{prefix}/checkpoints_roots', self.get_checkpoints_roots)
|
||||
app.router.add_get(f'/api/{prefix}/unet_roots', self.get_unet_roots)
|
||||
|
||||
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
|
||||
"""Get detailed information for a specific checkpoint by name"""
|
||||
@@ -102,4 +107,34 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
return web.json_response(versions)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching checkpoint model versions: {e}")
|
||||
return web.Response(status=500, text=str(e))
|
||||
return web.Response(status=500, text=str(e))
|
||||
|
||||
async def get_checkpoints_roots(self, request: web.Request) -> web.Response:
|
||||
"""Return the list of checkpoint roots from config"""
|
||||
try:
|
||||
roots = config.checkpoints_roots
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"roots": roots
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting checkpoint roots: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_unet_roots(self, request: web.Request) -> web.Response:
|
||||
"""Return the list of unet roots from config"""
|
||||
try:
|
||||
roots = config.unet_roots
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"roots": roots
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting unet roots: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
@@ -49,10 +49,6 @@ class LoraRoutes(BaseModelRoutes):
|
||||
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)
|
||||
|
||||
# LoRA-specific management routes
|
||||
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)
|
||||
|
||||
# CivitAI integration with LoRA-specific validation
|
||||
app.router.add_get(f'/api/{prefix}/civitai/versions/{{model_id}}', self.get_civitai_versions_lora)
|
||||
app.router.add_get(f'/api/{prefix}/civitai/model/version/{{modelVersionId}}', self.get_civitai_model_by_version)
|
||||
@@ -284,105 +280,6 @@ class LoraRoutes(BaseModelRoutes):
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
|
||||
# Model management methods
|
||||
async def move_model(self, request: web.Request) -> web.Response:
|
||||
"""Handle model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path') # full path of the model file
|
||||
target_path = data.get('target_path') # folder path to move the model to
|
||||
|
||||
if not file_path or not target_path:
|
||||
return web.Response(text='File path and target path are required', status=400)
|
||||
|
||||
# Check if source and destination are the same
|
||||
import os
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
||||
|
||||
# Check if target file already exists
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
|
||||
if os.path.exists(target_file_path):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f"Target file already exists: {target_file_path}"
|
||||
}, status=409) # 409 Conflict
|
||||
|
||||
# Call scanner to handle the move operation
|
||||
success = await self.service.scanner.move_model(file_path, target_path)
|
||||
|
||||
if success:
|
||||
return web.json_response({'success': True, 'new_file_path': target_file_path})
|
||||
else:
|
||||
return web.Response(text='Failed to move model', status=500)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving model: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
||||
"""Handle bulk model move request"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_paths = data.get('file_paths', []) # list of full paths of the model files
|
||||
target_path = data.get('target_path') # folder path to move the models to
|
||||
|
||||
if not file_paths or not target_path:
|
||||
return web.Response(text='File paths and target path are required', status=400)
|
||||
|
||||
results = []
|
||||
import os
|
||||
for file_path in file_paths:
|
||||
# Check if source and destination are the same
|
||||
source_dir = os.path.dirname(file_path)
|
||||
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": True,
|
||||
"message": "Source and target directories are the same"
|
||||
})
|
||||
continue
|
||||
|
||||
# Check if target file already exists
|
||||
file_name = os.path.basename(file_path)
|
||||
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||
|
||||
if os.path.exists(target_file_path):
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": False,
|
||||
"message": f"Target file already exists: {target_file_path}"
|
||||
})
|
||||
continue
|
||||
|
||||
# Try to move the model
|
||||
success = await self.service.scanner.move_model(file_path, target_path)
|
||||
results.append({
|
||||
"path": file_path,
|
||||
"success": success,
|
||||
"message": "Success" if success else "Failed to move model"
|
||||
})
|
||||
|
||||
# Count successes and failures
|
||||
success_count = sum(1 for r in results if r["success"])
|
||||
failure_count = len(results) - success_count
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||
'results': results,
|
||||
'success_count': success_count,
|
||||
'failure_count': failure_count
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
||||
"""Get model description for a Lora model"""
|
||||
try:
|
||||
|
||||
@@ -167,6 +167,9 @@ class MiscRoutes:
|
||||
|
||||
# Validate and update settings
|
||||
for key, value in data.items():
|
||||
if value == settings.get(key):
|
||||
# No change, skip
|
||||
continue
|
||||
# Special handling for example_images_path - verify path exists
|
||||
if key == 'example_images_path' and value:
|
||||
if not os.path.exists(value):
|
||||
|
||||
@@ -22,7 +22,6 @@ from ..config import config
|
||||
# Check if running in standalone mode
|
||||
standalone_mode = 'nodes' not in sys.modules
|
||||
|
||||
from ..utils.utils import download_civitai_image
|
||||
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
|
||||
|
||||
# Only import MetadataRegistry in non-standalone mode
|
||||
@@ -376,16 +375,6 @@ class RecipeRoutes:
|
||||
# Use meta field from image_info as metadata
|
||||
if 'meta' in image_info:
|
||||
metadata = image_info['meta']
|
||||
|
||||
else:
|
||||
# Not a Civitai image URL, use the original download method
|
||||
temp_path = download_civitai_image(url)
|
||||
|
||||
if not temp_path:
|
||||
return web.json_response({
|
||||
"error": "Failed to download image from URL",
|
||||
"loras": []
|
||||
}, status=400)
|
||||
|
||||
# If metadata wasn't obtained from Civitai API, extract it from the image
|
||||
if metadata is None:
|
||||
@@ -638,21 +627,6 @@ class RecipeRoutes:
|
||||
image = base64.b64decode(image_base64)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": f"Invalid base64 image data: {str(e)}"}, status=400)
|
||||
elif image_url:
|
||||
# Download image from URL
|
||||
temp_path = download_civitai_image(image_url)
|
||||
if not temp_path:
|
||||
return web.json_response({"error": "Failed to download image from URL"}, status=400)
|
||||
|
||||
# Read the downloaded image
|
||||
with open(temp_path, 'rb') as f:
|
||||
image = f.read()
|
||||
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
return web.json_response({"error": "No image data provided"}, status=400)
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ class UpdateRoutes:
|
||||
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Download latest release ZIP from GitHub and replace plugin files.
|
||||
Skips settings.json.
|
||||
Skips settings.json. Writes extracted file list to .tracking.
|
||||
"""
|
||||
repo_owner = "willmiao"
|
||||
repo_name = "ComfyUI-Lora-Manager"
|
||||
@@ -196,7 +196,6 @@ class UpdateRoutes:
|
||||
src = os.path.join(extracted_root, item)
|
||||
dst = os.path.join(plugin_root, item)
|
||||
if os.path.isdir(src):
|
||||
# Remove old folder, then copy
|
||||
if os.path.exists(dst):
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
|
||||
@@ -205,6 +204,17 @@ class UpdateRoutes:
|
||||
continue
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
# Write .tracking file: list all files under extracted_root, relative to extracted_root
|
||||
# for ComfyUI Manager to work properly
|
||||
tracking_info_file = os.path.join(plugin_root, '.tracking')
|
||||
tracking_files = []
|
||||
for root, dirs, files in os.walk(extracted_root):
|
||||
for file in files:
|
||||
rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
|
||||
tracking_files.append(rel_path.replace("\\", "/"))
|
||||
with open(tracking_info_file, "w", encoding='utf-8') as file:
|
||||
file.write('\n'.join(tracking_files))
|
||||
|
||||
os.remove(zip_path)
|
||||
logger.info(f"Updated plugin via ZIP to {version}")
|
||||
return True, version
|
||||
@@ -364,65 +374,28 @@ class UpdateRoutes:
|
||||
"""Get Git repository information"""
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
|
||||
|
||||
git_info = {
|
||||
'commit_hash': 'unknown',
|
||||
'short_hash': 'unknown',
|
||||
'short_hash': 'stable',
|
||||
'branch': 'unknown',
|
||||
'commit_date': 'unknown'
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
# Check if we're in a git repository
|
||||
if not os.path.exists(os.path.join(plugin_root, '.git')):
|
||||
return git_info
|
||||
|
||||
# Get current commit hash
|
||||
result = subprocess.run(
|
||||
['git', 'rev-parse', 'HEAD'],
|
||||
cwd=plugin_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
git_info['commit_hash'] = result.stdout.strip()
|
||||
git_info['short_hash'] = git_info['commit_hash'][:7]
|
||||
|
||||
# Get current branch name
|
||||
result = subprocess.run(
|
||||
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
||||
cwd=plugin_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
git_info['branch'] = result.stdout.strip()
|
||||
|
||||
# Get commit date
|
||||
result = subprocess.run(
|
||||
['git', 'show', '-s', '--format=%ci', 'HEAD'],
|
||||
cwd=plugin_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
commit_date = result.stdout.strip()
|
||||
# Format the date nicely if possible
|
||||
try:
|
||||
date_obj = datetime.strptime(commit_date, '%Y-%m-%d %H:%M:%S %z')
|
||||
git_info['commit_date'] = date_obj.strftime('%Y-%m-%d')
|
||||
except:
|
||||
git_info['commit_date'] = commit_date
|
||||
|
||||
|
||||
repo = git.Repo(plugin_root)
|
||||
commit = repo.head.commit
|
||||
git_info['commit_hash'] = commit.hexsha
|
||||
git_info['short_hash'] = commit.hexsha[:7]
|
||||
git_info['branch'] = repo.active_branch.name if not repo.head.is_detached else 'detached'
|
||||
git_info['commit_date'] = commit.committed_datetime.strftime('%Y-%m-%d')
|
||||
except Exception as e:
|
||||
logger.warning(f"Error getting git info: {e}")
|
||||
|
||||
|
||||
return git_info
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -21,6 +21,14 @@ class CheckpointScanner(ModelScanner):
|
||||
hash_index=ModelHashIndex()
|
||||
)
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
if hasattr(metadata, "model_type"):
|
||||
if root_path in config.checkpoints_roots:
|
||||
metadata.model_type = "checkpoint"
|
||||
elif root_path in config.unet_roots:
|
||||
metadata.model_type = "diffusion_model"
|
||||
return metadata
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get checkpoint root directories"""
|
||||
return config.base_models_roots
|
||||
@@ -569,12 +569,12 @@ class ModelScanner:
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
|
||||
# Use original path instead of real path
|
||||
file_path = entry.path.replace(os.sep, "/")
|
||||
await self._process_single_file(file_path, original_root, models)
|
||||
result = await self._process_model_file(file_path, original_root)
|
||||
if result:
|
||||
models.append(result)
|
||||
await asyncio.sleep(0)
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
# For directories, continue scanning with original path
|
||||
await scan_recursive(entry.path, visited_paths)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing entry {entry.path}: {e}")
|
||||
@@ -583,15 +583,6 @@ class ModelScanner:
|
||||
|
||||
await scan_recursive(root_path, set())
|
||||
return models
|
||||
|
||||
async def _process_single_file(self, file_path: str, root_path: str, models: list):
|
||||
"""Process a single file and add to results list"""
|
||||
try:
|
||||
result = await self._process_model_file(file_path, root_path)
|
||||
if result:
|
||||
models.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {file_path}: {e}")
|
||||
|
||||
def is_initializing(self) -> bool:
|
||||
"""Check if the scanner is currently initializing"""
|
||||
@@ -613,7 +604,10 @@ class ModelScanner:
|
||||
return os.path.dirname(rel_path).replace(os.path.sep, '/')
|
||||
return ''
|
||||
|
||||
# Common methods shared between scanners
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
"""Hook for subclasses: adjust metadata during scanning"""
|
||||
return metadata
|
||||
|
||||
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||
"""Process a single model file and return its metadata"""
|
||||
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||
@@ -667,6 +661,9 @@ class ModelScanner:
|
||||
if metadata is None:
|
||||
metadata = await self._create_default_metadata(file_path)
|
||||
|
||||
# Hook: allow subclasses to adjust metadata
|
||||
metadata = self.adjust_metadata(metadata, file_path, root_path)
|
||||
|
||||
model_data = metadata.to_dict()
|
||||
|
||||
# Skip excluded models
|
||||
@@ -732,48 +729,6 @@ class ModelScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||
|
||||
async def _scan_directory(self, root_path: str) -> List[Dict]:
|
||||
"""Base implementation for directory scanning"""
|
||||
models = []
|
||||
original_root = root_path
|
||||
|
||||
async def scan_recursive(path: str, visited_paths: set):
|
||||
try:
|
||||
real_path = os.path.realpath(path)
|
||||
if real_path in visited_paths:
|
||||
logger.debug(f"Skipping already visited path: {path}")
|
||||
return
|
||||
visited_paths.add(real_path)
|
||||
|
||||
with os.scandir(path) as it:
|
||||
entries = list(it)
|
||||
for entry in entries:
|
||||
try:
|
||||
if entry.is_file(follow_symlinks=True):
|
||||
ext = os.path.splitext(entry.name)[1].lower()
|
||||
if ext in self.file_extensions:
|
||||
file_path = entry.path.replace(os.sep, "/")
|
||||
await self._process_single_file(file_path, original_root, models)
|
||||
await asyncio.sleep(0)
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
await scan_recursive(entry.path, visited_paths)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing entry {entry.path}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning {path}: {e}")
|
||||
|
||||
await scan_recursive(root_path, set())
|
||||
return models
|
||||
|
||||
async def _process_single_file(self, file_path: str, root_path: str, models_list: list):
|
||||
"""Process a single file and add to results list"""
|
||||
try:
|
||||
result = await self._process_model_file(file_path, root_path)
|
||||
if result:
|
||||
models_list.append(result)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {file_path}: {e}")
|
||||
|
||||
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
|
||||
"""Add a model to the cache
|
||||
|
||||
|
||||
@@ -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._auto_set_default_roots()
|
||||
self._check_environment_variables()
|
||||
|
||||
def _load_settings(self) -> Dict[str, Any]:
|
||||
@@ -21,6 +22,28 @@ class SettingsManager:
|
||||
logger.error(f"Error loading settings: {e}")
|
||||
return self._get_default_settings()
|
||||
|
||||
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', {})
|
||||
updated = False
|
||||
# loras
|
||||
loras = folder_paths.get('loras', [])
|
||||
if isinstance(loras, list) and len(loras) == 1 and not self.settings.get('default_lora_root'):
|
||||
self.settings['default_lora_root'] = loras[0]
|
||||
updated = True
|
||||
# checkpoints
|
||||
checkpoints = folder_paths.get('checkpoints', [])
|
||||
if isinstance(checkpoints, list) and len(checkpoints) == 1 and not self.settings.get('default_checkpoint_root'):
|
||||
self.settings['default_checkpoint_root'] = checkpoints[0]
|
||||
updated = True
|
||||
# embeddings
|
||||
embeddings = folder_paths.get('embeddings', [])
|
||||
if isinstance(embeddings, list) and len(embeddings) == 1 and not self.settings.get('default_embedding_root'):
|
||||
self.settings['default_embedding_root'] = embeddings[0]
|
||||
updated = True
|
||||
if updated:
|
||||
self._save_settings()
|
||||
|
||||
def _check_environment_variables(self) -> None:
|
||||
"""Check for environment variables and update settings if needed"""
|
||||
env_api_key = os.environ.get('CIVITAI_API_KEY')
|
||||
|
||||
@@ -43,7 +43,15 @@ class ExampleImagesFileManager:
|
||||
|
||||
# Construct folder path for this model
|
||||
model_folder = os.path.join(example_images_path, model_hash)
|
||||
|
||||
model_folder = os.path.abspath(model_folder) # Get absolute path
|
||||
|
||||
# Path validation: ensure model_folder is under example_images_path
|
||||
if not model_folder.startswith(os.path.abspath(example_images_path)):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Invalid model folder path'
|
||||
}, status=400)
|
||||
|
||||
# Check if folder exists
|
||||
if not os.path.exists(model_folder):
|
||||
return web.json_response({
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
from difflib import SequenceMatcher
|
||||
import requests
|
||||
import tempfile
|
||||
import os
|
||||
from bs4 import BeautifulSoup
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
import asyncio
|
||||
@@ -50,81 +47,6 @@ def get_lora_info(lora_name):
|
||||
# No event loop is running, we can use asyncio.run()
|
||||
return asyncio.run(_get_lora_info_async())
|
||||
|
||||
def download_twitter_image(url):
|
||||
"""Download image from a URL containing twitter:image meta tag
|
||||
|
||||
Args:
|
||||
url (str): The URL to download image from
|
||||
|
||||
Returns:
|
||||
str: Path to downloaded temporary image file
|
||||
"""
|
||||
try:
|
||||
# Download page content
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Find twitter:image meta tag
|
||||
meta_tag = soup.find('meta', attrs={'property': 'twitter:image'})
|
||||
if not meta_tag:
|
||||
return None
|
||||
|
||||
image_url = meta_tag['content']
|
||||
|
||||
# Download image
|
||||
image_response = requests.get(image_url)
|
||||
image_response.raise_for_status()
|
||||
|
||||
# Save to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
||||
temp_file.write(image_response.content)
|
||||
return temp_file.name
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading twitter image: {e}")
|
||||
return None
|
||||
|
||||
def download_civitai_image(url):
|
||||
"""Download image from a URL containing avatar image with specific class and style attributes
|
||||
|
||||
Args:
|
||||
url (str): The URL to download image from
|
||||
|
||||
Returns:
|
||||
str: Path to downloaded temporary image file
|
||||
"""
|
||||
try:
|
||||
# Download page content
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# Find image with specific class and style attributes
|
||||
image = soup.select_one('img.EdgeImage_image__iH4_q.max-h-full.w-auto.max-w-full')
|
||||
|
||||
if not image or 'src' not in image.attrs:
|
||||
return None
|
||||
|
||||
image_url = image['src']
|
||||
|
||||
# Download image
|
||||
image_response = requests.get(image_url)
|
||||
image_response.raise_for_status()
|
||||
|
||||
# Save to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
||||
temp_file.write(image_response.content)
|
||||
return temp_file.name
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading civitai avatar: {e}")
|
||||
return None
|
||||
|
||||
def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool:
|
||||
"""
|
||||
Check if text matches pattern using fuzzy matching.
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "0.8.22"
|
||||
version = "0.8.25"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
"jinja2",
|
||||
"safetensors",
|
||||
"beautifulsoup4",
|
||||
"piexif",
|
||||
"Pillow",
|
||||
"olefile", # for getting rid of warning message
|
||||
"requests",
|
||||
"toml",
|
||||
"natsort",
|
||||
"GitPython"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
aiohttp
|
||||
jinja2
|
||||
safetensors
|
||||
beautifulsoup4
|
||||
piexif
|
||||
Pillow
|
||||
olefile
|
||||
requests
|
||||
toml
|
||||
numpy
|
||||
natsort
|
||||
|
||||
@@ -424,6 +424,33 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Style for version name */
|
||||
.version-name {
|
||||
display: inline-block;
|
||||
color: rgba(255,255,255,0.8); /* Muted white */
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
font-size: 0.85em;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
margin-top: 2px;
|
||||
opacity: 0.8; /* Slightly transparent for better readability */
|
||||
border: 1px solid rgba(255,255,255,0.25); /* Subtle border */
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 6px;
|
||||
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
|
||||
}
|
||||
|
||||
/* Medium density adjustments for version name */
|
||||
.medium-density .version-name {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
/* Compact density adjustments for version name */
|
||||
.compact-density .version-name {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
/* Prevent text selection on cards and interactive elements */
|
||||
.model-card,
|
||||
.model-card *,
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Responsive header container for larger screens */
|
||||
@media (min-width: 2000px) {
|
||||
.header-container {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 3000px) {
|
||||
.header-container {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo and title styling */
|
||||
.header-branding {
|
||||
display: flex;
|
||||
|
||||
@@ -183,7 +183,11 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-file-name-btn {
|
||||
/* 合并编辑按钮样式 */
|
||||
.edit-model-name-btn,
|
||||
.edit-file-name-btn,
|
||||
.edit-base-model-btn,
|
||||
.edit-model-description-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
@@ -195,17 +199,28 @@
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.edit-model-name-btn.visible,
|
||||
.edit-file-name-btn.visible,
|
||||
.file-name-wrapper:hover .edit-file-name-btn {
|
||||
.edit-base-model-btn.visible,
|
||||
.edit-model-description-btn.visible,
|
||||
.model-name-header:hover .edit-model-name-btn,
|
||||
.file-name-wrapper:hover .edit-file-name-btn,
|
||||
.base-model-display:hover .edit-base-model-btn,
|
||||
.model-name-header:hover .edit-model-description-btn {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.edit-file-name-btn:hover {
|
||||
.edit-model-name-btn:hover,
|
||||
.edit-file-name-btn:hover,
|
||||
.edit-base-model-btn:hover,
|
||||
.edit-model-description-btn:hover {
|
||||
opacity: 0.8 !important;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .edit-file-name-btn:hover {
|
||||
[data-theme="dark"] .edit-model-name-btn:hover,
|
||||
[data-theme="dark"] .edit-file-name-btn:hover,
|
||||
[data-theme="dark"] .edit-base-model-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@@ -234,32 +249,6 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.edit-base-model-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.edit-base-model-btn.visible,
|
||||
.base-model-display:hover .edit-base-model-btn {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.edit-base-model-btn:hover {
|
||||
opacity: 0.8 !important;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .edit-base-model-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.base-model-selector {
|
||||
width: 100%;
|
||||
padding: 3px 5px;
|
||||
@@ -316,32 +305,6 @@
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.edit-model-name-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
margin-left: var(--space-1);
|
||||
}
|
||||
|
||||
.edit-model-name-btn.visible,
|
||||
.model-name-header:hover .edit-model-name-btn {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.edit-model-name-btn:hover {
|
||||
opacity: 0.8 !important;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .edit-model-name-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Tab System Styling */
|
||||
.showcase-tabs {
|
||||
display: flex;
|
||||
|
||||
@@ -29,7 +29,7 @@ export const MODEL_CONFIG = {
|
||||
defaultPageSize: 100,
|
||||
supportsLetterFilter: false,
|
||||
supportsBulkOperations: true,
|
||||
supportsMove: false,
|
||||
supportsMove: true,
|
||||
templateName: 'checkpoints.html'
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
@@ -63,6 +63,10 @@ export function getApiEndpoints(modelType) {
|
||||
|
||||
// Bulk operations
|
||||
bulkDelete: `/api/${modelType}/bulk-delete`,
|
||||
|
||||
// Move operations (now common for all model types that support move)
|
||||
moveModel: `/api/${modelType}/move_model`,
|
||||
moveBulk: `/api/${modelType}/move_models_bulk`,
|
||||
|
||||
// CivitAI integration
|
||||
fetchCivitai: `/api/${modelType}/fetch-civitai`,
|
||||
@@ -99,14 +103,14 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
||||
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
||||
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
||||
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
|
||||
moveModel: `/api/${MODEL_TYPES.LORA}/move_model`,
|
||||
moveBulk: `/api/${MODEL_TYPES.LORA}/move_models_bulk`,
|
||||
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
|
||||
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
||||
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
||||
},
|
||||
[MODEL_TYPES.CHECKPOINT]: {
|
||||
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
|
||||
checkpoints_roots: `/api/${MODEL_TYPES.CHECKPOINT}/checkpoints_roots`,
|
||||
unet_roots: `/api/${MODEL_TYPES.CHECKPOINT}/unet_roots`,
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@ import {
|
||||
DOWNLOAD_ENDPOINTS,
|
||||
WS_ENDPOINTS
|
||||
} from './apiConfig.js';
|
||||
import { createModelApiClient } from './modelApiFactory.js';
|
||||
|
||||
/**
|
||||
* Universal API client for all model types
|
||||
* Abstract base class for all model API clients
|
||||
*/
|
||||
class ModelApiClient {
|
||||
export class BaseModelApiClient {
|
||||
constructor(modelType = null) {
|
||||
if (this.constructor === BaseModelApiClient) {
|
||||
throw new Error("BaseModelApiClient is abstract and cannot be instantiated directly");
|
||||
}
|
||||
this.modelType = modelType || getCurrentModelType();
|
||||
this.apiConfig = getCompleteApiConfig(this.modelType);
|
||||
}
|
||||
@@ -42,9 +46,6 @@ class ModelApiClient {
|
||||
return pageState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch models with pagination
|
||||
*/
|
||||
async fetchModelsPage(page = 1, pageSize = null) {
|
||||
const pageState = this.getPageState();
|
||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
||||
@@ -79,9 +80,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset and reload models with virtual scrolling
|
||||
*/
|
||||
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
||||
const pageState = this.getPageState();
|
||||
|
||||
@@ -93,24 +91,20 @@ class ModelApiClient {
|
||||
pageState.currentPage = 1; // Reset to first page
|
||||
}
|
||||
|
||||
// Fetch the current page
|
||||
const startTime = performance.now();
|
||||
const result = await this.fetchModelsPage(pageState.currentPage, pageState.pageSize);
|
||||
const endTime = performance.now();
|
||||
console.log(`fetchModelsPage耗时: ${(endTime - startTime).toFixed(2)} ms`);
|
||||
|
||||
// Update the virtual scroller
|
||||
state.virtualScroller.refreshWithData(
|
||||
result.items,
|
||||
result.totalItems,
|
||||
result.hasMore
|
||||
);
|
||||
|
||||
// Update state
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = pageState.currentPage + 1;
|
||||
|
||||
// Update folders if needed
|
||||
if (updateFolders && result.folders) {
|
||||
updateFolderTags(result.folders);
|
||||
}
|
||||
@@ -126,9 +120,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a model
|
||||
*/
|
||||
async deleteModel(filePath) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`);
|
||||
@@ -163,9 +154,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude a model
|
||||
*/
|
||||
async excludeModel(filePath) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`);
|
||||
@@ -200,9 +188,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a model file
|
||||
*/
|
||||
async renameModelFile(filePath, newFileName) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||
@@ -239,9 +224,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace model preview
|
||||
*/
|
||||
replaceModelPreview(filePath) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
@@ -257,9 +239,6 @@ class ModelApiClient {
|
||||
input.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload preview image
|
||||
*/
|
||||
async uploadPreview(filePath, file, nsfwLevel = 0) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Uploading preview...');
|
||||
@@ -281,7 +260,6 @@ class ModelApiClient {
|
||||
const data = await response.json();
|
||||
const pageState = this.getPageState();
|
||||
|
||||
// Update the version timestamp
|
||||
const timestamp = Date.now();
|
||||
if (pageState.previewVersions) {
|
||||
pageState.previewVersions.set(filePath, timestamp);
|
||||
@@ -305,9 +283,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save model metadata
|
||||
*/
|
||||
async saveModelMetadata(filePath, data) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
@@ -332,9 +307,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models (scan)
|
||||
*/
|
||||
async refreshModels(fullRebuild = false) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(
|
||||
@@ -360,9 +332,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CivitAI metadata for single model
|
||||
*/
|
||||
async refreshSingleModelMetadata(filePath) {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
||||
@@ -399,9 +368,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CivitAI metadata for all models
|
||||
*/
|
||||
async fetchCivitaiMetadata() {
|
||||
let ws = null;
|
||||
|
||||
@@ -477,9 +443,6 @@ class ModelApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CivitAI metadata for multiple models with progress tracking
|
||||
*/
|
||||
async refreshBulkModelMetadata(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
@@ -493,7 +456,6 @@ class ModelApiClient {
|
||||
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
|
||||
|
||||
try {
|
||||
// Process files sequentially to avoid overwhelming the API
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const fileName = filePath.split('/').pop();
|
||||
@@ -535,7 +497,6 @@ class ModelApiClient {
|
||||
processedCount++;
|
||||
}
|
||||
|
||||
// Show completion message
|
||||
let completionMessage;
|
||||
if (successCount === totalItems) {
|
||||
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
|
||||
@@ -575,113 +536,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a single model to target path
|
||||
* @returns {string|null} - The new file path if moved, null if not moved
|
||||
*/
|
||||
async moveSingleModel(filePath, targetPath) {
|
||||
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
||||
showToast('Model is already in the selected folder', 'info');
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.moveModel, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
throw new Error('Failed to move model');
|
||||
}
|
||||
|
||||
if (result && result.message) {
|
||||
showToast(result.message, 'info');
|
||||
} else {
|
||||
showToast('Model moved successfully', 'success');
|
||||
}
|
||||
|
||||
// Return new file path if move succeeded
|
||||
if (result.success) {
|
||||
return result.new_file_path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move multiple models to target path
|
||||
* @returns {Array<string>} - Array of new file paths that were moved successfully
|
||||
*/
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
const movedPaths = filePaths.filter(path => {
|
||||
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
|
||||
});
|
||||
|
||||
if (movedPaths.length === 0) {
|
||||
showToast('All selected models are already in the target folder', 'info');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.moveBulk, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_paths: movedPaths,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to move models');
|
||||
}
|
||||
|
||||
let successFilePaths = [];
|
||||
if (result.success) {
|
||||
if (result.failure_count > 0) {
|
||||
showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning');
|
||||
console.log('Move operation results:', result.results);
|
||||
const failedFiles = result.results
|
||||
.filter(r => !r.success)
|
||||
.map(r => {
|
||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
||||
return `${fileName}: ${r.message}`;
|
||||
});
|
||||
if (failedFiles.length > 0) {
|
||||
const failureMessage = failedFiles.length <= 3
|
||||
? failedFiles.join('\n')
|
||||
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
showToast(`Successfully moved ${result.success_count} models`, 'success');
|
||||
}
|
||||
// Collect new file paths for successful moves
|
||||
successFilePaths = result.results
|
||||
.filter(r => r.success)
|
||||
.map(r => r.path);
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to move models');
|
||||
}
|
||||
return successFilePaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Civitai model versions
|
||||
*/
|
||||
async fetchCivitaiVersions(modelId) {
|
||||
try {
|
||||
const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`);
|
||||
@@ -699,9 +553,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch model roots
|
||||
*/
|
||||
async fetchModelRoots() {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.roots);
|
||||
@@ -715,9 +566,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch model folders
|
||||
*/
|
||||
async fetchModelFolders() {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||
@@ -731,9 +579,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a model
|
||||
*/
|
||||
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
|
||||
try {
|
||||
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
||||
@@ -759,13 +604,9 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query parameters for API requests
|
||||
*/
|
||||
_buildQueryParams(baseParams, pageState) {
|
||||
const params = new URLSearchParams(baseParams);
|
||||
|
||||
// Add common parameters
|
||||
if (pageState.activeFolder !== null) {
|
||||
params.append('folder', pageState.activeFolder);
|
||||
}
|
||||
@@ -774,12 +615,10 @@ class ModelApiClient {
|
||||
params.append('favorites_only', 'true');
|
||||
}
|
||||
|
||||
// Add letter filter for supported model types
|
||||
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||
params.append('first_letter', pageState.activeLetterFilter);
|
||||
}
|
||||
|
||||
// Add search parameters
|
||||
if (pageState.filters?.search) {
|
||||
params.append('search', pageState.filters.search);
|
||||
params.append('fuzzy', 'true');
|
||||
@@ -794,7 +633,6 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Add filter parameters
|
||||
if (pageState.filters) {
|
||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||
pageState.filters.tags.forEach(tag => {
|
||||
@@ -809,17 +647,12 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Add model-specific parameters
|
||||
this._addModelSpecificParams(params, pageState);
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add model-specific parameters to query
|
||||
*/
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
// Override in specific implementations or handle via configuration
|
||||
if (this.modelType === 'loras') {
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
@@ -837,23 +670,149 @@ class ModelApiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export factory functions and utilities
|
||||
export function createModelApiClient(modelType = null) {
|
||||
return new ModelApiClient(modelType);
|
||||
}
|
||||
async moveSingleModel(filePath, targetPath) {
|
||||
// Only allow move if supported
|
||||
if (!this.apiConfig.config.supportsMove) {
|
||||
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
||||
return null;
|
||||
}
|
||||
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
|
||||
showToast(`${this.apiConfig.config.displayName} is already in the selected folder`, 'info');
|
||||
return null;
|
||||
}
|
||||
|
||||
let _singletonClient = null;
|
||||
const response = await fetch(this.apiConfig.endpoints.moveModel, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
|
||||
export function getModelApiClient() {
|
||||
if (!_singletonClient) {
|
||||
_singletonClient = new ModelApiClient();
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (result && result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
throw new Error(`Failed to move ${this.apiConfig.config.displayName}`);
|
||||
}
|
||||
|
||||
if (result && result.message) {
|
||||
showToast(result.message, 'info');
|
||||
} else {
|
||||
showToast(`${this.apiConfig.config.displayName} moved successfully`, 'success');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
return result.new_file_path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
_singletonClient.setModelType(state.currentPageType);
|
||||
return _singletonClient;
|
||||
}
|
||||
|
||||
export async function resetAndReload(updateFolders = false) {
|
||||
return getModelApiClient().loadMoreWithVirtualScroll(true, updateFolders);
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
if (!this.apiConfig.config.supportsMove) {
|
||||
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
|
||||
return [];
|
||||
}
|
||||
const movedPaths = filePaths.filter(path => {
|
||||
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
|
||||
});
|
||||
|
||||
if (movedPaths.length === 0) {
|
||||
showToast(`All selected ${this.apiConfig.config.displayName}s are already in the target folder`, 'info');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_paths: movedPaths,
|
||||
target_path: targetPath
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
|
||||
}
|
||||
|
||||
let successFilePaths = [];
|
||||
if (result.success) {
|
||||
if (result.failure_count > 0) {
|
||||
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
|
||||
console.log('Move operation results:', result.results);
|
||||
const failedFiles = result.results
|
||||
.filter(r => !r.success)
|
||||
.map(r => {
|
||||
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
||||
return `${fileName}: ${r.message}`;
|
||||
});
|
||||
if (failedFiles.length > 0) {
|
||||
const failureMessage = failedFiles.length <= 3
|
||||
? failedFiles.join('\n')
|
||||
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
|
||||
}
|
||||
successFilePaths = result.results
|
||||
.filter(r => r.success)
|
||||
.map(r => r.path);
|
||||
} else {
|
||||
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||
}
|
||||
return successFilePaths;
|
||||
}
|
||||
|
||||
async bulkDeleteModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
}
|
||||
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_paths: filePaths
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
deleted_count: result.deleted_count,
|
||||
failed_count: result.failed_count || 0,
|
||||
errors: result.errors || []
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
93
static/js/api/checkpointApi.js
Normal file
93
static/js/api/checkpointApi.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { BaseModelApiClient } from './baseModelApi.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Checkpoint-specific API client
|
||||
*/
|
||||
export class CheckpointApiClient extends BaseModelApiClient {
|
||||
/**
|
||||
* Get checkpoint information
|
||||
*/
|
||||
async getCheckpointInfo(filePath) {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.info, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ file_path: filePath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch checkpoint info');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching checkpoint info:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get checkpoint roots
|
||||
*/
|
||||
async getCheckpointsRoots() {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.checkpoints_roots, {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch checkpoints roots');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching checkpoints roots:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unet roots
|
||||
*/
|
||||
async getUnetRoots() {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.unet_roots, {
|
||||
method: 'GET'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch unet roots');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching unet roots:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate roots based on model type
|
||||
*/
|
||||
async fetchModelRoots(modelType = 'checkpoint') {
|
||||
try {
|
||||
let response;
|
||||
if (modelType === 'diffusion_model') {
|
||||
response = await fetch(this.apiConfig.endpoints.specific.unet_roots, {
|
||||
method: 'GET'
|
||||
});
|
||||
} else {
|
||||
response = await fetch(this.apiConfig.endpoints.specific.checkpoints_roots, {
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${modelType} roots`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${modelType} roots:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
static/js/api/embeddingApi.js
Normal file
8
static/js/api/embeddingApi.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { BaseModelApiClient } from './baseModelApi.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Embedding-specific API client
|
||||
*/
|
||||
export class EmbeddingApiClient extends BaseModelApiClient {
|
||||
}
|
||||
94
static/js/api/loraApi.js
Normal file
94
static/js/api/loraApi.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { BaseModelApiClient } from './baseModelApi.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { getSessionItem } from '../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
* LoRA-specific API client
|
||||
*/
|
||||
export class LoraApiClient extends BaseModelApiClient {
|
||||
/**
|
||||
* Add LoRA-specific parameters to query
|
||||
*/
|
||||
_addModelSpecificParams(params, pageState) {
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
if (filterLoraHash) {
|
||||
params.append('lora_hash', filterLoraHash);
|
||||
} else if (filterLoraHashes) {
|
||||
try {
|
||||
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
|
||||
params.append('lora_hashes', filterLoraHashes.join(','));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing lora hashes from session storage:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LoRA notes
|
||||
*/
|
||||
async getLoraNote(filePath) {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.notes,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ file_path: filePath })
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch LoRA notes');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching LoRA notes:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LoRA trigger words
|
||||
*/
|
||||
async getLoraTriggerWords(filePath) {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.triggerWords, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ file_path: filePath })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch trigger words');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching trigger words:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get letter counts for LoRAs
|
||||
*/
|
||||
async getLetterCounts() {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.letterCounts);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch letter counts');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching letter counts:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
35
static/js/api/modelApiFactory.js
Normal file
35
static/js/api/modelApiFactory.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { LoraApiClient } from './loraApi.js';
|
||||
import { CheckpointApiClient } from './checkpointApi.js';
|
||||
import { EmbeddingApiClient } from './embeddingApi.js';
|
||||
import { MODEL_TYPES } from './apiConfig.js';
|
||||
import { state } from '../state/index.js';
|
||||
|
||||
export function createModelApiClient(modelType) {
|
||||
switch (modelType) {
|
||||
case MODEL_TYPES.LORA:
|
||||
return new LoraApiClient();
|
||||
case MODEL_TYPES.CHECKPOINT:
|
||||
return new CheckpointApiClient();
|
||||
case MODEL_TYPES.EMBEDDING:
|
||||
return new EmbeddingApiClient();
|
||||
default:
|
||||
throw new Error(`Unsupported model type: ${modelType}`);
|
||||
}
|
||||
}
|
||||
|
||||
let _singletonClient = null;
|
||||
|
||||
export function getModelApiClient() {
|
||||
const currentType = state.currentPageType;
|
||||
|
||||
if (!_singletonClient || _singletonClient.modelType !== currentType) {
|
||||
_singletonClient = createModelApiClient(currentType);
|
||||
}
|
||||
|
||||
return _singletonClient;
|
||||
}
|
||||
|
||||
export function resetAndReload(updateFolders = false) {
|
||||
const client = getModelApiClient();
|
||||
return client.loadMoreWithVirtualScroll(true, updateFolders);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
|
||||
export class CheckpointContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -54,8 +54,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'move':
|
||||
// Move to folder (placeholder)
|
||||
showToast('Move to folder feature coming soon', 'info');
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.model_type);
|
||||
break;
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||
|
||||
export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
@@ -54,8 +54,7 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'move':
|
||||
// Move to folder (placeholder)
|
||||
showToast('Move to folder feature coming soon', 'info');
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
||||
import { copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { copyLoraSyntax, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
|
||||
export class LoraContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -36,7 +37,7 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
break;
|
||||
case 'copyname':
|
||||
// Generate and copy LoRA syntax
|
||||
this.copyLoraSyntax();
|
||||
copyLoraSyntax(this.currentCard);
|
||||
break;
|
||||
case 'sendappend':
|
||||
// Send LoRA to workflow (append mode)
|
||||
@@ -66,16 +67,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
// Specific LoRA methods
|
||||
copyLoraSyntax() {
|
||||
const card = this.currentCard;
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
|
||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
||||
}
|
||||
|
||||
sendLoraToWorkflow(replaceMode) {
|
||||
const card = this.currentCard;
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { formatDate } from '../utils/formatters.js';
|
||||
import { resetAndReload} from '../api/baseModelApi.js';
|
||||
import { resetAndReload} from '../api/modelApiFactory.js';
|
||||
import { LoadingManager } from '../managers/LoadingManager.js';
|
||||
|
||||
export class ModelDuplicatesManager {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// AlphabetBar.js - Component for alphabet filtering
|
||||
import { getCurrentPageState } from '../../state/index.js';
|
||||
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js';
|
||||
import { resetAndReload } from '../../api/baseModelApi.js';
|
||||
import { resetAndReload } from '../../api/modelApiFactory.js';
|
||||
|
||||
/**
|
||||
* AlphabetBar class - Handles the alphabet filtering UI and interactions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// CheckpointsControls.js - Specific implementation for the Checkpoints page
|
||||
import { PageControls } from './PageControls.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// EmbeddingsControls.js - Specific implementation for the Embeddings page
|
||||
import { PageControls } from './PageControls.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// LorasControls.js - Specific implementation for the LoRAs page
|
||||
import { PageControls } from './PageControls.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { createAlphabetBar } from '../alphabet/index.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
|
||||
// Add global event delegation handlers
|
||||
@@ -152,7 +153,7 @@ async function toggleFavorite(card) {
|
||||
}
|
||||
|
||||
function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
if (modelType === 'loras') {
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
@@ -164,16 +165,13 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||
}
|
||||
|
||||
function handleCopyAction(card, modelType) {
|
||||
if (modelType === 'loras') {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
||||
} else if (modelType === 'checkpoints') {
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
copyLoraSyntax(card);
|
||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||
// Checkpoint copy functionality - copy checkpoint name
|
||||
const checkpointName = card.dataset.file_name;
|
||||
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
||||
} else if (modelType === 'embeddings') {
|
||||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||
const embeddingName = card.dataset.file_name;
|
||||
copyToClipboard(embeddingName, 'Embedding name copied');
|
||||
}
|
||||
@@ -377,10 +375,15 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||
|
||||
// LoRA specific data
|
||||
if (modelType === 'loras') {
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
card.dataset.usage_tips = model.usage_tips;
|
||||
}
|
||||
|
||||
// checkpoint specific data
|
||||
if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||
card.dataset.model_type = model.model_type; // checkpoint or diffusion_model
|
||||
}
|
||||
|
||||
// Store metadata if available
|
||||
if (model.civitai) {
|
||||
card.dataset.meta = JSON.stringify(model.civitai || {});
|
||||
@@ -406,7 +409,7 @@ export function createModelCard(model, modelType) {
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||
if (modelType === 'loras' && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
@@ -482,6 +485,7 @@ export function createModelCard(model, modelType) {
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${model.model_name}</span>
|
||||
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-folder-open"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* ModelDescription.js
|
||||
* Handles model description related functionality - General version
|
||||
@@ -40,4 +42,99 @@ export function setupTabSwitching() {
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up model description editing functionality
|
||||
* @param {string} filePath - File path
|
||||
*/
|
||||
export function setupModelDescriptionEditing(filePath) {
|
||||
const descContent = document.querySelector('.model-description-content');
|
||||
const descContainer = document.querySelector('.model-description-container');
|
||||
if (!descContent || !descContainer) return;
|
||||
|
||||
// Add edit button if not present
|
||||
let editBtn = descContainer.querySelector('.edit-model-description-btn');
|
||||
if (!editBtn) {
|
||||
editBtn = document.createElement('button');
|
||||
editBtn.className = 'edit-model-description-btn';
|
||||
editBtn.title = 'Edit model description';
|
||||
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
|
||||
descContainer.insertBefore(editBtn, descContent);
|
||||
}
|
||||
|
||||
// Show edit button on hover
|
||||
descContainer.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
descContainer.addEventListener('mouseleave', () => {
|
||||
if (!descContainer.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
descContainer.classList.add('editing');
|
||||
descContent.setAttribute('contenteditable', 'true');
|
||||
descContent.dataset.originalValue = descContent.innerHTML.trim();
|
||||
descContent.focus();
|
||||
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(descContent);
|
||||
range.collapse(false);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Keyboard events
|
||||
descContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.innerHTML = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Save on blur
|
||||
descContent.addEventListener('blur', async function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
const newValue = this.innerHTML.trim();
|
||||
const originalValue = this.dataset.originalValue;
|
||||
if (newValue === originalValue) {
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
if (!newValue) {
|
||||
this.innerHTML = originalValue;
|
||||
showToast('Description cannot be empty', 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Save to backend
|
||||
const { getModelApiClient } = await import('../../api/modelApiFactory.js');
|
||||
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
|
||||
showToast('Model description updated', 'success');
|
||||
} catch (err) {
|
||||
this.innerHTML = originalValue;
|
||||
showToast('Failed to update model description', 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
function exitEditMode() {
|
||||
descContent.removeAttribute('contenteditable');
|
||||
descContainer.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.js';
|
||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
/**
|
||||
* Set up model name editing functionality
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
scrollToTop,
|
||||
loadExampleImages
|
||||
} from './showcase/ShowcaseView.js';
|
||||
import { setupTabSwitching } from './ModelDescription.js';
|
||||
import { setupTabSwitching, setupModelDescriptionEditing } from './ModelDescription.js';
|
||||
import {
|
||||
setupModelNameEditing,
|
||||
setupBaseModelEditing,
|
||||
setupFileNameEditing
|
||||
} from './ModelMetadata.js';
|
||||
import { setupTagEditMode } from './ModelTags.js';
|
||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
@@ -33,7 +33,6 @@ export function showModelModal(model, modelType) {
|
||||
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
// Generate model type specific content
|
||||
// const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : '';
|
||||
let typeSpecificContent;
|
||||
if (modelType === 'loras') {
|
||||
typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
|
||||
@@ -211,6 +210,7 @@ export function showModelModal(model, modelType) {
|
||||
setupModelNameEditing(model.file_path);
|
||||
setupBaseModelEditing(model.file_path);
|
||||
setupFileNameEditing(model.file_path);
|
||||
setupModelDescriptionEditing(model.file_path, model.modelDescription || '');
|
||||
setupEventHandlers(model.file_path);
|
||||
|
||||
// LoRA specific setup
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Module for handling model tag editing functionality - 共享版本
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
// Preset tag suggestions
|
||||
const PRESET_TAGS = [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* PresetTags.js
|
||||
* Handles LoRA model preset parameter tags - Shared version
|
||||
*/
|
||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
/**
|
||||
* Parse preset parameters
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Moved to shared directory for consistency
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
|
||||
/**
|
||||
* Fetch trained words for a model
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { getModelApiClient } from '../../../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||
|
||||
/**
|
||||
* Try to load local image first, fall back to remote if local fails
|
||||
|
||||
@@ -5,6 +5,8 @@ import { modalManager } from './managers/ModalManager.js';
|
||||
import { updateService } from './managers/UpdateService.js';
|
||||
import { HeaderManager } from './components/Header.js';
|
||||
import { settingsManager } from './managers/SettingsManager.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||
import { helpManager } from './managers/HelpManager.js';
|
||||
import { bannerService } from './managers/BannerService.js';
|
||||
@@ -33,11 +35,16 @@ export class AppCore {
|
||||
window.settingsManager = settingsManager;
|
||||
window.exampleImagesManager = exampleImagesManager;
|
||||
window.helpManager = helpManager;
|
||||
window.moveManager = moveManager;
|
||||
window.bulkManager = bulkManager;
|
||||
|
||||
// Initialize UI components
|
||||
window.headerManager = new HeaderManager();
|
||||
initTheme();
|
||||
initBackToTop();
|
||||
|
||||
// Initialize the bulk manager
|
||||
bulkManager.initialize();
|
||||
|
||||
// Initialize the example images manager
|
||||
exampleImagesManager.initialize();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { appCore } from './core.js';
|
||||
import { state } from './state/index.js';
|
||||
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
import { LoraContextMenu } from './components/ContextMenu/index.js';
|
||||
import { createPageControls } from './components/controls/index.js';
|
||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||
@@ -33,15 +31,6 @@ class LoraPageManager {
|
||||
window.closeDeleteModal = closeDeleteModal;
|
||||
window.confirmExclude = confirmExclude;
|
||||
window.closeExcludeModal = closeExcludeModal;
|
||||
window.moveManager = moveManager;
|
||||
|
||||
// Bulk operations
|
||||
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
|
||||
window.clearSelection = () => bulkManager.clearSelection();
|
||||
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
|
||||
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
|
||||
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
|
||||
window.bulkManager = bulkManager;
|
||||
|
||||
// Expose duplicates manager
|
||||
window.modelDuplicatesManager = this.duplicatesManager;
|
||||
@@ -56,9 +45,6 @@ class LoraPageManager {
|
||||
// Initialize cards for current bulk mode state (should be false initially)
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
// Initialize the bulk manager
|
||||
bulkManager.initialize();
|
||||
|
||||
// Initialize common page features (virtual scroll)
|
||||
appCore.initializePageFeatures();
|
||||
}
|
||||
|
||||
@@ -1,147 +1,201 @@
|
||||
import { state } from '../state/index.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
||||
import { moveManager } from './MoveManager.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
|
||||
export class BulkManager {
|
||||
constructor() {
|
||||
this.bulkBtn = document.getElementById('bulkOperationsBtn');
|
||||
this.bulkPanel = document.getElementById('bulkOperationsPanel');
|
||||
this.isStripVisible = false; // Track strip visibility state
|
||||
this.isStripVisible = false;
|
||||
|
||||
// Initialize selected loras set in state if not already there
|
||||
if (!state.selectedLoras) {
|
||||
state.selectedLoras = new Set();
|
||||
}
|
||||
this.stripMaxThumbnails = 50;
|
||||
|
||||
// Cache for lora metadata to handle non-visible selected loras
|
||||
if (!state.loraMetadataCache) {
|
||||
state.loraMetadataCache = new Map();
|
||||
}
|
||||
|
||||
this.stripMaxThumbnails = 50; // Maximum thumbnails to show in strip
|
||||
// Model type specific action configurations
|
||||
this.actionConfig = {
|
||||
[MODEL_TYPES.LORA]: {
|
||||
sendToWorkflow: true,
|
||||
copyAll: true,
|
||||
refreshAll: true,
|
||||
moveAll: true,
|
||||
deleteAll: true
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: true,
|
||||
moveAll: true,
|
||||
deleteAll: true
|
||||
},
|
||||
[MODEL_TYPES.CHECKPOINT]: {
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: true,
|
||||
moveAll: false,
|
||||
deleteAll: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Add event listeners if needed
|
||||
// (Already handled via onclick attributes in HTML, but could be moved here)
|
||||
|
||||
// Add event listeners for the selected count to toggle thumbnail strip
|
||||
this.setupEventListeners();
|
||||
this.setupGlobalKeyboardListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Bulk operations button listeners
|
||||
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
|
||||
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
|
||||
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
|
||||
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
|
||||
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
|
||||
const clearBtn = this.bulkPanel?.querySelector('[data-action="clear"]');
|
||||
|
||||
if (sendToWorkflowBtn) {
|
||||
sendToWorkflowBtn.addEventListener('click', () => this.sendAllModelsToWorkflow());
|
||||
}
|
||||
if (copyAllBtn) {
|
||||
copyAllBtn.addEventListener('click', () => this.copyAllModelsSyntax());
|
||||
}
|
||||
if (refreshAllBtn) {
|
||||
refreshAllBtn.addEventListener('click', () => this.refreshAllMetadata());
|
||||
}
|
||||
if (moveAllBtn) {
|
||||
moveAllBtn.addEventListener('click', () => {
|
||||
moveManager.showMoveModal('bulk');
|
||||
});
|
||||
}
|
||||
if (deleteAllBtn) {
|
||||
deleteAllBtn.addEventListener('click', () => this.showBulkDeleteModal());
|
||||
}
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', () => this.clearSelection());
|
||||
}
|
||||
|
||||
// Selected count click listener
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
if (selectedCount) {
|
||||
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
|
||||
}
|
||||
}
|
||||
|
||||
// Add global keyboard event listener for Ctrl+A
|
||||
setupGlobalKeyboardListeners() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// First check if any modal is currently open - if so, don't handle Ctrl+A
|
||||
if (modalManager.isAnyModalOpen()) {
|
||||
return; // Exit early - let the browser handle Ctrl+A within the modal
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if search input is currently focused - if so, don't handle Ctrl+A
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput && document.activeElement === searchInput) {
|
||||
return; // Exit early - let the browser handle Ctrl+A within the search input
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's Ctrl+A (or Cmd+A on Mac)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
||||
|
||||
// Prevent default browser "Select All" behavior
|
||||
e.preventDefault();
|
||||
|
||||
// If not in bulk mode, enable it first
|
||||
if (!state.bulkMode) {
|
||||
this.toggleBulkMode();
|
||||
// Small delay to ensure DOM is updated
|
||||
setTimeout(() => this.selectAllVisibleLoras(), 50);
|
||||
setTimeout(() => this.selectAllVisibleModels(), 50);
|
||||
} else {
|
||||
this.selectAllVisibleLoras();
|
||||
this.selectAllVisibleModels();
|
||||
}
|
||||
} else if (e.key === 'Escape' && state.bulkMode) {
|
||||
// If in bulk mode, exit it on Escape
|
||||
this.toggleBulkMode();
|
||||
} else if (e.key.toLowerCase() === 'b') {
|
||||
// If 'b' is pressed, toggle bulk mode
|
||||
this.toggleBulkMode();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleBulkMode() {
|
||||
// Toggle the state
|
||||
state.bulkMode = !state.bulkMode;
|
||||
|
||||
// Update UI
|
||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||
|
||||
// Important: Remove the hidden class when entering bulk mode
|
||||
if (state.bulkMode) {
|
||||
this.bulkPanel.classList.remove('hidden');
|
||||
// Use setTimeout to ensure the DOM updates before adding visible class
|
||||
// This helps with the transition animation
|
||||
this.updateActionButtonsVisibility();
|
||||
setTimeout(() => {
|
||||
this.bulkPanel.classList.add('visible');
|
||||
}, 10);
|
||||
} else {
|
||||
this.bulkPanel.classList.remove('visible');
|
||||
// Add hidden class back after transition completes
|
||||
setTimeout(() => {
|
||||
this.bulkPanel.classList.add('hidden');
|
||||
}, 400); // Match this with the transition duration in CSS
|
||||
|
||||
// Hide thumbnail strip if it's visible
|
||||
}, 400);
|
||||
this.hideThumbnailStrip();
|
||||
}
|
||||
|
||||
// First update all cards' visual state before clearing selection
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
// Clear selection if exiting bulk mode - do this after updating cards
|
||||
if (!state.bulkMode) {
|
||||
this.clearSelection();
|
||||
|
||||
// TODO: fix this, no DOM manipulation should be done here
|
||||
// Force a lightweight refresh of the cards to ensure proper display
|
||||
// This is less disruptive than a full resetAndReload()
|
||||
// TODO:
|
||||
document.querySelectorAll('.model-card').forEach(card => {
|
||||
// Re-apply normal display mode to all card actions
|
||||
const actions = card.querySelectorAll('.card-actions, .card-button');
|
||||
actions.forEach(action => action.style.display = 'flex');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateActionButtonsVisibility() {
|
||||
const currentModelType = state.currentPageType;
|
||||
const config = this.actionConfig[currentModelType];
|
||||
|
||||
if (!config) return;
|
||||
|
||||
// Update button visibility based on model type
|
||||
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
|
||||
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
|
||||
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
|
||||
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
|
||||
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
|
||||
|
||||
if (sendToWorkflowBtn) {
|
||||
sendToWorkflowBtn.style.display = config.sendToWorkflow ? 'block' : 'none';
|
||||
}
|
||||
if (copyAllBtn) {
|
||||
copyAllBtn.style.display = config.copyAll ? 'block' : 'none';
|
||||
}
|
||||
if (refreshAllBtn) {
|
||||
refreshAllBtn.style.display = config.refreshAll ? 'block' : 'none';
|
||||
}
|
||||
if (moveAllBtn) {
|
||||
moveAllBtn.style.display = config.moveAll ? 'block' : 'none';
|
||||
}
|
||||
if (deleteAllBtn) {
|
||||
deleteAllBtn.style.display = config.deleteAll ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
document.querySelectorAll('.model-card.selected').forEach(card => {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
state.selectedLoras.clear();
|
||||
state.selectedModels.clear();
|
||||
this.updateSelectedCount();
|
||||
|
||||
// Hide thumbnail strip if it's visible
|
||||
this.hideThumbnailStrip();
|
||||
}
|
||||
|
||||
updateSelectedCount() {
|
||||
const countElement = document.getElementById('selectedCount');
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const displayName = currentConfig?.displayName || 'Models';
|
||||
|
||||
if (countElement) {
|
||||
// Set text content without the icon
|
||||
countElement.textContent = `${state.selectedLoras.size} selected `;
|
||||
countElement.textContent = `${state.selectedModels.size} ${displayName.toLowerCase()}(s) selected `;
|
||||
|
||||
// Update caret icon if it exists
|
||||
const existingCaret = countElement.querySelector('.dropdown-caret');
|
||||
if (existingCaret) {
|
||||
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||
existingCaret.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
||||
existingCaret.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
|
||||
} else {
|
||||
// Create new caret icon if it doesn't exist
|
||||
const caretIcon = document.createElement('i');
|
||||
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
||||
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
|
||||
caretIcon.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
|
||||
countElement.appendChild(caretIcon);
|
||||
}
|
||||
}
|
||||
@@ -149,16 +203,18 @@ export class BulkManager {
|
||||
|
||||
toggleCardSelection(card) {
|
||||
const filepath = card.dataset.filepath;
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
if (card.classList.contains('selected')) {
|
||||
card.classList.remove('selected');
|
||||
state.selectedLoras.delete(filepath);
|
||||
state.selectedModels.delete(filepath);
|
||||
} else {
|
||||
card.classList.add('selected');
|
||||
state.selectedLoras.add(filepath);
|
||||
state.selectedModels.add(filepath);
|
||||
|
||||
// Cache the metadata for this lora
|
||||
state.loraMetadataCache.set(filepath, {
|
||||
// Cache the metadata for this model
|
||||
const metadataCache = this.getMetadataCache();
|
||||
metadataCache.set(filepath, {
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
previewUrl: this.getCardPreviewUrl(card),
|
||||
@@ -169,35 +225,49 @@ export class BulkManager {
|
||||
|
||||
this.updateSelectedCount();
|
||||
|
||||
// Update thumbnail strip if it's visible
|
||||
if (this.isStripVisible) {
|
||||
this.updateThumbnailStrip();
|
||||
}
|
||||
}
|
||||
|
||||
getMetadataCache() {
|
||||
const currentType = state.currentPageType;
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
// Initialize metadata cache if it doesn't exist
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
if (!state.loraMetadataCache) {
|
||||
state.loraMetadataCache = new Map();
|
||||
}
|
||||
return state.loraMetadataCache;
|
||||
} else {
|
||||
if (!pageState.metadataCache) {
|
||||
pageState.metadataCache = new Map();
|
||||
}
|
||||
return pageState.metadataCache;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to get preview URL from a card
|
||||
getCardPreviewUrl(card) {
|
||||
const img = card.querySelector('img');
|
||||
const video = card.querySelector('video source');
|
||||
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
|
||||
}
|
||||
|
||||
// Helper method to check if preview is a video
|
||||
isCardPreviewVideo(card) {
|
||||
return card.querySelector('video') !== null;
|
||||
}
|
||||
|
||||
// Apply selection state to cards after they are refreshed
|
||||
applySelectionState() {
|
||||
if (!state.bulkMode) return;
|
||||
|
||||
document.querySelectorAll('.model-card').forEach(card => {
|
||||
const filepath = card.dataset.filepath;
|
||||
if (state.selectedLoras.has(filepath)) {
|
||||
if (state.selectedModels.has(filepath)) {
|
||||
card.classList.add('selected');
|
||||
|
||||
// Update the cache with latest data
|
||||
state.loraMetadataCache.set(filepath, {
|
||||
const metadataCache = this.getMetadataCache();
|
||||
metadataCache.set(filepath, {
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
previewUrl: this.getCardPreviewUrl(card),
|
||||
@@ -212,30 +282,33 @@ export class BulkManager {
|
||||
this.updateSelectedCount();
|
||||
}
|
||||
|
||||
async copyAllLorasSyntax() {
|
||||
if (state.selectedLoras.size === 0) {
|
||||
async copyAllModelsSyntax() {
|
||||
if (state.currentPageType !== MODEL_TYPES.LORA) {
|
||||
showToast('Copy syntax is only available for LoRAs', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const loraSyntaxes = [];
|
||||
const missingLoras = [];
|
||||
const metadataCache = this.getMetadataCache();
|
||||
|
||||
// Process all selected loras using our metadata cache
|
||||
for (const filepath of state.selectedLoras) {
|
||||
const metadata = state.loraMetadataCache.get(filepath);
|
||||
for (const filepath of state.selectedModels) {
|
||||
const metadata = metadataCache.get(filepath);
|
||||
|
||||
if (metadata) {
|
||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||
} else {
|
||||
// If we don't have metadata, this is an error case
|
||||
missingLoras.push(filepath);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any loras with missing metadata
|
||||
if (missingLoras.length > 0) {
|
||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||
@@ -249,31 +322,33 @@ export class BulkManager {
|
||||
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
|
||||
}
|
||||
|
||||
// Add method to send all selected loras to workflow
|
||||
async sendAllLorasToWorkflow() {
|
||||
if (state.selectedLoras.size === 0) {
|
||||
async sendAllModelsToWorkflow() {
|
||||
if (state.currentPageType !== MODEL_TYPES.LORA) {
|
||||
showToast('Send to workflow is only available for LoRAs', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const loraSyntaxes = [];
|
||||
const missingLoras = [];
|
||||
const metadataCache = this.getMetadataCache();
|
||||
|
||||
// Process all selected loras using our metadata cache
|
||||
for (const filepath of state.selectedLoras) {
|
||||
const metadata = state.loraMetadataCache.get(filepath);
|
||||
for (const filepath of state.selectedModels) {
|
||||
const metadata = metadataCache.get(filepath);
|
||||
|
||||
if (metadata) {
|
||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||
const strength = usageTips.strength || 1;
|
||||
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||
} else {
|
||||
// If we don't have metadata, this is an error case
|
||||
missingLoras.push(filepath);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle any loras with missing metadata
|
||||
if (missingLoras.length > 0) {
|
||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||
@@ -284,82 +359,48 @@ export class BulkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the loras to the workflow
|
||||
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
|
||||
}
|
||||
|
||||
// Show the bulk delete confirmation modal
|
||||
showBulkDeleteModal() {
|
||||
if (state.selectedLoras.size === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No models selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the count in the modal
|
||||
const countElement = document.getElementById('bulkDeleteCount');
|
||||
if (countElement) {
|
||||
countElement.textContent = state.selectedLoras.size;
|
||||
countElement.textContent = state.selectedModels.size;
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('bulkDeleteModal');
|
||||
}
|
||||
|
||||
// Confirm bulk delete action
|
||||
async confirmBulkDelete() {
|
||||
if (state.selectedLoras.size === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No models selected', 'warning');
|
||||
modalManager.closeModal('bulkDeleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the modal first before showing loading indicator
|
||||
modalManager.closeModal('bulkDeleteModal');
|
||||
|
||||
try {
|
||||
// Show loading indicator
|
||||
state.loadingManager.showSimpleLoading('Deleting models...');
|
||||
const apiClient = getModelApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
|
||||
// Gather all file paths for deletion
|
||||
const filePaths = Array.from(state.selectedLoras);
|
||||
|
||||
// Call the backend API
|
||||
const response = await fetch('/api/loras/bulk-delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_paths: filePaths
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
showToast(`Successfully deleted ${result.deleted_count} models`, 'success');
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
showToast(`Successfully deleted ${result.deleted_count} ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
|
||||
|
||||
// If virtual scroller exists, update the UI without page reload
|
||||
if (state.virtualScroller) {
|
||||
// Remove each deleted item from the virtual scroller
|
||||
filePaths.forEach(path => {
|
||||
state.virtualScroller.removeItemByFilePath(path);
|
||||
});
|
||||
|
||||
// Clear the selection
|
||||
this.clearSelection();
|
||||
} else {
|
||||
// Clear the selection
|
||||
this.clearSelection();
|
||||
|
||||
// Fall back to page reload for non-virtual scroll mode
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
}
|
||||
filePaths.forEach(path => {
|
||||
state.virtualScroller.removeItemByFilePath(path);
|
||||
});
|
||||
this.clearSelection();
|
||||
|
||||
if (window.modelDuplicatesManager) {
|
||||
// Update duplicates badge after refresh
|
||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||
}
|
||||
} else {
|
||||
@@ -368,16 +409,11 @@ export class BulkManager {
|
||||
} catch (error) {
|
||||
console.error('Error during bulk delete:', error);
|
||||
showToast('Failed to delete models', 'error');
|
||||
} finally {
|
||||
// Hide loading indicator
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Create and show the thumbnail strip of selected LoRAs
|
||||
toggleThumbnailStrip() {
|
||||
// If no items are selected, do nothing
|
||||
if (state.selectedLoras.size === 0) return;
|
||||
if (state.selectedModels.size === 0) return;
|
||||
|
||||
const existing = document.querySelector('.selected-thumbnails-strip');
|
||||
if (existing) {
|
||||
@@ -388,38 +424,30 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
showThumbnailStrip() {
|
||||
// Create the thumbnail strip container
|
||||
const strip = document.createElement('div');
|
||||
strip.className = 'selected-thumbnails-strip';
|
||||
|
||||
// Create a container for the thumbnails (for scrolling)
|
||||
const thumbnailContainer = document.createElement('div');
|
||||
thumbnailContainer.className = 'thumbnails-container';
|
||||
strip.appendChild(thumbnailContainer);
|
||||
|
||||
// Position the strip above the bulk operations panel
|
||||
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
|
||||
|
||||
// Populate the thumbnails
|
||||
this.updateThumbnailStrip();
|
||||
|
||||
// Update strip visibility state and caret direction
|
||||
this.isStripVisible = true;
|
||||
this.updateSelectedCount(); // Update caret
|
||||
this.updateSelectedCount();
|
||||
|
||||
// Add animation class after a short delay to trigger transition
|
||||
setTimeout(() => strip.classList.add('visible'), 10);
|
||||
}
|
||||
|
||||
hideThumbnailStrip() {
|
||||
const strip = document.querySelector('.selected-thumbnails-strip');
|
||||
if (strip && this.isStripVisible) { // Only hide if actually visible
|
||||
if (strip && this.isStripVisible) {
|
||||
strip.classList.remove('visible');
|
||||
|
||||
// Update strip visibility state
|
||||
this.isStripVisible = false;
|
||||
|
||||
// Update caret without triggering another hide
|
||||
const countElement = document.getElementById('selectedCount');
|
||||
if (countElement) {
|
||||
const caret = countElement.querySelector('.dropdown-caret');
|
||||
@@ -428,7 +456,6 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for animation to complete before removing
|
||||
setTimeout(() => {
|
||||
if (strip.parentNode) {
|
||||
strip.parentNode.removeChild(strip);
|
||||
@@ -441,33 +468,28 @@ export class BulkManager {
|
||||
const container = document.querySelector('.thumbnails-container');
|
||||
if (!container) return;
|
||||
|
||||
// Clear existing thumbnails
|
||||
container.innerHTML = '';
|
||||
|
||||
// Get all selected loras
|
||||
const selectedLoras = Array.from(state.selectedLoras);
|
||||
const selectedModels = Array.from(state.selectedModels);
|
||||
|
||||
// Create counter if we have more thumbnails than we'll show
|
||||
if (selectedLoras.length > this.stripMaxThumbnails) {
|
||||
if (selectedModels.length > this.stripMaxThumbnails) {
|
||||
const counter = document.createElement('div');
|
||||
counter.className = 'strip-counter';
|
||||
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedLoras.length} selected`;
|
||||
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedModels.length} selected`;
|
||||
container.appendChild(counter);
|
||||
}
|
||||
|
||||
// Limit the number of thumbnails to display
|
||||
const thumbnailsToShow = selectedLoras.slice(0, this.stripMaxThumbnails);
|
||||
const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails);
|
||||
const metadataCache = this.getMetadataCache();
|
||||
|
||||
// Add a thumbnail for each selected LoRA (limited to max)
|
||||
thumbnailsToShow.forEach(filepath => {
|
||||
const metadata = state.loraMetadataCache.get(filepath);
|
||||
const metadata = metadataCache.get(filepath);
|
||||
if (!metadata) return;
|
||||
|
||||
const thumbnail = document.createElement('div');
|
||||
thumbnail.className = 'selected-thumbnail';
|
||||
thumbnail.dataset.filepath = filepath;
|
||||
|
||||
// Create the visual element (image or video)
|
||||
if (metadata.isVideo) {
|
||||
thumbnail.innerHTML = `
|
||||
<video autoplay loop muted playsinline>
|
||||
@@ -484,14 +506,12 @@ export class BulkManager {
|
||||
`;
|
||||
}
|
||||
|
||||
// Add click handler for deselection
|
||||
thumbnail.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.thumbnail-remove')) {
|
||||
this.deselectItem(filepath);
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handler for the remove button
|
||||
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.deselectItem(filepath);
|
||||
@@ -502,43 +522,36 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
deselectItem(filepath) {
|
||||
// Find and deselect the corresponding card if it's in the DOM
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
if (card) {
|
||||
card.classList.remove('selected');
|
||||
}
|
||||
|
||||
// Remove from the selection set
|
||||
state.selectedLoras.delete(filepath);
|
||||
state.selectedModels.delete(filepath);
|
||||
|
||||
// Update UI
|
||||
this.updateSelectedCount();
|
||||
this.updateThumbnailStrip();
|
||||
|
||||
// Hide the strip if no more selections
|
||||
if (state.selectedLoras.size === 0) {
|
||||
if (state.selectedModels.size === 0) {
|
||||
this.hideThumbnailStrip();
|
||||
}
|
||||
}
|
||||
|
||||
// Add method to select all visible loras
|
||||
selectAllVisibleLoras() {
|
||||
// Only select loras already in the VirtualScroller's data model
|
||||
selectAllVisibleModels() {
|
||||
if (!state.virtualScroller || !state.virtualScroller.items) {
|
||||
showToast('Unable to select all items', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const oldCount = state.selectedLoras.size;
|
||||
const oldCount = state.selectedModels.size;
|
||||
const metadataCache = this.getMetadataCache();
|
||||
|
||||
// Add all loaded loras to the selection set
|
||||
state.virtualScroller.items.forEach(item => {
|
||||
if (item && item.file_path) {
|
||||
state.selectedLoras.add(item.file_path);
|
||||
state.selectedModels.add(item.file_path);
|
||||
|
||||
// Add to metadata cache if not already there
|
||||
if (!state.loraMetadataCache.has(item.file_path)) {
|
||||
state.loraMetadataCache.set(item.file_path, {
|
||||
if (!metadataCache.has(item.file_path)) {
|
||||
metadataCache.set(item.file_path, {
|
||||
fileName: item.file_name,
|
||||
usageTips: item.usage_tips || '{}',
|
||||
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
|
||||
@@ -549,45 +562,37 @@ export class BulkManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Update visual state
|
||||
this.applySelectionState();
|
||||
|
||||
// Show success message
|
||||
const newlySelected = state.selectedLoras.size - oldCount;
|
||||
showToast(`Selected ${newlySelected} additional LoRAs`, 'success');
|
||||
const newlySelected = state.selectedModels.size - oldCount;
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
showToast(`Selected ${newlySelected} additional ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
|
||||
|
||||
// Update thumbnail strip if visible
|
||||
if (this.isStripVisible) {
|
||||
this.updateThumbnailStrip();
|
||||
}
|
||||
}
|
||||
|
||||
// Add method to refresh metadata for all selected models
|
||||
async refreshAllMetadata() {
|
||||
if (state.selectedLoras.size === 0) {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('No models selected', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the API client for the current model type
|
||||
const apiClient = getModelApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
|
||||
// Convert Set to Array for processing
|
||||
const filePaths = Array.from(state.selectedLoras);
|
||||
|
||||
// Call the bulk refresh method
|
||||
const result = await apiClient.refreshBulkModelMetadata(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
// Update the metadata cache for successfully refreshed items
|
||||
for (const filepath of state.selectedLoras) {
|
||||
const metadata = state.loraMetadataCache.get(filepath);
|
||||
const metadataCache = this.getMetadataCache();
|
||||
for (const filepath of state.selectedModels) {
|
||||
const metadata = metadataCache.get(filepath);
|
||||
if (metadata) {
|
||||
// Find the corresponding card to get updated data
|
||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||
if (card) {
|
||||
state.loraMetadataCache.set(filepath, {
|
||||
metadataCache.set(filepath, {
|
||||
...metadata,
|
||||
fileName: card.dataset.file_name,
|
||||
usageTips: card.dataset.usage_tips,
|
||||
@@ -599,7 +604,6 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Update thumbnail strip if visible
|
||||
if (this.isStripVisible) {
|
||||
this.updateThumbnailStrip();
|
||||
}
|
||||
@@ -612,5 +616,4 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const bulkManager = new BulkManager();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { LoadingManager } from './LoadingManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/baseModelApi.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
export class DownloadManager {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
// ExampleImagesManager.js
|
||||
@@ -14,6 +15,12 @@ class ExampleImagesManager {
|
||||
this.isMigrating = false; // Track migration state separately from downloading
|
||||
this.hasShownCompletionToast = false; // Flag to track if completion toast has been shown
|
||||
|
||||
// Auto download properties
|
||||
this.autoDownloadInterval = null;
|
||||
this.lastAutoDownloadCheck = 0;
|
||||
this.autoDownloadCheckInterval = 10 * 60 * 1000; // 10 minutes in milliseconds
|
||||
this.pageInitTime = Date.now(); // Track when page was initialized
|
||||
|
||||
// Initialize download path field and check download status
|
||||
this.initializePathOptions();
|
||||
this.checkDownloadStatus();
|
||||
@@ -48,6 +55,14 @@ class ExampleImagesManager {
|
||||
if (collapseBtn) {
|
||||
collapseBtn.onclick = () => this.toggleProgressPanel();
|
||||
}
|
||||
|
||||
// Setup auto download if enabled
|
||||
if (state.global.settings.autoDownloadExampleImages) {
|
||||
this.setupAutoDownload();
|
||||
}
|
||||
|
||||
// Make this instance globally accessible
|
||||
window.exampleImagesManager = this;
|
||||
}
|
||||
|
||||
// Initialize event listeners for buttons
|
||||
@@ -133,6 +148,15 @@ class ExampleImagesManager {
|
||||
console.error('Failed to update example images path:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup or clear auto download based on path availability
|
||||
if (state.global.settings.autoDownloadExampleImages) {
|
||||
if (hasPath) {
|
||||
this.setupAutoDownload();
|
||||
} else {
|
||||
this.clearAutoDownload();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize path options:', error);
|
||||
@@ -646,6 +670,121 @@ class ExampleImagesManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupAutoDownload() {
|
||||
// Only setup if conditions are met
|
||||
if (!this.canAutoDownload()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing interval
|
||||
this.clearAutoDownload();
|
||||
|
||||
// Wait at least 30 seconds after page initialization before first check
|
||||
const timeSinceInit = Date.now() - this.pageInitTime;
|
||||
const initialDelay = Math.max(60000 - timeSinceInit, 5000); // At least 5 seconds, up to 60 seconds
|
||||
|
||||
console.log(`Setting up auto download with initial delay of ${initialDelay}ms`);
|
||||
|
||||
setTimeout(() => {
|
||||
// Do initial check
|
||||
this.performAutoDownloadCheck();
|
||||
|
||||
// Set up recurring interval
|
||||
this.autoDownloadInterval = setInterval(() => {
|
||||
this.performAutoDownloadCheck();
|
||||
}, this.autoDownloadCheckInterval);
|
||||
|
||||
}, initialDelay);
|
||||
}
|
||||
|
||||
clearAutoDownload() {
|
||||
if (this.autoDownloadInterval) {
|
||||
clearInterval(this.autoDownloadInterval);
|
||||
this.autoDownloadInterval = null;
|
||||
console.log('Auto download interval cleared');
|
||||
}
|
||||
}
|
||||
|
||||
canAutoDownload() {
|
||||
// Check if auto download is enabled
|
||||
if (!state.global.settings.autoDownloadExampleImages) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if download path is set
|
||||
const pathInput = document.getElementById('exampleImagesPath');
|
||||
if (!pathInput || !pathInput.value.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if already downloading
|
||||
if (this.isDownloading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async performAutoDownloadCheck() {
|
||||
const now = Date.now();
|
||||
|
||||
// Prevent too frequent checks (minimum 2 minutes between checks)
|
||||
if (now - this.lastAutoDownloadCheck < 2 * 60 * 1000) {
|
||||
console.log('Skipping auto download check - too soon since last check');
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastAutoDownloadCheck = now;
|
||||
|
||||
if (!this.canAutoDownload()) {
|
||||
console.log('Auto download conditions not met, skipping check');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Performing auto download check...');
|
||||
|
||||
const outputDir = document.getElementById('exampleImagesPath').value;
|
||||
const optimize = document.getElementById('optimizeExampleImages').checked;
|
||||
|
||||
const response = await fetch('/api/download-example-images', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
output_dir: outputDir,
|
||||
optimize: optimize,
|
||||
model_types: ['lora', 'checkpoint', 'embedding'],
|
||||
auto_mode: true // Flag to indicate this is an automatic download
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Only show progress if there are actually items to download
|
||||
if (data.status && data.status.total > 0) {
|
||||
this.isDownloading = true;
|
||||
this.isPaused = false;
|
||||
this.hasShownCompletionToast = false;
|
||||
this.startTime = new Date();
|
||||
this.updateUI(data.status);
|
||||
this.showProgressPanel();
|
||||
this.startProgressUpdates();
|
||||
this.updateDownloadButtonText();
|
||||
console.log(`Auto download started: ${data.status.total} items to process`);
|
||||
} else {
|
||||
console.log('Auto download check completed - no new items to download');
|
||||
}
|
||||
} else {
|
||||
console.warn('Auto download check failed:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto download check error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BASE_MODEL_CLASSES } from '../utils/constants.js';
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
|
||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||
|
||||
export class FilterManager {
|
||||
@@ -67,14 +66,7 @@ export class FilterManager {
|
||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||
|
||||
// Determine the API endpoint based on the page type
|
||||
let tagsEndpoint = '/api/loras/top-tags?limit=20';
|
||||
if (this.currentPage === 'recipes') {
|
||||
tagsEndpoint = '/api/recipes/top-tags?limit=20';
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
tagsEndpoint = '/api/checkpoints/top-tags?limit=20';
|
||||
} else if (this.currentPage === 'embeddings') {
|
||||
tagsEndpoint = '/api/embeddings/top-tags?limit=20';
|
||||
}
|
||||
const tagsEndpoint = `/api/${this.currentPage}/top-tags?limit=20`;
|
||||
|
||||
const response = await fetch(tagsEndpoint);
|
||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||
@@ -141,19 +133,8 @@ export class FilterManager {
|
||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||
if (!baseModelTagsContainer) return;
|
||||
|
||||
// Set the appropriate API endpoint based on current page
|
||||
let apiEndpoint = '';
|
||||
if (this.currentPage === 'loras') {
|
||||
apiEndpoint = '/api/loras/base-models';
|
||||
} else if (this.currentPage === 'recipes') {
|
||||
apiEndpoint = '/api/recipes/base-models';
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
apiEndpoint = '/api/checkpoints/base-models';
|
||||
} else if (this.currentPage === 'embeddings') {
|
||||
apiEndpoint = '/api/embeddings/base-models';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
// Set the API endpoint based on current page
|
||||
const apiEndpoint = `/api/${this.currentPage}/base-models`;
|
||||
|
||||
// Fetch base models
|
||||
fetch(apiEndpoint)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { LoadingManager } from './LoadingManager.js';
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { ImportStepManager } from './import/ImportStepManager.js';
|
||||
import { ImageProcessor } from './import/ImageProcessor.js';
|
||||
import { RecipeDataManager } from './import/RecipeDataManager.js';
|
||||
@@ -86,8 +84,8 @@ export class ImportManager {
|
||||
const uploadError = document.getElementById('uploadError');
|
||||
if (uploadError) uploadError.textContent = '';
|
||||
|
||||
const urlError = document.getElementById('urlError');
|
||||
if (urlError) urlError.textContent = '';
|
||||
const importUrlError = document.getElementById('importUrlError');
|
||||
if (importUrlError) importUrlError.textContent = '';
|
||||
|
||||
const recipeName = document.getElementById('recipeName');
|
||||
if (recipeName) recipeName.value = '';
|
||||
@@ -167,10 +165,10 @@ export class ImportManager {
|
||||
|
||||
// Clear error messages
|
||||
const uploadError = document.getElementById('uploadError');
|
||||
const urlError = document.getElementById('urlError');
|
||||
const importUrlError = document.getElementById('importUrlError');
|
||||
|
||||
if (uploadError) uploadError.textContent = '';
|
||||
if (urlError) urlError.textContent = '';
|
||||
if (importUrlError) importUrlError.textContent = '';
|
||||
}
|
||||
|
||||
handleImageUpload(event) {
|
||||
@@ -224,8 +222,8 @@ export class ImportManager {
|
||||
const uploadError = document.getElementById('uploadError');
|
||||
if (uploadError) uploadError.textContent = '';
|
||||
|
||||
const urlError = document.getElementById('urlError');
|
||||
if (urlError) urlError.textContent = '';
|
||||
const importUrlError = document.getElementById('importUrlError');
|
||||
if (importUrlError) importUrlError.textContent = '';
|
||||
}
|
||||
|
||||
backToDetails() {
|
||||
|
||||
@@ -1,107 +1,116 @@
|
||||
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { bulkManager } from './BulkManager.js';
|
||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
|
||||
class MoveManager {
|
||||
constructor() {
|
||||
this.currentFilePath = null;
|
||||
this.bulkFilePaths = null;
|
||||
this.modal = document.getElementById('moveModal');
|
||||
this.loraRootSelect = document.getElementById('moveLoraRoot');
|
||||
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();
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
// 初始化LoRA根目录选择器
|
||||
this.loraRootSelect.addEventListener('change', () => this.updatePathPreview());
|
||||
// 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();
|
||||
});
|
||||
|
||||
// 新文件夹输入事件
|
||||
// New folder input event
|
||||
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
|
||||
}
|
||||
|
||||
async showMoveModal(filePath) {
|
||||
async showMoveModal(filePath, modelType = null) {
|
||||
// Reset state
|
||||
this.currentFilePath = null;
|
||||
this.bulkFilePaths = null;
|
||||
|
||||
const apiClient = getModelApiClient();
|
||||
const currentPageType = state.currentPageType;
|
||||
const modelConfig = apiClient.apiConfig.config;
|
||||
|
||||
// Handle bulk mode
|
||||
if (filePath === 'bulk') {
|
||||
const selectedPaths = Array.from(state.selectedLoras);
|
||||
const selectedPaths = Array.from(state.selectedModels);
|
||||
if (selectedPaths.length === 0) {
|
||||
showToast('No LoRAs selected', 'warning');
|
||||
showToast('No models selected', 'warning');
|
||||
return;
|
||||
}
|
||||
this.bulkFilePaths = selectedPaths;
|
||||
this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`;
|
||||
this.modalTitle.textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`;
|
||||
} else {
|
||||
// Single file mode
|
||||
this.currentFilePath = filePath;
|
||||
this.modalTitle.textContent = "Move Model";
|
||||
this.modalTitle.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`;
|
||||
|
||||
// Clear previous selections
|
||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
this.newFolderInput.value = '';
|
||||
|
||||
try {
|
||||
// Fetch LoRA roots
|
||||
const rootsResponse = await fetch('/api/loras/roots');
|
||||
if (!rootsResponse.ok) {
|
||||
throw new Error('Failed to fetch LoRA roots');
|
||||
// Fetch model roots
|
||||
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();
|
||||
}
|
||||
|
||||
const rootsData = await rootsResponse.json();
|
||||
if (!rootsData.roots || rootsData.roots.length === 0) {
|
||||
throw new Error('No LoRA roots found');
|
||||
throw new Error(`No ${modelConfig.displayName.toLowerCase()} roots found`);
|
||||
}
|
||||
|
||||
// 填充LoRA根目录选择器
|
||||
this.loraRootSelect.innerHTML = rootsData.roots.map(root =>
|
||||
// Populate model root selector
|
||||
this.modelRootSelect.innerHTML = rootsData.roots.map(root =>
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
// Set default lora root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
||||
// Set default root if available
|
||||
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; // Remove 's' from plural
|
||||
const defaultRoot = getStorageItem('settings', {})[settingsKey];
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
this.loraRootSelect.value = defaultRoot;
|
||||
this.modelRootSelect.value = defaultRoot;
|
||||
}
|
||||
|
||||
// Fetch folders dynamically
|
||||
const foldersResponse = await fetch('/api/loras/folders');
|
||||
if (!foldersResponse.ok) {
|
||||
throw new Error('Failed to fetch folders');
|
||||
}
|
||||
|
||||
const foldersData = await foldersResponse.json();
|
||||
const foldersData = await apiClient.fetchModelFolders();
|
||||
|
||||
// Update folder browser with dynamic content
|
||||
this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
||||
@@ -112,13 +121,13 @@ class MoveManager {
|
||||
modalManager.showModal('moveModal');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching LoRA roots or folders:', error);
|
||||
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
updatePathPreview() {
|
||||
const selectedRoot = this.loraRootSelect.value;
|
||||
const selectedRoot = this.modelRootSelect.value;
|
||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
||||
const newFolder = this.newFolderInput.value.trim();
|
||||
|
||||
@@ -134,7 +143,7 @@ class MoveManager {
|
||||
}
|
||||
|
||||
async moveModel() {
|
||||
const selectedRoot = this.loraRootSelect.value;
|
||||
const selectedRoot = this.modelRootSelect.value;
|
||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
||||
const newFolder = this.newFolderInput.value.trim();
|
||||
|
||||
@@ -191,11 +200,8 @@ class MoveManager {
|
||||
|
||||
// Refresh folder tags after successful move
|
||||
try {
|
||||
const foldersResponse = await fetch('/api/loras/folders');
|
||||
if (foldersResponse.ok) {
|
||||
const foldersData = await foldersResponse.json();
|
||||
updateFolderTags(foldersData.folders);
|
||||
}
|
||||
const foldersData = await apiClient.fetchModelFolders();
|
||||
updateFolderTags(foldersData.folders);
|
||||
} catch (error) {
|
||||
console.error('Error refreshing folder tags:', error);
|
||||
}
|
||||
@@ -204,7 +210,7 @@ class MoveManager {
|
||||
|
||||
// If we were in bulk mode, exit it after successful move
|
||||
if (this.bulkFilePaths && state.bulkMode) {
|
||||
toggleBulkMode();
|
||||
bulkManager.toggleBulkMode();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { updatePanelPositions } from "../utils/uiHelpers.js";
|
||||
import { getCurrentPageState } from "../state/index.js";
|
||||
import { getModelApiClient } from "../api/baseModelApi.js";
|
||||
import { getModelApiClient } from "../api/modelApiFactory.js";
|
||||
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
|
||||
/**
|
||||
* SearchManager - Handles search functionality across different pages
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/baseModelApi.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';
|
||||
|
||||
@@ -15,29 +15,44 @@ export class SettingsManager {
|
||||
|
||||
// Ensure settings are loaded from localStorage
|
||||
this.loadSettingsFromStorage();
|
||||
|
||||
|
||||
// Sync settings to backend if needed
|
||||
this.syncSettingsToBackendIfNeeded();
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
loadSettingsFromStorage() {
|
||||
// Get saved settings from localStorage
|
||||
const savedSettings = getStorageItem('settings');
|
||||
|
||||
|
||||
// Migrate legacy default_loras_root to default_lora_root if present
|
||||
if (savedSettings && savedSettings.default_loras_root && !savedSettings.default_lora_root) {
|
||||
savedSettings.default_lora_root = savedSettings.default_loras_root;
|
||||
delete savedSettings.default_loras_root;
|
||||
setStorageItem('settings', savedSettings);
|
||||
}
|
||||
|
||||
// Apply saved settings to state if available
|
||||
if (savedSettings) {
|
||||
state.global.settings = { ...state.global.settings, ...savedSettings };
|
||||
}
|
||||
|
||||
|
||||
// Initialize default values for new settings if they don't exist
|
||||
if (state.global.settings.compactMode === undefined) {
|
||||
state.global.settings.compactMode = false;
|
||||
}
|
||||
|
||||
|
||||
// Set default for optimizeExampleImages if undefined
|
||||
if (state.global.settings.optimizeExampleImages === undefined) {
|
||||
state.global.settings.optimizeExampleImages = true;
|
||||
}
|
||||
|
||||
|
||||
// Set default for autoDownloadExampleImages if undefined
|
||||
if (state.global.settings.autoDownloadExampleImages === undefined) {
|
||||
state.global.settings.autoDownloadExampleImages = true;
|
||||
}
|
||||
|
||||
// Set default for cardInfoDisplay if undefined
|
||||
if (state.global.settings.cardInfoDisplay === undefined) {
|
||||
state.global.settings.cardInfoDisplay = 'always';
|
||||
@@ -72,6 +87,55 @@ export class SettingsManager {
|
||||
if (state.global.settings.default_embedding_root === undefined) {
|
||||
state.global.settings.default_embedding_root = '';
|
||||
}
|
||||
|
||||
// Set default for includeTriggerWords if undefined
|
||||
if (state.global.settings.includeTriggerWords === undefined) {
|
||||
state.global.settings.includeTriggerWords = false;
|
||||
}
|
||||
}
|
||||
|
||||
async syncSettingsToBackendIfNeeded() {
|
||||
// Get local settings from storage
|
||||
const localSettings = getStorageItem('settings') || {};
|
||||
|
||||
// Fields that need to be synced to backend
|
||||
const fieldsToSync = [
|
||||
'civitai_api_key',
|
||||
'default_lora_root',
|
||||
'default_checkpoint_root',
|
||||
'default_embedding_root',
|
||||
'base_model_path_mappings',
|
||||
'download_path_template'
|
||||
];
|
||||
|
||||
// Build payload for syncing
|
||||
const payload = {};
|
||||
|
||||
fieldsToSync.forEach(key => {
|
||||
if (localSettings[key] !== undefined) {
|
||||
if (key === 'base_model_path_mappings') {
|
||||
payload[key] = JSON.stringify(localSettings[key]);
|
||||
} else {
|
||||
payload[key] = localSettings[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Only send request if there is something to sync
|
||||
if (Object.keys(payload).length > 0) {
|
||||
try {
|
||||
await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
// Log success to console
|
||||
console.log('Settings synced to backend');
|
||||
} catch (e) {
|
||||
// Log error to console
|
||||
console.error('Failed to sync settings to backend:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
@@ -141,6 +205,12 @@ export class SettingsManager {
|
||||
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
|
||||
}
|
||||
|
||||
// Set auto download example images setting
|
||||
const autoDownloadExampleImagesCheckbox = document.getElementById('autoDownloadExampleImages');
|
||||
if (autoDownloadExampleImagesCheckbox) {
|
||||
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false;
|
||||
}
|
||||
|
||||
// Set download path template setting
|
||||
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate');
|
||||
if (downloadPathTemplateSelect) {
|
||||
@@ -148,6 +218,12 @@ export class SettingsManager {
|
||||
this.updatePathTemplatePreview();
|
||||
}
|
||||
|
||||
// Set include trigger words setting
|
||||
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
|
||||
if (includeTriggerWordsCheckbox) {
|
||||
includeTriggerWordsCheckbox.checked = state.global.settings.includeTriggerWords || false;
|
||||
}
|
||||
|
||||
// Load base model path mappings
|
||||
this.loadBaseModelMappings();
|
||||
|
||||
@@ -159,8 +235,6 @@ export class SettingsManager {
|
||||
|
||||
// Load default embedding root
|
||||
await this.loadEmbeddingRoots();
|
||||
|
||||
// Backend settings are loaded from the template directly
|
||||
}
|
||||
|
||||
async loadLoraRoots() {
|
||||
@@ -193,7 +267,7 @@ export class SettingsManager {
|
||||
});
|
||||
|
||||
// Set selected value from settings
|
||||
const defaultRoot = state.global.settings.default_loras_root || '';
|
||||
const defaultRoot = state.global.settings.default_lora_root || '';
|
||||
defaultLoraRootSelect.value = defaultRoot;
|
||||
|
||||
} catch (error) {
|
||||
@@ -495,8 +569,12 @@ export class SettingsManager {
|
||||
state.global.settings.autoplayOnHover = value;
|
||||
} else if (settingKey === 'optimize_example_images') {
|
||||
state.global.settings.optimizeExampleImages = value;
|
||||
} else if (settingKey === 'auto_download_example_images') {
|
||||
state.global.settings.autoDownloadExampleImages = value;
|
||||
} else if (settingKey === 'compact_mode') {
|
||||
state.global.settings.compactMode = value;
|
||||
} else if (settingKey === 'include_trigger_words') {
|
||||
state.global.settings.includeTriggerWords = value;
|
||||
} else {
|
||||
// For any other settings that might be added in the future
|
||||
state.global.settings[settingKey] = value;
|
||||
@@ -507,7 +585,7 @@ export class SettingsManager {
|
||||
|
||||
try {
|
||||
// For backend settings, make API call
|
||||
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover', 'optimize_example_images', 'use_centralized_examples'].includes(settingKey)) {
|
||||
if (['show_only_sfw'].includes(settingKey)) {
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
@@ -522,14 +600,23 @@ export class SettingsManager {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save setting');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
if (settingKey === 'show_only_sfw') {
|
||||
// Trigger auto download setup/teardown when setting changes
|
||||
if (settingKey === 'auto_download_example_images' && window.exampleImagesManager) {
|
||||
if (value) {
|
||||
window.exampleImagesManager.setupAutoDownload();
|
||||
} else {
|
||||
window.exampleImagesManager.clearAutoDownload();
|
||||
}
|
||||
}
|
||||
|
||||
if (settingKey === 'show_only_sfw' || settingKey === 'blur_mature_content') {
|
||||
this.reloadContent();
|
||||
}
|
||||
|
||||
@@ -552,7 +639,7 @@ export class SettingsManager {
|
||||
|
||||
// Update frontend state
|
||||
if (settingKey === 'default_lora_root') {
|
||||
state.global.settings.default_loras_root = value;
|
||||
state.global.settings.default_lora_root = value;
|
||||
} else if (settingKey === 'default_checkpoint_root') {
|
||||
state.global.settings.default_checkpoint_root = value;
|
||||
} else if (settingKey === 'default_embedding_root') {
|
||||
@@ -632,10 +719,7 @@ export class SettingsManager {
|
||||
// Update state
|
||||
state.global.settings[settingKey] = value;
|
||||
|
||||
// Save to localStorage if appropriate
|
||||
if (!settingKey.includes('api_key')) { // Don't store API keys in localStorage for security
|
||||
setStorageItem('settings', state.global.settings);
|
||||
}
|
||||
setStorageItem('settings', state.global.settings);
|
||||
|
||||
// For backend settings, make API call
|
||||
const payload = {};
|
||||
@@ -714,83 +798,13 @@ export class SettingsManager {
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
// Reload the checkpoints without updating folders
|
||||
await resetAndReload(false);
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
// Get frontend settings from UI
|
||||
const blurMatureContent = document.getElementById('blurMatureContent').checked;
|
||||
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
||||
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
||||
const defaultCheckpointRoot = document.getElementById('defaultCheckpointRoot').value;
|
||||
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
|
||||
const optimizeExampleImages = document.getElementById('optimizeExampleImages').checked;
|
||||
|
||||
// Get backend settings
|
||||
const apiKey = document.getElementById('civitaiApiKey').value;
|
||||
|
||||
// Update frontend state and save to localStorage
|
||||
state.global.settings.blurMatureContent = blurMatureContent;
|
||||
state.global.settings.show_only_sfw = showOnlySFW;
|
||||
state.global.settings.default_loras_root = defaultLoraRoot;
|
||||
state.global.settings.default_checkpoint_root = defaultCheckpointRoot;
|
||||
state.global.settings.autoplayOnHover = autoplayOnHover;
|
||||
state.global.settings.optimizeExampleImages = optimizeExampleImages;
|
||||
|
||||
// Save settings to localStorage
|
||||
setStorageItem('settings', state.global.settings);
|
||||
|
||||
try {
|
||||
// Save backend settings via API
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
civitai_api_key: apiKey,
|
||||
show_only_sfw: showOnlySFW,
|
||||
optimize_example_images: optimizeExampleImages,
|
||||
default_checkpoint_root: defaultCheckpointRoot
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save settings');
|
||||
}
|
||||
|
||||
showToast('Settings saved successfully', 'success');
|
||||
modalManager.closeModal('settingsModal');
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
if (this.currentPage === 'loras') {
|
||||
// Reload the loras without updating folders
|
||||
await resetAndReload(false);
|
||||
} else if (this.currentPage === 'recipes') {
|
||||
// Reload the recipes without updating folders
|
||||
await window.recipeManager.loadRecipes();
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
// Reload the checkpoints without updating folders
|
||||
await window.checkpointsManager.loadCheckpoints();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to save settings: ' + error.message, 'error');
|
||||
} else if (this.currentPage === 'embeddings') {
|
||||
// Reload the embeddings without updating folders
|
||||
await resetAndReload(false);
|
||||
}
|
||||
}
|
||||
|
||||
applyFrontendSettings() {
|
||||
// Apply blur setting to existing content
|
||||
const blurSetting = state.global.settings.blurMatureContent;
|
||||
document.querySelectorAll('.model-card[data-nsfw="true"] .card-image').forEach(img => {
|
||||
if (blurSetting) {
|
||||
img.classList.add('nsfw-blur');
|
||||
} else {
|
||||
img.classList.remove('nsfw-blur');
|
||||
}
|
||||
});
|
||||
|
||||
// Apply autoplay setting to existing videos in card previews
|
||||
const autoplayOnHover = state.global.settings.autoplayOnHover;
|
||||
document.querySelectorAll('.card-preview video').forEach(video => {
|
||||
|
||||
@@ -112,7 +112,7 @@ export class FolderBrowser {
|
||||
).join('');
|
||||
|
||||
// Set default lora root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
||||
const defaultRoot = getStorageItem('settings', {}).default_lora_root;
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
loraRoot.value = defaultRoot;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ImageProcessor {
|
||||
|
||||
async handleUrlInput() {
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
const errorElement = document.getElementById('urlError');
|
||||
const errorElement = document.getElementById('importUrlError');
|
||||
const input = urlInput.value.trim();
|
||||
|
||||
// Validate input
|
||||
|
||||
@@ -89,6 +89,10 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: []
|
||||
},
|
||||
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
metadataCache: new Map(),
|
||||
showFavoritesOnly: false,
|
||||
duplicatesMode: false,
|
||||
},
|
||||
@@ -112,6 +116,9 @@ export const state = {
|
||||
baseModel: [],
|
||||
tags: []
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
metadataCache: new Map(),
|
||||
showFavoritesOnly: false,
|
||||
duplicatesMode: false,
|
||||
}
|
||||
@@ -154,12 +161,43 @@ export const state = {
|
||||
get filters() { return this.pages[this.currentPageType].filters; },
|
||||
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
||||
|
||||
get bulkMode() { return this.pages.loras.bulkMode; },
|
||||
set bulkMode(value) { this.pages.loras.bulkMode = value; },
|
||||
get bulkMode() {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
return this.pages.loras.bulkMode;
|
||||
} else {
|
||||
return this.pages[currentType].bulkMode;
|
||||
}
|
||||
},
|
||||
set bulkMode(value) {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
this.pages.loras.bulkMode = value;
|
||||
} else {
|
||||
this.pages[currentType].bulkMode = value;
|
||||
}
|
||||
},
|
||||
|
||||
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
||||
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
||||
|
||||
get selectedModels() {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
return this.pages.loras.selectedLoras;
|
||||
} else {
|
||||
return this.pages[currentType].selectedModels;
|
||||
}
|
||||
},
|
||||
set selectedModels(value) {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
this.pages.loras.selectedLoras = value;
|
||||
} else {
|
||||
this.pages[currentType].selectedModels = value;
|
||||
}
|
||||
},
|
||||
|
||||
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
||||
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { VirtualScroller } from './VirtualScroller.js';
|
||||
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
|
||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { showToast } from './uiHelpers.js';
|
||||
|
||||
// Function to dynamically import the appropriate card creator based on page type
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
|
||||
const apiClient = getModelApiClient();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
||||
|
||||
@@ -285,6 +285,76 @@ export function getNSFWLevelName(level) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
export function copyLoraSyntax(card) {
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || "{}");
|
||||
const strength = usageTips.strength || 1;
|
||||
const baseSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||
|
||||
// Check if trigger words should be included
|
||||
const includeTriggerWords = state.global.settings.includeTriggerWords;
|
||||
|
||||
if (!includeTriggerWords) {
|
||||
copyToClipboard(baseSyntax, "LoRA syntax copied to clipboard");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get trigger words from metadata
|
||||
const meta = card.dataset.meta ? JSON.parse(card.dataset.meta) : null;
|
||||
const trainedWords = meta?.trainedWords;
|
||||
|
||||
if (
|
||||
!trainedWords ||
|
||||
!Array.isArray(trainedWords) ||
|
||||
trainedWords.length === 0
|
||||
) {
|
||||
copyToClipboard(
|
||||
baseSyntax,
|
||||
"LoRA syntax copied to clipboard (no trigger words found)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let finalSyntax = baseSyntax;
|
||||
|
||||
if (trainedWords.length === 1) {
|
||||
// Single group: append trigger words to the same line
|
||||
const triggers = trainedWords[0]
|
||||
.split(",")
|
||||
.map((word) => word.trim())
|
||||
.filter((word) => word);
|
||||
if (triggers.length > 0) {
|
||||
finalSyntax = `${baseSyntax}, ${triggers.join(", ")}`;
|
||||
}
|
||||
copyToClipboard(
|
||||
finalSyntax,
|
||||
"LoRA syntax with trigger words copied to clipboard"
|
||||
);
|
||||
} else {
|
||||
// Multiple groups: format with separators
|
||||
const groups = trainedWords
|
||||
.map((group) => {
|
||||
const triggers = group
|
||||
.split(",")
|
||||
.map((word) => word.trim())
|
||||
.filter((word) => word);
|
||||
return triggers.join(", ");
|
||||
})
|
||||
.filter((group) => group);
|
||||
|
||||
if (groups.length > 0) {
|
||||
// Use separator between all groups except the first
|
||||
finalSyntax = baseSyntax + ", " + groups[0];
|
||||
for (let i = 1; i < groups.length; i++) {
|
||||
finalSyntax += `\n${"-".repeat(17)}\n${groups[i]}`;
|
||||
}
|
||||
}
|
||||
copyToClipboard(
|
||||
finalSyntax,
|
||||
"LoRA syntax with trigger word groups copied to clipboard"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends LoRA syntax to the active ComfyUI workflow
|
||||
* @param {string} loraSyntax - The LoRA syntax to send
|
||||
|
||||
@@ -50,14 +50,11 @@
|
||||
<i class="fas fa-cloud-download-alt"></i> Download
|
||||
</button>
|
||||
</div>
|
||||
<!-- Conditional buttons based on page -->
|
||||
{% if request.path == '/loras' %}
|
||||
<div class="control-group">
|
||||
<button id="bulkOperationsBtn" data-action="bulk" title="Bulk Operations (Press B)">
|
||||
<i class="fas fa-th-large"></i> <span>Bulk <div class="shortcut-key">B</div></span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="control-group">
|
||||
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
|
||||
<i class="fas fa-clone"></i> Duplicates
|
||||
@@ -119,22 +116,22 @@
|
||||
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
|
||||
</span>
|
||||
<div class="bulk-operations-actions">
|
||||
<button onclick="bulkManager.sendAllLorasToWorkflow()" title="Send all selected LoRAs to workflow">
|
||||
<button data-action="send-to-workflow" title="Send all selected LoRAs to workflow">
|
||||
<i class="fas fa-arrow-right"></i> Send to Workflow
|
||||
</button>
|
||||
<button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax">
|
||||
<button data-action="copy-all" title="Copy all selected LoRAs syntax">
|
||||
<i class="fas fa-copy"></i> Copy All
|
||||
</button>
|
||||
<button onclick="bulkManager.refreshAllMetadata()" title="Refresh CivitAI metadata for selected models">
|
||||
<button data-action="refresh-all" title="Refresh CivitAI metadata for selected models">
|
||||
<i class="fas fa-sync-alt"></i> Refresh All
|
||||
</button>
|
||||
<button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
|
||||
<button data-action="move-all" title="Move selected models to folder">
|
||||
<i class="fas fa-folder-open"></i> Move All
|
||||
</button>
|
||||
<button onclick="bulkManager.showBulkDeleteModal()" title="Delete selected LoRAs" class="danger-btn">
|
||||
<button data-action="delete-all" title="Delete selected models" class="danger-btn">
|
||||
<i class="fas fa-trash"></i> Delete All
|
||||
</button>
|
||||
<button onclick="bulkManager.clearSelection()" title="Clear selection">
|
||||
<button data-action="clear" title="Clear selection">
|
||||
<i class="fas fa-times"></i> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<i class="fas fa-download"></i> Fetch Image
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-message" id="urlError"></div>
|
||||
<div class="error-message" id="importUrlError"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
<div class="path-preview">
|
||||
<label>Target Location Preview:</label>
|
||||
<div class="path-display" id="moveTargetPathDisplay">
|
||||
<span class="path-text">Select a LoRA root directory</span>
|
||||
<span class="path-text">Select a model root directory</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label>Select LoRA Root:</label>
|
||||
<select id="moveLoraRoot"></select>
|
||||
<label id="moveRootLabel">Select Model Root:</label>
|
||||
<select id="moveModelRoot"></select>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Target Folder:</label>
|
||||
|
||||
@@ -272,6 +272,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="autoDownloadExampleImages">Auto Download Example Images</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="autoDownloadExampleImages" checked
|
||||
onchange="settingsManager.saveToggleSetting('autoDownloadExampleImages', 'auto_download_example_images')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Automatically download example images for models that don't have them (requires download location to be set)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
@@ -290,6 +308,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Misc. Section -->
|
||||
<div class="settings-section">
|
||||
<h3>Misc.</h3>
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="includeTriggerWords">Include Trigger Words in LoRA Syntax</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="includeTriggerWords"
|
||||
onchange="settingsManager.saveToggleSetting('includeTriggerWords', 'include_trigger_words')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Include trained trigger words when copying LoRA syntax to clipboard
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,6 +2,52 @@
|
||||
|
||||
---
|
||||
|
||||
### v0.8.16
|
||||
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
|
||||
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart
|
||||
* **Customizable Display Density** - Replaced compact mode with adjustable display density settings for personalized layout customization
|
||||
* **Model Creator Information** - Added creator details to model information panels for better attribution
|
||||
* **Improved WebP Support** - Enhanced Save Image node with workflow embedding capability for WebP format images
|
||||
* **Direct Example Access** - Added "Open Example Images Folder" button to card interfaces for convenient browsing of downloaded model examples
|
||||
* **Enhanced Compatibility** - Full ComfyUI Desktop support for "Send lora or recipe to workflow" functionality
|
||||
* **Cache Management** - Added settings to clear existing cache files when needed
|
||||
* **Bug Fixes & Stability** - Various improvements for overall reliability and performance
|
||||
|
||||
### v0.8.15
|
||||
* **Enhanced One-Click Integration** - Replaced copy button with direct send button allowing LoRAs/recipes to be sent directly to your current ComfyUI workflow without needing to paste
|
||||
* **Flexible Workflow Integration** - Click to append LoRAs/recipes to existing loader nodes or Shift+click to replace content, with additional right-click menu options for "Send to Workflow (Append)" or "Send to Workflow (Replace)"
|
||||
* **Improved LoRA Loader Controls** - Added header drag functionality for proportional strength adjustment of all LoRAs simultaneously (including CLIP strengths when expanded)
|
||||
* **Keyboard Navigation Support** - Implemented Page Up/Down for page scrolling, Home key to jump to top, and End key to jump to bottom for faster browsing through large collections
|
||||
|
||||
### v0.8.14
|
||||
* **Virtualized Scrolling** - Completely rebuilt rendering mechanism for smooth browsing with no lag or freezing, now supporting virtually unlimited model collections with optimized layouts for large displays, improving space utilization and user experience
|
||||
* **Compact Display Mode** - Added space-efficient view option that displays more cards per row (7 on 1080p, 8 on 2K, 10 on 4K)
|
||||
* **Enhanced LoRA Node Functionality** - Comprehensive improvements to LoRA loader/stacker nodes including real-time trigger word updates (reflecting any change anywhere in the LoRA chain for precise updates) and expanded context menu with "Copy Notes" and "Copy Trigger Words" options for faster workflow
|
||||
|
||||
### v0.8.13
|
||||
* **Enhanced Recipe Management** - Added "Find duplicates" feature to identify and batch delete duplicate recipes with duplicate detection notifications during imports
|
||||
* **Improved Source Tracking** - Source URLs are now saved with recipes imported via URL, allowing users to view original content with one click or manually edit links
|
||||
* **Advanced LoRA Control** - Double-click LoRAs in Loader/Stacker nodes to access expanded CLIP strength controls for more precise adjustments of model and CLIP strength separately
|
||||
* **Lycoris Model Support** - Added compatibility with Lycoris models for expanded creative options
|
||||
* **Bug Fixes & UX Improvements** - Resolved various issues and enhanced overall user experience with numerous optimizations
|
||||
|
||||
### v0.8.12
|
||||
* **Enhanced Model Discovery** - Added alphabetical navigation bar to LoRAs page for faster browsing through large collections
|
||||
* **Optimized Example Images** - Improved download logic to automatically refresh stale metadata before fetching example images
|
||||
* **Model Exclusion System** - New right-click option to exclude specific LoRAs or checkpoints from management
|
||||
* **Improved Showcase Experience** - Enhanced interaction in LoRA and checkpoint showcase areas for better usability
|
||||
|
||||
### v0.8.11
|
||||
* **Offline Image Support** - Added functionality to download and save all model example images locally, ensuring access even when offline or if images are removed from CivitAI or the site is down
|
||||
* **Resilient Download System** - Implemented pause/resume capability with checkpoint recovery that persists through restarts or unexpected exits
|
||||
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
|
||||
|
||||
### v0.8.10
|
||||
* **Standalone Mode** - Run LoRA Manager independently from ComfyUI for a lightweight experience that works even with other stable diffusion interfaces
|
||||
* **Portable Edition** - New one-click portable version for easy startup and updates in standalone mode
|
||||
* **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module
|
||||
* **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections
|
||||
|
||||
### v0.8.9
|
||||
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
|
||||
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
|
||||
|
||||
@@ -4,39 +4,11 @@ import {
|
||||
LORA_PATTERN,
|
||||
collectActiveLorasFromChain,
|
||||
updateConnectedTriggerWords,
|
||||
chainCallback
|
||||
chainCallback,
|
||||
mergeLoras
|
||||
} from "./utils.js";
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
|
||||
function mergeLoras(lorasText, lorasArr) {
|
||||
const result = [];
|
||||
let match;
|
||||
|
||||
// Reset pattern index before using
|
||||
LORA_PATTERN.lastIndex = 0;
|
||||
|
||||
// Parse text input and create initial entries
|
||||
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
|
||||
const name = match[1];
|
||||
const modelStrength = Number(match[2]);
|
||||
// Extract clip strength if provided, otherwise use model strength
|
||||
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
|
||||
|
||||
// Find if this lora exists in the array data
|
||||
const existingLora = lorasArr.find(l => l.name === name);
|
||||
|
||||
result.push({
|
||||
name: name,
|
||||
// Use existing strength if available, otherwise use input strength
|
||||
strength: existingLora ? existingLora.strength : modelStrength,
|
||||
active: existingLora ? existingLora.active : true,
|
||||
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraLoader",
|
||||
|
||||
|
||||
@@ -4,39 +4,11 @@ import {
|
||||
getActiveLorasFromNode,
|
||||
collectActiveLorasFromChain,
|
||||
updateConnectedTriggerWords,
|
||||
chainCallback
|
||||
chainCallback,
|
||||
mergeLoras
|
||||
} from "./utils.js";
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
|
||||
function mergeLoras(lorasText, lorasArr) {
|
||||
const result = [];
|
||||
let match;
|
||||
|
||||
// Reset pattern index before using
|
||||
LORA_PATTERN.lastIndex = 0;
|
||||
|
||||
// Parse text input and create initial entries
|
||||
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
|
||||
const name = match[1];
|
||||
const modelStrength = Number(match[2]);
|
||||
// Extract clip strength if provided, otherwise use model strength
|
||||
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
|
||||
|
||||
// Find if this lora exists in the array data
|
||||
const existingLora = lorasArr.find(l => l.name === name);
|
||||
|
||||
result.push({
|
||||
name: name,
|
||||
// Use existing strength if available, otherwise use input strength
|
||||
strength: existingLora ? existingLora.strength : modelStrength,
|
||||
active: existingLora ? existingLora.active : true,
|
||||
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraStacker",
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createToggle, createArrowButton, PreviewTooltip } from "./loras_widget_components.js";
|
||||
import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection } from "./loras_widget_components.js";
|
||||
import {
|
||||
parseLoraValue,
|
||||
formatLoraValue,
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
CONTAINER_PADDING,
|
||||
EMPTY_CONTAINER_HEIGHT
|
||||
} from "./loras_widget_utils.js";
|
||||
import { initDrag, createContextMenu, initHeaderDrag } from "./loras_widget_events.js";
|
||||
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
|
||||
|
||||
export function addLorasWidget(node, name, opts, callback) {
|
||||
// Create container for loras
|
||||
@@ -42,6 +41,30 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
// Create preview tooltip instance
|
||||
const previewTooltip = new PreviewTooltip();
|
||||
|
||||
// Selection state - only one LoRA can be selected at a time
|
||||
let selectedLora = null;
|
||||
|
||||
// Function to select a LoRA
|
||||
const selectLora = (loraName) => {
|
||||
selectedLora = loraName;
|
||||
// Update visual feedback for all entries
|
||||
container.querySelectorAll('.comfy-lora-entry').forEach(entry => {
|
||||
const entryLoraName = entry.dataset.loraName;
|
||||
updateEntrySelection(entry, entryLoraName === selectedLora);
|
||||
});
|
||||
};
|
||||
|
||||
// Add keyboard event listener to container
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (handleKeyboardNavigation(e, selectedLora, widget, renderLoras, selectLora)) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
// Make container focusable for keyboard events
|
||||
container.tabIndex = 0;
|
||||
container.style.outline = 'none';
|
||||
|
||||
// Function to render loras from data
|
||||
const renderLoras = (value, widget) => {
|
||||
// Clear existing content
|
||||
@@ -185,6 +208,26 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
marginBottom: "4px",
|
||||
});
|
||||
|
||||
// Store lora name and active state in dataset for selection
|
||||
loraEl.dataset.loraName = name;
|
||||
loraEl.dataset.active = active;
|
||||
|
||||
// Add click handler for selection
|
||||
loraEl.addEventListener('click', (e) => {
|
||||
// Skip if clicking on interactive elements
|
||||
if (e.target.closest('.comfy-lora-toggle') ||
|
||||
e.target.closest('input') ||
|
||||
e.target.closest('.comfy-lora-arrow') ||
|
||||
e.target.closest('.comfy-lora-drag-handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectLora(name);
|
||||
container.focus(); // Focus container for keyboard events
|
||||
});
|
||||
|
||||
// Add double-click handler to toggle clip entry
|
||||
loraEl.addEventListener('dblclick', (e) => {
|
||||
// Skip if clicking on toggle or strength control areas
|
||||
@@ -220,6 +263,12 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
}
|
||||
});
|
||||
|
||||
// Create drag handle for reordering
|
||||
const dragHandle = createDragHandle();
|
||||
|
||||
// Initialize reorder drag functionality
|
||||
initReorderDrag(dragHandle, name, widget, renderLoras);
|
||||
|
||||
// Create toggle for this lora
|
||||
const toggle = createToggle(active, (newActive) => {
|
||||
// Update this lora's active state
|
||||
@@ -416,6 +465,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
minWidth: "0", // Allow shrinking
|
||||
});
|
||||
|
||||
leftSection.appendChild(dragHandle); // Add drag handle first
|
||||
leftSection.appendChild(toggle);
|
||||
leftSection.appendChild(nameEl);
|
||||
|
||||
@@ -424,6 +474,9 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
|
||||
container.appendChild(loraEl);
|
||||
|
||||
// Update selection state
|
||||
updateEntrySelection(loraEl, name === selectedLora);
|
||||
|
||||
// If expanded, show the clip entry
|
||||
if (isExpanded) {
|
||||
totalVisibleEntries++;
|
||||
@@ -444,6 +497,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
marginTop: "-2px"
|
||||
});
|
||||
|
||||
// Store the same lora name in clip entry dataset
|
||||
clipEl.dataset.loraName = name;
|
||||
clipEl.dataset.active = active;
|
||||
|
||||
// Create clip name display
|
||||
const clipNameEl = document.createElement("div");
|
||||
clipNameEl.textContent = "[clip] " + name;
|
||||
@@ -601,7 +658,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
});
|
||||
|
||||
// Calculate height based on number of loras and fixed sizes
|
||||
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 10) * LORA_ENTRY_HEIGHT);
|
||||
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 12) * LORA_ENTRY_HEIGHT);
|
||||
updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
|
||||
};
|
||||
|
||||
@@ -685,6 +742,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
widget.onRemove = () => {
|
||||
container.remove();
|
||||
previewTooltip.cleanup();
|
||||
// Remove keyboard event listener
|
||||
container.removeEventListener('keydown', handleKeyboardNavigation);
|
||||
};
|
||||
|
||||
return { minWidth: 400, minHeight: defaultHeight, widget };
|
||||
|
||||
@@ -78,6 +78,87 @@ export function createArrowButton(direction, onClick) {
|
||||
return button;
|
||||
}
|
||||
|
||||
// Function to create drag handle
|
||||
export function createDragHandle() {
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "comfy-lora-drag-handle";
|
||||
handle.innerHTML = "≡";
|
||||
handle.title = "Drag to reorder LoRA";
|
||||
|
||||
Object.assign(handle.style, {
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
fontSize: "14px",
|
||||
color: "rgba(226, 232, 240, 0.6)",
|
||||
transition: "all 0.2s ease",
|
||||
marginRight: "8px",
|
||||
flexShrink: "0"
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
handle.onmouseenter = () => {
|
||||
handle.style.color = "rgba(226, 232, 240, 0.9)";
|
||||
handle.style.transform = "scale(1.1)";
|
||||
};
|
||||
|
||||
handle.onmouseleave = () => {
|
||||
handle.style.color = "rgba(226, 232, 240, 0.6)";
|
||||
handle.style.transform = "scale(1)";
|
||||
};
|
||||
|
||||
// Change cursor when dragging
|
||||
handle.onmousedown = () => {
|
||||
handle.style.cursor = "grabbing";
|
||||
};
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
// Function to create drop indicator
|
||||
export function createDropIndicator() {
|
||||
const indicator = document.createElement("div");
|
||||
indicator.className = "comfy-lora-drop-indicator";
|
||||
|
||||
Object.assign(indicator.style, {
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
right: "0",
|
||||
height: "3px",
|
||||
backgroundColor: "rgba(66, 153, 225, 0.9)",
|
||||
borderRadius: "2px",
|
||||
opacity: "0",
|
||||
transition: "opacity 0.2s ease",
|
||||
boxShadow: "0 0 6px rgba(66, 153, 225, 0.8)",
|
||||
zIndex: "10",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
|
||||
return indicator;
|
||||
}
|
||||
|
||||
// Function to update entry selection state
|
||||
export function updateEntrySelection(entryEl, isSelected) {
|
||||
const baseColor = entryEl.dataset.active === 'true' ?
|
||||
"rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)";
|
||||
const selectedColor = entryEl.dataset.active === 'true' ?
|
||||
"rgba(66, 153, 225, 0.3)" : "rgba(66, 153, 225, 0.2)";
|
||||
|
||||
if (isSelected) {
|
||||
entryEl.style.backgroundColor = selectedColor;
|
||||
entryEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
|
||||
entryEl.style.boxShadow = "0 0 0 1px rgba(66, 153, 225, 0.3)";
|
||||
} else {
|
||||
entryEl.style.backgroundColor = baseColor;
|
||||
entryEl.style.border = "1px solid transparent";
|
||||
entryEl.style.boxShadow = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Function to create menu item
|
||||
export function createMenuItem(text, icon, onClick) {
|
||||
const menuItem = document.createElement('div');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { createMenuItem } from "./loras_widget_components.js";
|
||||
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast } from "./loras_widget_utils.js";
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createMenuItem, createDropIndicator } from "./loras_widget_components.js";
|
||||
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast, moveLoraByDirection, getDropTargetIndex } from "./loras_widget_utils.js";
|
||||
|
||||
// Function to handle strength adjustment via dragging
|
||||
export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false) {
|
||||
@@ -227,6 +228,223 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
|
||||
});
|
||||
}
|
||||
|
||||
// Function to initialize drag-and-drop for reordering
|
||||
export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
|
||||
let isDragging = false;
|
||||
let draggedElement = null;
|
||||
let dropIndicator = null;
|
||||
let container = null;
|
||||
let scale = 1;
|
||||
|
||||
dragHandle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
isDragging = true;
|
||||
draggedElement = dragHandle.closest('.comfy-lora-entry');
|
||||
container = draggedElement.parentElement;
|
||||
|
||||
// Add dragging class and visual feedback
|
||||
draggedElement.classList.add('comfy-lora-dragging');
|
||||
draggedElement.style.opacity = '0.5';
|
||||
draggedElement.style.transform = 'scale(0.98)';
|
||||
|
||||
// Create single drop indicator with absolute positioning
|
||||
dropIndicator = createDropIndicator();
|
||||
|
||||
// Make container relatively positioned for absolute indicator
|
||||
const originalPosition = container.style.position;
|
||||
container.style.position = 'relative';
|
||||
container.appendChild(dropIndicator);
|
||||
|
||||
// Store original position for cleanup
|
||||
container._originalPosition = originalPosition;
|
||||
|
||||
// Add global cursor style
|
||||
document.body.style.cursor = 'grabbing';
|
||||
|
||||
// Store workflow scale for accurate positioning
|
||||
scale = app.canvas.ds.scale;
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging || !draggedElement || !dropIndicator) return;
|
||||
|
||||
const targetIndex = getDropTargetIndex(container, e.clientY);
|
||||
const entries = container.querySelectorAll('.comfy-lora-entry, .comfy-lora-clip-entry');
|
||||
|
||||
if (targetIndex === 0) {
|
||||
// Show at top
|
||||
const firstEntry = entries[0];
|
||||
if (firstEntry) {
|
||||
const rect = firstEntry.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
|
||||
dropIndicator.style.opacity = '1';
|
||||
}
|
||||
} else if (targetIndex < entries.length) {
|
||||
// Show between entries
|
||||
const targetEntry = entries[targetIndex];
|
||||
if (targetEntry) {
|
||||
const rect = targetEntry.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
|
||||
dropIndicator.style.opacity = '1';
|
||||
}
|
||||
} else {
|
||||
// Show at bottom
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
if (lastEntry) {
|
||||
const rect = lastEntry.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
dropIndicator.style.top = `${(rect.bottom - containerRect.top + 2) / scale}px`;
|
||||
dropIndicator.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
if (!isDragging || !draggedElement) return;
|
||||
|
||||
const targetIndex = getDropTargetIndex(container, e.clientY);
|
||||
|
||||
// Get current LoRA data
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const currentIndex = lorasData.findIndex(l => l.name === loraName);
|
||||
|
||||
if (currentIndex !== -1 && currentIndex !== targetIndex) {
|
||||
// Calculate actual target index (excluding clip entries from count)
|
||||
const loraEntries = container.querySelectorAll('.comfy-lora-entry');
|
||||
let actualTargetIndex = targetIndex;
|
||||
|
||||
// Adjust target index if it's beyond the number of actual LoRA entries
|
||||
if (actualTargetIndex > loraEntries.length) {
|
||||
actualTargetIndex = loraEntries.length;
|
||||
}
|
||||
|
||||
// Move the LoRA
|
||||
const newLoras = [...lorasData];
|
||||
const [moved] = newLoras.splice(currentIndex, 1);
|
||||
newLoras.splice(actualTargetIndex > currentIndex ? actualTargetIndex - 1 : actualTargetIndex, 0, moved);
|
||||
|
||||
widget.value = formatLoraValue(newLoras);
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(widget.value);
|
||||
}
|
||||
|
||||
// Re-render
|
||||
if (renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
isDragging = false;
|
||||
if (draggedElement) {
|
||||
draggedElement.classList.remove('comfy-lora-dragging');
|
||||
draggedElement.style.opacity = '';
|
||||
draggedElement.style.transform = '';
|
||||
draggedElement = null;
|
||||
}
|
||||
|
||||
if (dropIndicator && container) {
|
||||
container.removeChild(dropIndicator);
|
||||
// Restore original position
|
||||
container.style.position = container._originalPosition || '';
|
||||
delete container._originalPosition;
|
||||
dropIndicator = null;
|
||||
}
|
||||
|
||||
// Reset cursor
|
||||
document.body.style.cursor = '';
|
||||
container = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Function to handle keyboard navigation
|
||||
export function handleKeyboardNavigation(event, selectedLora, widget, renderFunction, selectLora) {
|
||||
if (!selectedLora) return false;
|
||||
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
let handled = false;
|
||||
|
||||
// Check for Ctrl/Cmd modifier for reordering
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
const newLorasUp = moveLoraByDirection(lorasData, selectedLora, 'up');
|
||||
widget.value = formatLoraValue(newLorasUp);
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
if (renderFunction) renderFunction(widget.value, widget);
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
const newLorasDown = moveLoraByDirection(lorasData, selectedLora, 'down');
|
||||
widget.value = formatLoraValue(newLorasDown);
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
if (renderFunction) renderFunction(widget.value, widget);
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
const newLorasTop = moveLoraByDirection(lorasData, selectedLora, 'top');
|
||||
widget.value = formatLoraValue(newLorasTop);
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
if (renderFunction) renderFunction(widget.value, widget);
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
const newLorasBottom = moveLoraByDirection(lorasData, selectedLora, 'bottom');
|
||||
widget.value = formatLoraValue(newLorasBottom);
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
if (renderFunction) renderFunction(widget.value, widget);
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Normal navigation without Ctrl/Cmd
|
||||
switch (event.key) {
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
const currentIndex = lorasData.findIndex(l => l.name === selectedLora);
|
||||
if (currentIndex > 0) {
|
||||
selectLora(lorasData[currentIndex - 1].name);
|
||||
}
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
const currentIndexDown = lorasData.findIndex(l => l.name === selectedLora);
|
||||
if (currentIndexDown < lorasData.length - 1) {
|
||||
selectLora(lorasData[currentIndexDown + 1].name);
|
||||
}
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'Delete':
|
||||
case 'Backspace':
|
||||
event.preventDefault();
|
||||
const filtered = lorasData.filter(l => l.name !== selectedLora);
|
||||
widget.value = formatLoraValue(filtered);
|
||||
if (widget.callback) widget.callback(widget.value);
|
||||
if (renderFunction) renderFunction(widget.value, widget);
|
||||
selectLora(null); // Clear selection
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
// Function to create context menu
|
||||
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
|
||||
// Hide preview tooltip first
|
||||
@@ -398,6 +616,94 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
|
||||
}
|
||||
);
|
||||
|
||||
// Move Up option with arrow up icon
|
||||
const moveUpOption = createMenuItem(
|
||||
'Move Up',
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"></path></svg>',
|
||||
() => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const newLoras = moveLoraByDirection(lorasData, loraName, 'up');
|
||||
widget.value = formatLoraValue(newLoras);
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(widget.value);
|
||||
}
|
||||
|
||||
if (renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Move Down option with arrow down icon
|
||||
const moveDownOption = createMenuItem(
|
||||
'Move Down',
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"></path></svg>',
|
||||
() => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const newLoras = moveLoraByDirection(lorasData, loraName, 'down');
|
||||
widget.value = formatLoraValue(newLoras);
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(widget.value);
|
||||
}
|
||||
|
||||
if (renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Move to Top option with chevrons up icon
|
||||
const moveTopOption = createMenuItem(
|
||||
'Move to Top',
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 11l-5-5-5 5M17 18l-5-5-5 5"></path></svg>',
|
||||
() => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const newLoras = moveLoraByDirection(lorasData, loraName, 'top');
|
||||
widget.value = formatLoraValue(newLoras);
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(widget.value);
|
||||
}
|
||||
|
||||
if (renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Move to Bottom option with chevrons down icon
|
||||
const moveBottomOption = createMenuItem(
|
||||
'Move to Bottom',
|
||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"></path></svg>',
|
||||
() => {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeMenu);
|
||||
|
||||
const lorasData = parseLoraValue(widget.value);
|
||||
const newLoras = moveLoraByDirection(lorasData, loraName, 'bottom');
|
||||
widget.value = formatLoraValue(newLoras);
|
||||
|
||||
if (widget.callback) {
|
||||
widget.callback(widget.value);
|
||||
}
|
||||
|
||||
if (renderFunction) {
|
||||
renderFunction(widget.value, widget);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add separator
|
||||
const separator1 = document.createElement('div');
|
||||
Object.assign(separator1.style, {
|
||||
@@ -412,9 +718,21 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
});
|
||||
|
||||
// Add separator for order options
|
||||
const orderSeparator = document.createElement('div');
|
||||
Object.assign(orderSeparator.style, {
|
||||
margin: '4px 0',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
});
|
||||
|
||||
menu.appendChild(viewOnCivitaiOption);
|
||||
menu.appendChild(deleteOption);
|
||||
menu.appendChild(separator1);
|
||||
menu.appendChild(moveUpOption);
|
||||
menu.appendChild(moveDownOption);
|
||||
menu.appendChild(moveTopOption);
|
||||
menu.appendChild(moveBottomOption);
|
||||
menu.appendChild(orderSeparator);
|
||||
menu.appendChild(copyNotesOption);
|
||||
menu.appendChild(copyTriggerWordsOption);
|
||||
menu.appendChild(separator2);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
|
||||
// Fixed sizes for component calculations
|
||||
export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
|
||||
export const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry
|
||||
export const HEADER_HEIGHT = 40; // Height of the header section
|
||||
export const HEADER_HEIGHT = 32; // Height of the header section
|
||||
export const CONTAINER_PADDING = 12; // Top and bottom padding
|
||||
export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
|
||||
|
||||
@@ -164,3 +164,71 @@ export function showToast(message, type = 'info') {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a LoRA to a new position in the array
|
||||
* @param {Array} loras - Array of LoRA objects
|
||||
* @param {number} fromIndex - Current index of the LoRA
|
||||
* @param {number} toIndex - Target index for the LoRA
|
||||
* @returns {Array} - New array with LoRA moved
|
||||
*/
|
||||
export function moveLoraInArray(loras, fromIndex, toIndex) {
|
||||
const newLoras = [...loras];
|
||||
const [removed] = newLoras.splice(fromIndex, 1);
|
||||
newLoras.splice(toIndex, 0, removed);
|
||||
return newLoras;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a LoRA by name to a specific position
|
||||
* @param {Array} loras - Array of LoRA objects
|
||||
* @param {string} loraName - Name of the LoRA to move
|
||||
* @param {string} direction - 'up', 'down', 'top', 'bottom'
|
||||
* @returns {Array} - New array with LoRA moved
|
||||
*/
|
||||
export function moveLoraByDirection(loras, loraName, direction) {
|
||||
const currentIndex = loras.findIndex(l => l.name === loraName);
|
||||
if (currentIndex === -1) return loras;
|
||||
|
||||
let newIndex;
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
newIndex = Math.max(0, currentIndex - 1);
|
||||
break;
|
||||
case 'down':
|
||||
newIndex = Math.min(loras.length - 1, currentIndex + 1);
|
||||
break;
|
||||
case 'top':
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'bottom':
|
||||
newIndex = loras.length - 1;
|
||||
break;
|
||||
default:
|
||||
return loras;
|
||||
}
|
||||
|
||||
if (newIndex === currentIndex) return loras;
|
||||
return moveLoraInArray(loras, currentIndex, newIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the drop target index based on mouse position
|
||||
* @param {HTMLElement} container - The container element
|
||||
* @param {number} clientY - Mouse Y position
|
||||
* @returns {number} - Target index for dropping
|
||||
*/
|
||||
export function getDropTargetIndex(container, clientY) {
|
||||
const entries = container.querySelectorAll('.comfy-lora-entry');
|
||||
let targetIndex = entries.length;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const rect = entries[i].getBoundingClientRect();
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
targetIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return targetIndex;
|
||||
}
|
||||
|
||||
@@ -183,4 +183,47 @@ export function updateConnectedTriggerWords(node, loraNames) {
|
||||
})
|
||||
}).catch(err => console.error("Error fetching trigger words:", err));
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeLoras(lorasText, lorasArr) {
|
||||
// Parse lorasText into a map: name -> {strength, clipStrength}
|
||||
const parsedLoras = {};
|
||||
let match;
|
||||
LORA_PATTERN.lastIndex = 0;
|
||||
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
|
||||
const name = match[1];
|
||||
const modelStrength = Number(match[2]);
|
||||
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
|
||||
parsedLoras[name] = { strength: modelStrength, clipStrength };
|
||||
}
|
||||
|
||||
// Build result array in the order of lorasArr
|
||||
const result = [];
|
||||
const usedNames = new Set();
|
||||
|
||||
for (const lora of lorasArr) {
|
||||
if (parsedLoras[lora.name]) {
|
||||
result.push({
|
||||
name: lora.name,
|
||||
strength: lora.strength !== undefined ? lora.strength : parsedLoras[lora.name].strength,
|
||||
active: lora.active !== undefined ? lora.active : true,
|
||||
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
|
||||
});
|
||||
usedNames.add(lora.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new loras from lorasText that are not in lorasArr, in their text order
|
||||
for (const name in parsedLoras) {
|
||||
if (!usedNames.has(name)) {
|
||||
result.push({
|
||||
name,
|
||||
strength: parsedLoras[name].strength,
|
||||
active: true,
|
||||
clipStrength: parsedLoras[name].clipStrength,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -2,41 +2,12 @@ import { app } from "../../scripts/app.js";
|
||||
import {
|
||||
LORA_PATTERN,
|
||||
getActiveLorasFromNode,
|
||||
collectActiveLorasFromChain,
|
||||
updateConnectedTriggerWords,
|
||||
chainCallback
|
||||
chainCallback,
|
||||
mergeLoras
|
||||
} from "./utils.js";
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
|
||||
function mergeLoras(lorasText, lorasArr) {
|
||||
const result = [];
|
||||
let match;
|
||||
|
||||
// Reset pattern index before using
|
||||
LORA_PATTERN.lastIndex = 0;
|
||||
|
||||
// Parse text input and create initial entries
|
||||
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
|
||||
const name = match[1];
|
||||
const modelStrength = Number(match[2]);
|
||||
// Extract clip strength if provided, otherwise use model strength
|
||||
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
|
||||
|
||||
// Find if this lora exists in the array data
|
||||
const existingLora = lorasArr.find(l => l.name === name);
|
||||
|
||||
result.push({
|
||||
name: name,
|
||||
// Use existing strength if available, otherwise use input strength
|
||||
strength: existingLora ? existingLora.strength : modelStrength,
|
||||
active: existingLora ? existingLora.active : true,
|
||||
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.WanVideoLoraSelect",
|
||||
|
||||
|
||||
BIN
wiki-images/civitai-image-page.jpg
Normal file
BIN
wiki-images/civitai-image-page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 597 KiB |
Reference in New Issue
Block a user