mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 06:02:11 -03:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8408e626 | ||
|
|
0906271aa9 | ||
|
|
4c33c9d256 | ||
|
|
fa9c78209f | ||
|
|
6678ec8a60 | ||
|
|
854e467c12 | ||
|
|
e6b94c7b21 | ||
|
|
2c6f9d8602 | ||
|
|
c74033b9c0 | ||
|
|
d2b21d27bb | ||
|
|
215272469f | ||
|
|
f7d05ab0f1 | ||
|
|
6f2ad2be77 | ||
|
|
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 | ||
|
|
d89c2ca128 | ||
|
|
835584cc85 | ||
|
|
b2ffbe3a68 | ||
|
|
defcc79e6c | ||
|
|
c06d9f84f0 | ||
|
|
fe57a8e156 | ||
|
|
b77105795a | ||
|
|
e2df5fcf27 |
81
README.md
81
README.md
@@ -34,6 +34,41 @@ Enhance your Civitai browsing experience with our companion browser extension! S
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v0.8.26
|
||||||
|
* **Creator Search Option**
|
||||||
|
- Added ability to search models by creator name, making it easier to find models from specific authors.
|
||||||
|
* **Enhanced Node Usability**
|
||||||
|
- Improved user experience for Lora Loader, Lora Stacker, and WanVideo Lora Select nodes by fixing the maximum height of the text input area. Users can now freely and conveniently adjust the LoRA region within these nodes.
|
||||||
|
* **Compatibility Fixes**
|
||||||
|
- Resolved compatibility issues with ComfyUI and certain custom nodes, including ComfyUI-Custom-Scripts, ensuring smoother integration and operation.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
* **Custom Download Path Templates & Base Model Mapping** - Implemented UI settings for configuring download path templates and base model path mappings, allowing customized model organization and storage location when downloading models via LM Civitai Extension.
|
||||||
|
* **LM Civitai Extension Enhancements** - Improved concurrent download performance and stability, with new support for canceling active downloads directly from the extension interface.
|
||||||
|
* **Update Feature** - Added update functionality, allowing users to update LoRA Manager to the latest release version directly from the LoRA Manager UI.
|
||||||
|
* **Bulk Operations: Refresh All** - Added bulk refresh functionality, allowing users to update Civitai metadata across multiple LoRAs.
|
||||||
|
|
||||||
### v0.8.20
|
### v0.8.20
|
||||||
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
|
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
|
||||||
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding
|
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding
|
||||||
@@ -62,52 +97,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
|
* **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
|
* **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)
|
[View Update History](./update_logs.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
21
py/config.py
21
py/config.py
@@ -60,6 +60,9 @@ class Config:
|
|||||||
if self.checkpoints_roots and len(self.checkpoints_roots) == 1 and "default_checkpoint_root" not in settings:
|
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]
|
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
|
# Save settings
|
||||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(settings, f, indent=2)
|
json.dump(settings, f, indent=2)
|
||||||
@@ -201,16 +204,20 @@ class Config:
|
|||||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
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
|
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
|
# Now sort and use only the deduplicated real paths
|
||||||
unique_checkpoint_paths = sorted(checkpoint_map.values(), key=lambda p: p.lower())
|
unique_paths = sorted(merged_map.values(), key=lambda p: p.lower())
|
||||||
unique_unet_paths = sorted(unet_map.values(), key=lambda p: p.lower())
|
|
||||||
|
|
||||||
# Store individual paths in class properties
|
# Split back into checkpoints and unet roots for class properties
|
||||||
self.checkpoints_roots = unique_checkpoint_paths
|
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_map.values()]
|
||||||
self.unet_roots = unique_unet_paths
|
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_paths
|
||||||
all_paths = unique_checkpoint_paths + unique_unet_paths
|
|
||||||
|
|
||||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
||||||
|
|
||||||
|
|||||||
@@ -146,45 +146,33 @@ class MetadataHook:
|
|||||||
# Store the original _async_map_node_over_list function
|
# Store the original _async_map_node_over_list function
|
||||||
original_map_node_over_list = getattr(execution, map_node_func_name)
|
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!
|
# 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):
|
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
|
# Only collect metadata when calling the main function of nodes
|
||||||
if func == obj.FUNCTION and hasattr(obj, '__class__'):
|
if func == obj.FUNCTION and hasattr(obj, '__class__'):
|
||||||
try:
|
try:
|
||||||
# Get the current prompt_id from the registry
|
|
||||||
registry = MetadataRegistry()
|
registry = MetadataRegistry()
|
||||||
# We now have prompt_id directly from the function parameters
|
|
||||||
|
|
||||||
if prompt_id is not None:
|
if prompt_id is not None:
|
||||||
# Get node class type
|
|
||||||
class_type = obj.__class__.__name__
|
class_type = obj.__class__.__name__
|
||||||
|
|
||||||
# Use the passed unique_id parameter instead of trying to extract it
|
|
||||||
node_id = unique_id
|
node_id = unique_id
|
||||||
|
|
||||||
# Record inputs before execution
|
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
registry.record_node_execution(node_id, class_type, input_data_all, None)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error collecting metadata (pre-execution): {str(e)}")
|
print(f"Error collecting metadata (pre-execution): {str(e)}")
|
||||||
|
|
||||||
# Execute the original async function with ALL parameters in the correct order
|
# 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)
|
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__'):
|
if func == obj.FUNCTION and hasattr(obj, '__class__'):
|
||||||
try:
|
try:
|
||||||
# Get the current prompt_id from the registry
|
|
||||||
registry = MetadataRegistry()
|
registry = MetadataRegistry()
|
||||||
|
|
||||||
if prompt_id is not None:
|
if prompt_id is not None:
|
||||||
# Get node class type
|
|
||||||
class_type = obj.__class__.__name__
|
class_type = obj.__class__.__name__
|
||||||
|
|
||||||
# Use the passed unique_id parameter
|
|
||||||
node_id = unique_id
|
node_id = unique_id
|
||||||
|
|
||||||
# Record outputs after execution
|
|
||||||
if node_id is not None:
|
if node_id is not None:
|
||||||
registry.update_node_execution(node_id, class_type, results)
|
registry.update_node_execution(node_id, class_type, results)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import asyncio
|
|
||||||
import re
|
import re
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import folder_paths # type: ignore
|
import folder_paths # type: ignore
|
||||||
|
|||||||
@@ -119,10 +119,10 @@ class RecipeMetadataParser(ABC):
|
|||||||
# Check if exists locally
|
# Check if exists locally
|
||||||
if recipe_scanner and lora_entry['hash']:
|
if recipe_scanner and lora_entry['hash']:
|
||||||
lora_scanner = recipe_scanner._lora_scanner
|
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:
|
if exists_locally:
|
||||||
try:
|
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['existsLocally'] = True
|
||||||
lora_entry['localPath'] = local_path
|
lora_entry['localPath'] = local_path
|
||||||
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]
|
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)
|
# First use Civitai resources if available (more reliable source)
|
||||||
if metadata.get("civitai_resources"):
|
if metadata.get("civitai_resources"):
|
||||||
for resource in 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"):
|
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
|
||||||
# Initialize lora entry
|
# Initialize lora entry
|
||||||
lora_entry = {
|
lora_entry = {
|
||||||
'id': resource.get("modelVersionId", 0),
|
'id': resource.get("modelVersionId", 0),
|
||||||
'modelId': resource.get("modelId", 0),
|
'modelId': resource.get("modelId", 0),
|
||||||
'name': resource.get("modelName", "Unknown LoRA"),
|
'name': resource.get("modelName", "Unknown LoRA"),
|
||||||
'version': resource.get("modelVersionName", ""),
|
'version': resource.get("modelVersionName", resource.get("versionName", "")),
|
||||||
'type': resource.get("type", "lora"),
|
'type': resource.get("type", "lora"),
|
||||||
'weight': round(float(resource.get("weight", 1.0)), 2),
|
'weight': round(float(resource.get("weight", 1.0)), 2),
|
||||||
'existsLocally': False,
|
'existsLocally': False,
|
||||||
|
|||||||
@@ -153,10 +153,6 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
# Process civitaiResources array
|
# Process civitaiResources array
|
||||||
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
||||||
for resource in metadata["civitaiResources"]:
|
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
|
# Get unique identifier for deduplication
|
||||||
version_id = str(resource.get("modelVersionId", ""))
|
version_id = str(resource.get("modelVersionId", ""))
|
||||||
|
|
||||||
@@ -275,6 +271,66 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
|||||||
|
|
||||||
result["loras"].append(lora_entry)
|
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 base model wasn't found earlier, use the most common one from LoRAs
|
||||||
if not result["base_model"] and base_model_counts:
|
if not result["base_model"] and base_model_counts:
|
||||||
result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
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
|
# Check if this LoRA exists locally by SHA256 hash
|
||||||
if lora.get('hash') and recipe_scanner:
|
if lora.get('hash') and recipe_scanner:
|
||||||
lora_scanner = recipe_scanner._lora_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:
|
if exists_locally:
|
||||||
lora_cache = await lora_scanner.get_cached_data()
|
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)
|
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class BaseModelRoutes(ABC):
|
|||||||
prefix: URL prefix (e.g., 'loras', 'checkpoints')
|
prefix: URL prefix (e.g., 'loras', 'checkpoints')
|
||||||
"""
|
"""
|
||||||
# Common model management routes
|
# Common model management routes
|
||||||
app.router.add_get(f'/api/{prefix}', self.get_models)
|
app.router.add_get(f'/api/{prefix}/list', self.get_models)
|
||||||
app.router.add_post(f'/api/{prefix}/delete', self.delete_model)
|
app.router.add_post(f'/api/{prefix}/delete', self.delete_model)
|
||||||
app.router.add_post(f'/api/{prefix}/exclude', self.exclude_model)
|
app.router.add_post(f'/api/{prefix}/exclude', self.exclude_model)
|
||||||
app.router.add_post(f'/api/{prefix}/fetch-civitai', self.fetch_civitai)
|
app.router.add_post(f'/api/{prefix}/fetch-civitai', self.fetch_civitai)
|
||||||
@@ -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}/rename', self.rename_model)
|
||||||
app.router.add_post(f'/api/{prefix}/bulk-delete', self.bulk_delete_models)
|
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}/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
|
# Common query routes
|
||||||
app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
|
app.router.add_get(f'/api/{prefix}/top-tags', self.get_top_tags)
|
||||||
@@ -175,6 +177,7 @@ class BaseModelRoutes(ABC):
|
|||||||
'filename': request.query.get('search_filename', 'true').lower() == 'true',
|
'filename': request.query.get('search_filename', 'true').lower() == 'true',
|
||||||
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
|
'modelname': request.query.get('search_modelname', 'true').lower() == 'true',
|
||||||
'tags': request.query.get('search_tags', 'false').lower() == 'true',
|
'tags': request.query.get('search_tags', 'false').lower() == 'true',
|
||||||
|
'creator': request.query.get('search_creator', 'false').lower() == 'true',
|
||||||
'recursive': request.query.get('recursive', 'false').lower() == 'true',
|
'recursive': request.query.get('recursive', 'false').lower() == 'true',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +411,7 @@ class BaseModelRoutes(ABC):
|
|||||||
group["models"].append(await self.service.format_response(model))
|
group["models"].append(await self.service.format_response(model))
|
||||||
|
|
||||||
# Find the model from the main index too
|
# 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:
|
if hash_val:
|
||||||
main_path = self.service.get_path_by_hash(hash_val)
|
main_path = self.service.get_path_by_hash(hash_val)
|
||||||
if main_path and main_path not in paths:
|
if main_path and main_path not in paths:
|
||||||
@@ -617,3 +620,80 @@ class BaseModelRoutes(ABC):
|
|||||||
return web.json_response({
|
return web.json_response({
|
||||||
"error": "Not implemented in base class"
|
"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 .base_model_routes import BaseModelRoutes
|
||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,6 +43,10 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
# Checkpoint info by name
|
# Checkpoint info by name
|
||||||
app.router.add_get(f'/api/{prefix}/info/{{name}}', self.get_checkpoint_info)
|
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:
|
async def get_checkpoint_info(self, request: web.Request) -> web.Response:
|
||||||
"""Get detailed information for a specific checkpoint by name"""
|
"""Get detailed information for a specific checkpoint by name"""
|
||||||
try:
|
try:
|
||||||
@@ -103,3 +108,33 @@ class CheckpointRoutes(BaseModelRoutes):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching checkpoint model versions: {e}")
|
logger.error(f"Error fetching checkpoint model versions: {e}")
|
||||||
return web.Response(status=500, text=str(e))
|
return web.Response(status=500, text=str(e))
|
||||||
|
|
||||||
|
async def 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}/civitai-url', self.get_lora_civitai_url)
|
||||||
app.router.add_get(f'/api/{prefix}/model-description', self.get_lora_model_description)
|
app.router.add_get(f'/api/{prefix}/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
|
# 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/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)
|
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)
|
"error": str(e)
|
||||||
}, status=500)
|
}, 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:
|
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
||||||
"""Get model description for a Lora model"""
|
"""Get model description for a Lora model"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -167,6 +167,9 @@ class MiscRoutes:
|
|||||||
|
|
||||||
# Validate and update settings
|
# Validate and update settings
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
|
if value == settings.get(key):
|
||||||
|
# No change, skip
|
||||||
|
continue
|
||||||
# Special handling for example_images_path - verify path exists
|
# Special handling for example_images_path - verify path exists
|
||||||
if key == 'example_images_path' and value:
|
if key == 'example_images_path' and value:
|
||||||
if not os.path.exists(value):
|
if not os.path.exists(value):
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from ..config import config
|
|||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
standalone_mode = 'nodes' not in sys.modules
|
standalone_mode = 'nodes' not in sys.modules
|
||||||
|
|
||||||
from ..utils.utils import download_civitai_image
|
|
||||||
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
|
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
|
||||||
|
|
||||||
# Only import MetadataRegistry in non-standalone mode
|
# Only import MetadataRegistry in non-standalone mode
|
||||||
@@ -377,16 +376,6 @@ class RecipeRoutes:
|
|||||||
if 'meta' in image_info:
|
if 'meta' in image_info:
|
||||||
metadata = image_info['meta']
|
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 wasn't obtained from Civitai API, extract it from the image
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
# Extract metadata from the image using ExifUtils
|
# Extract metadata from the image using ExifUtils
|
||||||
@@ -638,21 +627,6 @@ class RecipeRoutes:
|
|||||||
image = base64.b64decode(image_base64)
|
image = base64.b64decode(image_base64)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": f"Invalid base64 image data: {str(e)}"}, status=400)
|
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:
|
else:
|
||||||
return web.json_response({"error": "No image data provided"}, status=400)
|
return web.json_response({"error": "No image data provided"}, status=400)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class StatsRoutes:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lora_scanner = None
|
self.lora_scanner = None
|
||||||
self.checkpoint_scanner = None
|
self.checkpoint_scanner = None
|
||||||
|
self.embedding_scanner = None
|
||||||
self.usage_stats = None
|
self.usage_stats = None
|
||||||
self.template_env = jinja2.Environment(
|
self.template_env = jinja2.Environment(
|
||||||
loader=jinja2.FileSystemLoader(config.templates_path),
|
loader=jinja2.FileSystemLoader(config.templates_path),
|
||||||
@@ -30,6 +31,7 @@ class StatsRoutes:
|
|||||||
"""Initialize services from ServiceRegistry"""
|
"""Initialize services from ServiceRegistry"""
|
||||||
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
|
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
self.embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
self.usage_stats = UsageStats()
|
self.usage_stats = UsageStats()
|
||||||
|
|
||||||
async def handle_stats_page(self, request: web.Request) -> web.Response:
|
async def handle_stats_page(self, request: web.Request) -> web.Response:
|
||||||
@@ -49,7 +51,12 @@ class StatsRoutes:
|
|||||||
(hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing)
|
(hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing)
|
||||||
)
|
)
|
||||||
|
|
||||||
is_initializing = lora_initializing or checkpoint_initializing
|
embedding_initializing = (
|
||||||
|
self.embedding_scanner._cache is None or
|
||||||
|
(hasattr(self.embedding_scanner, 'is_initializing') and self.embedding_scanner.is_initializing())
|
||||||
|
)
|
||||||
|
|
||||||
|
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
|
||||||
|
|
||||||
template = self.template_env.get_template('statistics.html')
|
template = self.template_env.get_template('statistics.html')
|
||||||
rendered = template.render(
|
rendered = template.render(
|
||||||
@@ -85,21 +92,29 @@ class StatsRoutes:
|
|||||||
checkpoint_count = len(checkpoint_cache.raw_data)
|
checkpoint_count = len(checkpoint_cache.raw_data)
|
||||||
checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
|
checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
|
||||||
|
|
||||||
|
# Get Embedding statistics
|
||||||
|
embedding_cache = await self.embedding_scanner.get_cached_data()
|
||||||
|
embedding_count = len(embedding_cache.raw_data)
|
||||||
|
embedding_size = sum(emb.get('size', 0) for emb in embedding_cache.raw_data)
|
||||||
|
|
||||||
# Get usage statistics
|
# Get usage statistics
|
||||||
usage_data = await self.usage_stats.get_stats()
|
usage_data = await self.usage_stats.get_stats()
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {
|
'data': {
|
||||||
'total_models': lora_count + checkpoint_count,
|
'total_models': lora_count + checkpoint_count + embedding_count,
|
||||||
'lora_count': lora_count,
|
'lora_count': lora_count,
|
||||||
'checkpoint_count': checkpoint_count,
|
'checkpoint_count': checkpoint_count,
|
||||||
'total_size': lora_size + checkpoint_size,
|
'embedding_count': embedding_count,
|
||||||
|
'total_size': lora_size + checkpoint_size + embedding_size,
|
||||||
'lora_size': lora_size,
|
'lora_size': lora_size,
|
||||||
'checkpoint_size': checkpoint_size,
|
'checkpoint_size': checkpoint_size,
|
||||||
|
'embedding_size': embedding_size,
|
||||||
'total_generations': usage_data.get('total_executions', 0),
|
'total_generations': usage_data.get('total_executions', 0),
|
||||||
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
|
||||||
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
|
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
|
||||||
|
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -121,14 +136,17 @@ class StatsRoutes:
|
|||||||
# Get model data for enrichment
|
# Get model data for enrichment
|
||||||
lora_cache = await self.lora_scanner.get_cached_data()
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
embedding_cache = await self.embedding_scanner.get_cached_data()
|
||||||
|
|
||||||
# Create hash to model mapping
|
# Create hash to model mapping
|
||||||
lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data}
|
lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data}
|
||||||
checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data}
|
checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data}
|
||||||
|
embedding_map = {emb['sha256']: emb for emb in embedding_cache.raw_data}
|
||||||
|
|
||||||
# Prepare top used models
|
# Prepare top used models
|
||||||
top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10)
|
top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10)
|
||||||
top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10)
|
top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10)
|
||||||
|
top_embeddings = self._get_top_used_models(usage_data.get('embeddings', {}), embedding_map, 10)
|
||||||
|
|
||||||
# Prepare usage timeline (last 30 days)
|
# Prepare usage timeline (last 30 days)
|
||||||
timeline = self._get_usage_timeline(usage_data, 30)
|
timeline = self._get_usage_timeline(usage_data, 30)
|
||||||
@@ -138,6 +156,7 @@ class StatsRoutes:
|
|||||||
'data': {
|
'data': {
|
||||||
'top_loras': top_loras,
|
'top_loras': top_loras,
|
||||||
'top_checkpoints': top_checkpoints,
|
'top_checkpoints': top_checkpoints,
|
||||||
|
'top_embeddings': top_embeddings,
|
||||||
'usage_timeline': timeline,
|
'usage_timeline': timeline,
|
||||||
'total_executions': usage_data.get('total_executions', 0)
|
'total_executions': usage_data.get('total_executions', 0)
|
||||||
}
|
}
|
||||||
@@ -158,16 +177,19 @@ class StatsRoutes:
|
|||||||
# Get model data
|
# Get model data
|
||||||
lora_cache = await self.lora_scanner.get_cached_data()
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
embedding_cache = await self.embedding_scanner.get_cached_data()
|
||||||
|
|
||||||
# Count by base model
|
# Count by base model
|
||||||
lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data)
|
lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data)
|
||||||
checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data)
|
checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data)
|
||||||
|
embedding_base_models = Counter(emb.get('base_model', 'Unknown') for emb in embedding_cache.raw_data)
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {
|
'data': {
|
||||||
'loras': dict(lora_base_models),
|
'loras': dict(lora_base_models),
|
||||||
'checkpoints': dict(checkpoint_base_models)
|
'checkpoints': dict(checkpoint_base_models),
|
||||||
|
'embeddings': dict(embedding_base_models)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -186,6 +208,7 @@ class StatsRoutes:
|
|||||||
# Get model data
|
# Get model data
|
||||||
lora_cache = await self.lora_scanner.get_cached_data()
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
embedding_cache = await self.embedding_scanner.get_cached_data()
|
||||||
|
|
||||||
# Count tag frequencies
|
# Count tag frequencies
|
||||||
all_tags = []
|
all_tags = []
|
||||||
@@ -193,6 +216,8 @@ class StatsRoutes:
|
|||||||
all_tags.extend(lora.get('tags', []))
|
all_tags.extend(lora.get('tags', []))
|
||||||
for cp in checkpoint_cache.raw_data:
|
for cp in checkpoint_cache.raw_data:
|
||||||
all_tags.extend(cp.get('tags', []))
|
all_tags.extend(cp.get('tags', []))
|
||||||
|
for emb in embedding_cache.raw_data:
|
||||||
|
all_tags.extend(emb.get('tags', []))
|
||||||
|
|
||||||
tag_counts = Counter(all_tags)
|
tag_counts = Counter(all_tags)
|
||||||
|
|
||||||
@@ -225,6 +250,7 @@ class StatsRoutes:
|
|||||||
# Get model data
|
# Get model data
|
||||||
lora_cache = await self.lora_scanner.get_cached_data()
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
embedding_cache = await self.embedding_scanner.get_cached_data()
|
||||||
|
|
||||||
# Create models with usage data
|
# Create models with usage data
|
||||||
lora_storage = []
|
lora_storage = []
|
||||||
@@ -255,15 +281,31 @@ class StatsRoutes:
|
|||||||
'base_model': cp.get('base_model', 'Unknown')
|
'base_model': cp.get('base_model', 'Unknown')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
embedding_storage = []
|
||||||
|
for emb in embedding_cache.raw_data:
|
||||||
|
usage_count = 0
|
||||||
|
if emb['sha256'] in usage_data.get('embeddings', {}):
|
||||||
|
usage_count = usage_data['embeddings'][emb['sha256']].get('total', 0)
|
||||||
|
|
||||||
|
embedding_storage.append({
|
||||||
|
'name': emb['model_name'],
|
||||||
|
'size': emb.get('size', 0),
|
||||||
|
'usage_count': usage_count,
|
||||||
|
'folder': emb.get('folder', ''),
|
||||||
|
'base_model': emb.get('base_model', 'Unknown')
|
||||||
|
})
|
||||||
|
|
||||||
# Sort by size
|
# Sort by size
|
||||||
lora_storage.sort(key=lambda x: x['size'], reverse=True)
|
lora_storage.sort(key=lambda x: x['size'], reverse=True)
|
||||||
checkpoint_storage.sort(key=lambda x: x['size'], reverse=True)
|
checkpoint_storage.sort(key=lambda x: x['size'], reverse=True)
|
||||||
|
embedding_storage.sort(key=lambda x: x['size'], reverse=True)
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': {
|
'data': {
|
||||||
'loras': lora_storage[:20], # Top 20 by size
|
'loras': lora_storage[:20], # Top 20 by size
|
||||||
'checkpoints': checkpoint_storage[:20]
|
'checkpoints': checkpoint_storage[:20],
|
||||||
|
'embeddings': embedding_storage[:20]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -285,15 +327,18 @@ class StatsRoutes:
|
|||||||
# Get model data
|
# Get model data
|
||||||
lora_cache = await self.lora_scanner.get_cached_data()
|
lora_cache = await self.lora_scanner.get_cached_data()
|
||||||
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
|
||||||
|
embedding_cache = await self.embedding_scanner.get_cached_data()
|
||||||
|
|
||||||
insights = []
|
insights = []
|
||||||
|
|
||||||
# Calculate unused models
|
# Calculate unused models
|
||||||
unused_loras = self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {}))
|
unused_loras = self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {}))
|
||||||
unused_checkpoints = self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
|
unused_checkpoints = self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
|
||||||
|
unused_embeddings = self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
|
||||||
|
|
||||||
total_loras = len(lora_cache.raw_data)
|
total_loras = len(lora_cache.raw_data)
|
||||||
total_checkpoints = len(checkpoint_cache.raw_data)
|
total_checkpoints = len(checkpoint_cache.raw_data)
|
||||||
|
total_embeddings = len(embedding_cache.raw_data)
|
||||||
|
|
||||||
if total_loras > 0:
|
if total_loras > 0:
|
||||||
unused_lora_percent = (unused_loras / total_loras) * 100
|
unused_lora_percent = (unused_loras / total_loras) * 100
|
||||||
@@ -315,9 +360,20 @@ class StatsRoutes:
|
|||||||
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
'suggestion': 'Review and consider removing checkpoints you no longer need.'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if total_embeddings > 0:
|
||||||
|
unused_embedding_percent = (unused_embeddings / total_embeddings) * 100
|
||||||
|
if unused_embedding_percent > 50:
|
||||||
|
insights.append({
|
||||||
|
'type': 'warning',
|
||||||
|
'title': 'High Number of Unused Embeddings',
|
||||||
|
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
|
||||||
|
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
|
||||||
|
})
|
||||||
|
|
||||||
# Storage insights
|
# Storage insights
|
||||||
total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \
|
total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \
|
||||||
sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
|
sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data) + \
|
||||||
|
sum(emb.get('size', 0) for emb in embedding_cache.raw_data)
|
||||||
|
|
||||||
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
|
||||||
insights.append({
|
insights.append({
|
||||||
@@ -390,6 +446,7 @@ class StatsRoutes:
|
|||||||
|
|
||||||
lora_usage = 0
|
lora_usage = 0
|
||||||
checkpoint_usage = 0
|
checkpoint_usage = 0
|
||||||
|
embedding_usage = 0
|
||||||
|
|
||||||
# Count usage for this date
|
# Count usage for this date
|
||||||
for model_usage in usage_data.get('loras', {}).values():
|
for model_usage in usage_data.get('loras', {}).values():
|
||||||
@@ -400,11 +457,16 @@ class StatsRoutes:
|
|||||||
if isinstance(model_usage, dict) and 'history' in model_usage:
|
if isinstance(model_usage, dict) and 'history' in model_usage:
|
||||||
checkpoint_usage += model_usage['history'].get(date_str, 0)
|
checkpoint_usage += model_usage['history'].get(date_str, 0)
|
||||||
|
|
||||||
|
for model_usage in usage_data.get('embeddings', {}).values():
|
||||||
|
if isinstance(model_usage, dict) and 'history' in model_usage:
|
||||||
|
embedding_usage += model_usage['history'].get(date_str, 0)
|
||||||
|
|
||||||
timeline.append({
|
timeline.append({
|
||||||
'date': date_str,
|
'date': date_str,
|
||||||
'lora_usage': lora_usage,
|
'lora_usage': lora_usage,
|
||||||
'checkpoint_usage': checkpoint_usage,
|
'checkpoint_usage': checkpoint_usage,
|
||||||
'total_usage': lora_usage + checkpoint_usage
|
'embedding_usage': embedding_usage,
|
||||||
|
'total_usage': lora_usage + checkpoint_usage + embedding_usage
|
||||||
})
|
})
|
||||||
|
|
||||||
return list(reversed(timeline)) # Oldest to newest
|
return list(reversed(timeline)) # Oldest to newest
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import logging
|
import logging
|
||||||
import toml
|
import toml
|
||||||
import git
|
import git
|
||||||
from datetime import datetime
|
import zipfile
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
@@ -101,18 +102,16 @@ class UpdateRoutes:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def perform_update(request):
|
async def perform_update(request):
|
||||||
"""
|
"""
|
||||||
Perform Git-based update to latest release tag or main branch
|
Perform Git-based update to latest release tag or main branch.
|
||||||
|
If .git is missing, fallback to ZIP download.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Parse request body
|
|
||||||
body = await request.json() if request.has_body else {}
|
body = await request.json() if request.has_body else {}
|
||||||
nightly = body.get('nightly', False)
|
nightly = body.get('nightly', False)
|
||||||
|
|
||||||
# Get current plugin directory
|
|
||||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
plugin_root = os.path.dirname(os.path.dirname(current_dir))
|
||||||
|
|
||||||
# Backup settings.json if it exists
|
|
||||||
settings_path = os.path.join(plugin_root, 'settings.json')
|
settings_path = os.path.join(plugin_root, 'settings.json')
|
||||||
settings_backup = None
|
settings_backup = None
|
||||||
if os.path.exists(settings_path):
|
if os.path.exists(settings_path):
|
||||||
@@ -120,10 +119,14 @@ class UpdateRoutes:
|
|||||||
settings_backup = f.read()
|
settings_backup = f.read()
|
||||||
logger.info("Backed up settings.json")
|
logger.info("Backed up settings.json")
|
||||||
|
|
||||||
# Perform Git update
|
git_folder = os.path.join(plugin_root, '.git')
|
||||||
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
if os.path.exists(git_folder):
|
||||||
|
# Git update
|
||||||
|
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
|
||||||
|
else:
|
||||||
|
# Fallback: Download ZIP and replace files
|
||||||
|
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
|
||||||
|
|
||||||
# Restore settings.json if we backed it up
|
|
||||||
if settings_backup and success:
|
if settings_backup and success:
|
||||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(settings_backup)
|
f.write(settings_backup)
|
||||||
@@ -138,7 +141,7 @@ class UpdateRoutes:
|
|||||||
else:
|
else:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Failed to complete Git update'
|
'error': 'Failed to complete update'
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -148,6 +151,87 @@ class UpdateRoutes:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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. Writes extracted file list to .tracking.
|
||||||
|
"""
|
||||||
|
repo_owner = "willmiao"
|
||||||
|
repo_name = "ComfyUI-Lora-Manager"
|
||||||
|
github_api = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(github_api) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error(f"Failed to fetch release info: {resp.status}")
|
||||||
|
return False, ""
|
||||||
|
data = await resp.json()
|
||||||
|
zip_url = data.get("zipball_url")
|
||||||
|
version = data.get("tag_name", "unknown")
|
||||||
|
|
||||||
|
# Download ZIP
|
||||||
|
async with session.get(zip_url) as zip_resp:
|
||||||
|
if zip_resp.status != 200:
|
||||||
|
logger.error(f"Failed to download ZIP: {zip_resp.status}")
|
||||||
|
return False, ""
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
|
||||||
|
tmp_zip.write(await zip_resp.read())
|
||||||
|
zip_path = tmp_zip.name
|
||||||
|
|
||||||
|
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json'])
|
||||||
|
|
||||||
|
# Extract ZIP to temp dir
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
|
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
||||||
|
zip_ref.extractall(tmp_dir)
|
||||||
|
# Find extracted folder (GitHub ZIP contains a root folder)
|
||||||
|
extracted_root = next(os.scandir(tmp_dir)).path
|
||||||
|
|
||||||
|
# Copy files, skipping settings.json
|
||||||
|
for item in os.listdir(extracted_root):
|
||||||
|
src = os.path.join(extracted_root, item)
|
||||||
|
dst = os.path.join(plugin_root, item)
|
||||||
|
if os.path.isdir(src):
|
||||||
|
if os.path.exists(dst):
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
|
||||||
|
else:
|
||||||
|
if item == 'settings.json':
|
||||||
|
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
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ZIP update failed: {e}", exc_info=True)
|
||||||
|
return False, ""
|
||||||
|
|
||||||
|
def _clean_plugin_folder(plugin_root, skip_files=None):
|
||||||
|
skip_files = skip_files or []
|
||||||
|
for item in os.listdir(plugin_root):
|
||||||
|
if item in skip_files:
|
||||||
|
continue
|
||||||
|
path = os.path.join(plugin_root, item)
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get_nightly_version() -> tuple[str, List[str]]:
|
async def _get_nightly_version() -> tuple[str, List[str]]:
|
||||||
"""
|
"""
|
||||||
@@ -291,7 +375,7 @@ class UpdateRoutes:
|
|||||||
|
|
||||||
git_info = {
|
git_info = {
|
||||||
'commit_hash': 'unknown',
|
'commit_hash': 'unknown',
|
||||||
'short_hash': 'unknown',
|
'short_hash': 'stable',
|
||||||
'branch': 'unknown',
|
'branch': 'unknown',
|
||||||
'commit_date': 'unknown'
|
'commit_date': 'unknown'
|
||||||
}
|
}
|
||||||
@@ -301,49 +385,12 @@ class UpdateRoutes:
|
|||||||
if not os.path.exists(os.path.join(plugin_root, '.git')):
|
if not os.path.exists(os.path.join(plugin_root, '.git')):
|
||||||
return git_info
|
return git_info
|
||||||
|
|
||||||
# Get current commit hash
|
repo = git.Repo(plugin_root)
|
||||||
result = subprocess.run(
|
commit = repo.head.commit
|
||||||
['git', 'rev-parse', 'HEAD'],
|
git_info['commit_hash'] = commit.hexsha
|
||||||
cwd=plugin_root,
|
git_info['short_hash'] = commit.hexsha[:7]
|
||||||
stdout=subprocess.PIPE,
|
git_info['branch'] = repo.active_branch.name if not repo.head.is_detached else 'detached'
|
||||||
stderr=subprocess.PIPE,
|
git_info['commit_date'] = commit.committed_datetime.strftime('%Y-%m-%d')
|
||||||
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
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error getting git info: {e}")
|
logger.warning(f"Error getting git info: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,22 @@ class BaseModelService(ABC):
|
|||||||
search_results.append(item)
|
search_results.append(item)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Search by creator
|
||||||
|
civitai = item.get('civitai')
|
||||||
|
creator_username = ''
|
||||||
|
if civitai and isinstance(civitai, dict):
|
||||||
|
creator = civitai.get('creator')
|
||||||
|
if creator and isinstance(creator, dict):
|
||||||
|
creator_username = creator.get('username', '')
|
||||||
|
if search_options.get('creator', False) and creator_username:
|
||||||
|
if fuzzy_search:
|
||||||
|
if fuzzy_match(creator_username, search):
|
||||||
|
search_results.append(item)
|
||||||
|
continue
|
||||||
|
elif search.lower() in creator_username.lower():
|
||||||
|
search_results.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
return search_results
|
return search_results
|
||||||
|
|
||||||
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
async def _apply_specific_filters(self, data: List[Dict], **kwargs) -> List[Dict]:
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ class CheckpointScanner(ModelScanner):
|
|||||||
hash_index=ModelHashIndex()
|
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]:
|
def get_model_roots(self) -> List[str]:
|
||||||
"""Get checkpoint root directories"""
|
"""Get checkpoint root directories"""
|
||||||
return config.base_models_roots
|
return config.base_models_roots
|
||||||
@@ -199,8 +199,6 @@ class ModelHashIndex:
|
|||||||
|
|
||||||
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
def get_hash_by_filename(self, filename: str) -> Optional[str]:
|
||||||
"""Get hash for a filename without extension"""
|
"""Get hash for a filename without extension"""
|
||||||
# Strip extension if present to make the function more flexible
|
|
||||||
filename = os.path.splitext(filename)[0]
|
|
||||||
return self._filename_to_hash.get(filename)
|
return self._filename_to_hash.get(filename)
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
|
|||||||
@@ -569,12 +569,12 @@ class ModelScanner:
|
|||||||
for entry in entries:
|
for entry in entries:
|
||||||
try:
|
try:
|
||||||
if entry.is_file(follow_symlinks=True) and any(entry.name.endswith(ext) for ext in self.file_extensions):
|
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, "/")
|
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)
|
await asyncio.sleep(0)
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
# For directories, continue scanning with original path
|
|
||||||
await scan_recursive(entry.path, visited_paths)
|
await scan_recursive(entry.path, visited_paths)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing entry {entry.path}: {e}")
|
logger.error(f"Error processing entry {entry.path}: {e}")
|
||||||
@@ -584,15 +584,6 @@ class ModelScanner:
|
|||||||
await scan_recursive(root_path, set())
|
await scan_recursive(root_path, set())
|
||||||
return models
|
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:
|
def is_initializing(self) -> bool:
|
||||||
"""Check if the scanner is currently initializing"""
|
"""Check if the scanner is currently initializing"""
|
||||||
return self._is_initializing
|
return self._is_initializing
|
||||||
@@ -613,7 +604,10 @@ class ModelScanner:
|
|||||||
return os.path.dirname(rel_path).replace(os.path.sep, '/')
|
return os.path.dirname(rel_path).replace(os.path.sep, '/')
|
||||||
return ''
|
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:
|
async def _process_model_file(self, file_path: str, root_path: str) -> Dict:
|
||||||
"""Process a single model file and return its metadata"""
|
"""Process a single model file and return its metadata"""
|
||||||
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
|
metadata = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||||
@@ -667,6 +661,9 @@ class ModelScanner:
|
|||||||
if metadata is None:
|
if metadata is None:
|
||||||
metadata = await self._create_default_metadata(file_path)
|
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()
|
model_data = metadata.to_dict()
|
||||||
|
|
||||||
# Skip excluded models
|
# Skip excluded models
|
||||||
@@ -732,48 +729,6 @@ class ModelScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {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:
|
async def add_model_to_cache(self, metadata_dict: Dict, folder: str = '') -> bool:
|
||||||
"""Add a model to the cache
|
"""Add a model to the cache
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class SettingsManager:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
|
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
|
||||||
self.settings = self._load_settings()
|
self.settings = self._load_settings()
|
||||||
|
self._auto_set_default_roots()
|
||||||
self._check_environment_variables()
|
self._check_environment_variables()
|
||||||
|
|
||||||
def _load_settings(self) -> Dict[str, Any]:
|
def _load_settings(self) -> Dict[str, Any]:
|
||||||
@@ -21,6 +22,28 @@ class SettingsManager:
|
|||||||
logger.error(f"Error loading settings: {e}")
|
logger.error(f"Error loading settings: {e}")
|
||||||
return self._get_default_settings()
|
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:
|
def _check_environment_variables(self) -> None:
|
||||||
"""Check for environment variables and update settings if needed"""
|
"""Check for environment variables and update settings if needed"""
|
||||||
env_api_key = os.environ.get('CIVITAI_API_KEY')
|
env_api_key = os.environ.get('CIVITAI_API_KEY')
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ VALID_LORA_TYPES = ['lora', 'locon', 'dora']
|
|||||||
|
|
||||||
# Civitai model tags in priority order for subfolder organization
|
# Civitai model tags in priority order for subfolder organization
|
||||||
CIVITAI_MODEL_TAGS = [
|
CIVITAI_MODEL_TAGS = [
|
||||||
'character', 'style', 'concept', 'clothing', 'base model',
|
'character', 'style', 'concept', 'clothing',
|
||||||
|
# 'base model', # exclude 'base model'
|
||||||
'poses', 'background', 'tool', 'vehicle', 'buildings',
|
'poses', 'background', 'tool', 'vehicle', 'buildings',
|
||||||
'objects', 'assets', 'animal', 'action'
|
'objects', 'assets', 'animal', 'action'
|
||||||
]
|
]
|
||||||
@@ -91,7 +91,7 @@ class DownloadManager:
|
|||||||
with open(progress_file, 'r', encoding='utf-8') as f:
|
with open(progress_file, 'r', encoding='utf-8') as f:
|
||||||
saved_progress = json.load(f)
|
saved_progress = json.load(f)
|
||||||
download_progress['processed_models'] = set(saved_progress.get('processed_models', []))
|
download_progress['processed_models'] = set(saved_progress.get('processed_models', []))
|
||||||
logger.info(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed")
|
logger.debug(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load progress file: {e}")
|
logger.error(f"Failed to load progress file: {e}")
|
||||||
download_progress['processed_models'] = set()
|
download_progress['processed_models'] = set()
|
||||||
@@ -230,7 +230,7 @@ class DownloadManager:
|
|||||||
|
|
||||||
# Update total count
|
# Update total count
|
||||||
download_progress['total'] = len(all_models)
|
download_progress['total'] = len(all_models)
|
||||||
logger.info(f"Found {download_progress['total']} models to process")
|
logger.debug(f"Found {download_progress['total']} models to process")
|
||||||
|
|
||||||
# Process each model
|
# Process each model
|
||||||
for i, (scanner_type, model, scanner) in enumerate(all_models):
|
for i, (scanner_type, model, scanner) in enumerate(all_models):
|
||||||
@@ -250,7 +250,7 @@ class DownloadManager:
|
|||||||
# Mark as completed
|
# Mark as completed
|
||||||
download_progress['status'] = 'completed'
|
download_progress['status'] = 'completed'
|
||||||
download_progress['end_time'] = time.time()
|
download_progress['end_time'] = time.time()
|
||||||
logger.info(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
|
logger.debug(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error during example images download: {str(e)}"
|
error_msg = f"Error during example images download: {str(e)}"
|
||||||
@@ -307,7 +307,7 @@ class DownloadManager:
|
|||||||
logger.debug(f"Skipping already processed model: {model_name}")
|
logger.debug(f"Skipping already processed model: {model_name}")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
|
logger.debug(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
|
||||||
|
|
||||||
# Create model directory
|
# Create model directory
|
||||||
model_dir = os.path.join(output_dir, model_hash)
|
model_dir = os.path.join(output_dir, model_hash)
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ class ExampleImagesFileManager:
|
|||||||
|
|
||||||
# Construct folder path for this model
|
# Construct folder path for this model
|
||||||
model_folder = os.path.join(example_images_path, model_hash)
|
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
|
# Check if folder exists
|
||||||
if not os.path.exists(model_folder):
|
if not os.path.exists(model_folder):
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
import requests
|
|
||||||
import tempfile
|
|
||||||
import os
|
import os
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
from ..config import config
|
from ..config import config
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -50,82 +47,7 @@ def get_lora_info(lora_name):
|
|||||||
# No event loop is running, we can use asyncio.run()
|
# No event loop is running, we can use asyncio.run()
|
||||||
return asyncio.run(_get_lora_info_async())
|
return asyncio.run(_get_lora_info_async())
|
||||||
|
|
||||||
def download_twitter_image(url):
|
def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool:
|
||||||
"""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.
|
Check if text matches pattern using fuzzy matching.
|
||||||
Returns True if similarity ratio is above threshold.
|
Returns True if similarity ratio is above threshold.
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "0.8.21"
|
version = "0.8.26"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"safetensors",
|
"safetensors",
|
||||||
"beautifulsoup4",
|
|
||||||
"piexif",
|
"piexif",
|
||||||
"Pillow",
|
"Pillow",
|
||||||
"olefile", # for getting rid of warning message
|
"olefile", # for getting rid of warning message
|
||||||
"requests",
|
|
||||||
"toml",
|
"toml",
|
||||||
"natsort",
|
"natsort",
|
||||||
"GitPython"
|
"GitPython"
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
aiohttp
|
aiohttp
|
||||||
jinja2
|
jinja2
|
||||||
safetensors
|
safetensors
|
||||||
beautifulsoup4
|
|
||||||
piexif
|
piexif
|
||||||
Pillow
|
Pillow
|
||||||
olefile
|
olefile
|
||||||
requests
|
|
||||||
toml
|
toml
|
||||||
numpy
|
numpy
|
||||||
natsort
|
natsort
|
||||||
pyyaml
|
|
||||||
GitPython
|
GitPython
|
||||||
|
|||||||
245
static/css/components/banner.css
Normal file
245
static/css/components/banner.css
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
/* Banner Container */
|
||||||
|
.banner-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
z-index: calc(var(--z-header) - 1);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--card-bg);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual Banner */
|
||||||
|
.banner-item {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05),
|
||||||
|
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02)
|
||||||
|
);
|
||||||
|
border-left: 4px solid var(--lora-accent);
|
||||||
|
animation: banner-slide-down 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner Content Layout */
|
||||||
|
.banner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner Text Section */
|
||||||
|
.banner-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Banner Actions */
|
||||||
|
.banner-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-action i {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Action Button */
|
||||||
|
.banner-action-primary {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-action-primary:hover {
|
||||||
|
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 6px oklch(var(--lora-accent) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary Action Button */
|
||||||
|
.banner-action-secondary {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-action-secondary:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tertiary Action Button */
|
||||||
|
.banner-action-tertiary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-action-tertiary:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dismiss Button */
|
||||||
|
.banner-dismiss {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-dismiss:hover {
|
||||||
|
background: oklch(var(--lora-accent) / 0.1);
|
||||||
|
color: var(--lora-accent);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes banner-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes banner-slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
max-height: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.banner-content {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-action {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-dismiss {
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-item {
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-title {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-description {
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.banner-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-action {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-content {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] .banner-item {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
|
||||||
|
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.03)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection */
|
||||||
|
.banner-item,
|
||||||
|
.banner-title,
|
||||||
|
.banner-description,
|
||||||
|
.banner-action,
|
||||||
|
.banner-dismiss {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
@@ -424,6 +424,33 @@
|
|||||||
font-size: 0.85em;
|
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 */
|
/* Prevent text selection on cards and interactive elements */
|
||||||
.model-card,
|
.model-card,
|
||||||
.model-card *,
|
.model-card *,
|
||||||
|
|||||||
@@ -19,6 +19,18 @@
|
|||||||
height: 100%;
|
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 */
|
/* Logo and title styling */
|
||||||
.header-branding {
|
.header-branding {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -123,6 +123,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
|
||||||
|
.modal-content .back-to-top {
|
||||||
|
position: sticky; /* 改用 sticky 定位 */
|
||||||
|
float: right; /* 使用 float 确保按钮在右侧 */
|
||||||
|
bottom: 20px; /* 距离底部的距离 */
|
||||||
|
margin-right: 20px; /* 右侧间距 */
|
||||||
|
margin-top: -56px; /* 负边距确保不占用额外空间 */
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .back-to-top.visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content .back-to-top:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
/* File name copy styles */
|
/* File name copy styles */
|
||||||
.file-name-wrapper {
|
.file-name-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -147,7 +183,11 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-file-name-btn {
|
/* 合并编辑按钮样式 */
|
||||||
|
.edit-model-name-btn,
|
||||||
|
.edit-file-name-btn,
|
||||||
|
.edit-base-model-btn,
|
||||||
|
.edit-model-description-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -159,17 +199,28 @@
|
|||||||
margin-left: var(--space-1);
|
margin-left: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-model-name-btn.visible,
|
||||||
.edit-file-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;
|
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;
|
opacity: 0.8 !important;
|
||||||
background: rgba(0, 0, 0, 0.05);
|
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);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,32 +249,6 @@
|
|||||||
flex: 1;
|
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 {
|
.base-model-selector {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 3px 5px;
|
padding: 3px 5px;
|
||||||
@@ -280,32 +305,6 @@
|
|||||||
background: var(--bg-color);
|
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 */
|
/* Tab System Styling */
|
||||||
.showcase-tabs {
|
.showcase-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,254 +4,31 @@
|
|||||||
margin-top: var(--space-4);
|
margin-top: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main showcase container */
|
.carousel {
|
||||||
.showcase-container {
|
transition: max-height 0.3s ease-in-out;
|
||||||
display: flex;
|
|
||||||
height: 750px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-sm);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--lora-surface);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.showcase-container.empty {
|
.carousel.collapsed {
|
||||||
height: 400px;
|
max-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thumbnail Sidebar */
|
.carousel-container {
|
||||||
.thumbnail-sidebar {
|
|
||||||
width: 200px;
|
|
||||||
background: var(--bg-color);
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-grid {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
|
||||||
padding: var(--space-2);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thumbnail-grid::-webkit-scrollbar {
|
|
||||||
display: none; /* WebKit */
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-item {
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: var(--lora-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-item:hover {
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
transform: scale(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-item.active {
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
box-shadow: 0 0 0 1px var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-media {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-media.blurred {
|
|
||||||
filter: blur(8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-indicator {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
color: white;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.7em;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumbnail-nsfw-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Import Section */
|
|
||||||
.import-section {
|
|
||||||
padding: var(--space-2);
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
background: var(--bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-files-btn {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: var(--lora-text);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: var(--space-2);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-files-btn:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-drop-zone {
|
|
||||||
border: 2px dashed var(--border-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
padding: var(--space-2);
|
|
||||||
text-align: center;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
background: var(--lora-surface);
|
|
||||||
min-height: 60px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-drop-zone.highlight {
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-zone-content i {
|
|
||||||
font-size: 1.2em;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main Display Area */
|
|
||||||
.main-display-area {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
background: var(--card-bg);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-display-area.empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-color);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state i {
|
|
||||||
font-size: 3em;
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state h3 {
|
|
||||||
margin: 0 0 var(--space-1);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: var(--space-2);
|
|
||||||
right: var(--space-2);
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn.info-btn.active {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: var(--lora-text);
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-media-container {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-wrapper {
|
.media-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
overflow: hidden;
|
margin-bottom: var(--space-2);
|
||||||
|
overflow: hidden; /* Ensure metadata panel is contained */
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-wrapper:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-wrapper img,
|
.media-wrapper img,
|
||||||
@@ -264,11 +41,50 @@
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Media Controls for main display */
|
.no-examples {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust the media wrapper for tab system */
|
||||||
|
#showcase-tab .carousel-container {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add styles for blurred showcase content */
|
||||||
|
.nsfw-media-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-wrapper img.blurred,
|
||||||
|
.media-wrapper video.blurred {
|
||||||
|
filter: blur(25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-wrapper .nsfw-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Position the toggle button at the top left of showcase media */
|
||||||
|
.showcase-toggle-btn {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add styles for showcase media controls */
|
||||||
.media-controls {
|
.media-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--space-2);
|
|
||||||
left: var(--space-2);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
@@ -278,15 +94,15 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-wrapper:hover .media-controls {
|
.media-controls.visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-control-btn {
|
.media-control-btn {
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -319,11 +135,13 @@
|
|||||||
border-color: var(--lora-error);
|
border-color: var(--lora-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disabled state for delete button */
|
||||||
.media-control-btn.example-delete-btn.disabled {
|
.media-control-btn.example-delete-btn.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Two-step confirmation for delete button */
|
||||||
.media-control-btn.example-delete-btn .confirm-icon {
|
.media-control-btn.example-delete-btn .confirm-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -354,29 +172,16 @@
|
|||||||
border-color: var(--lora-error);
|
border-color: var(--lora-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toggle blur button for main display */
|
@keyframes pulse {
|
||||||
.showcase-toggle-btn {
|
0% {
|
||||||
position: absolute;
|
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7);
|
||||||
top: calc(var(--space-2) + 44px);
|
}
|
||||||
left: var(--space-2);
|
70% {
|
||||||
z-index: 3;
|
box-shadow: 0 0 0 5px rgba(220, 53, 69, 0);
|
||||||
width: 32px;
|
}
|
||||||
height: 32px;
|
100% {
|
||||||
border-radius: 50%;
|
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0);
|
||||||
background: var(--bg-color);
|
}
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-wrapper:hover .showcase-toggle-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image Metadata Panel Styles */
|
/* Image Metadata Panel Styles */
|
||||||
@@ -390,20 +195,22 @@
|
|||||||
padding: var(--space-2);
|
padding: var(--space-2);
|
||||||
transform: translateY(100%);
|
transform: translateY(100%);
|
||||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
|
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
|
||||||
z-index: 15;
|
z-index: 5;
|
||||||
max-height: 50%;
|
max-height: 50%; /* Reduced to take less space */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-metadata-panel.visible {
|
/* Show metadata panel only when the 'visible' class is added */
|
||||||
|
.media-wrapper .image-metadata-panel.visible {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
opacity: 0.98;
|
opacity: 0.98;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Adjust to dark theme */
|
||||||
[data-theme="dark"] .image-metadata-panel {
|
[data-theme="dark"] .image-metadata-panel {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||||
@@ -415,6 +222,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Styling for parameters tags */
|
||||||
.params-tags {
|
.params-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -447,6 +255,7 @@
|
|||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Special styling for prompt row */
|
||||||
.metadata-row.prompt-row {
|
.metadata-row.prompt-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
@@ -472,7 +281,7 @@
|
|||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 6px 30px 6px 8px;
|
padding: 6px 30px 6px 8px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
max-height: 80px;
|
max-height: 80px; /* Reduced from 120px */
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -504,6 +313,27 @@
|
|||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling for metadata panel */
|
||||||
|
.image-metadata-panel::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-metadata-panel::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-metadata-panel::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
.image-metadata-panel {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No metadata message styling */
|
||||||
.no-metadata-message {
|
.no-metadata-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -522,66 +352,31 @@
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling for metadata panel */
|
/* Scroll Indicator */
|
||||||
.image-metadata-panel::-webkit-scrollbar {
|
.scroll-indicator {
|
||||||
width: 6px;
|
cursor: pointer;
|
||||||
}
|
padding: var(--space-2);
|
||||||
|
background: var(--lora-surface);
|
||||||
.image-metadata-panel::-webkit-scrollbar-track {
|
border: 1px solid var(--lora-border);
|
||||||
background: transparent;
|
border-radius: var(--border-radius-sm);
|
||||||
}
|
|
||||||
|
|
||||||
.image-metadata-panel::-webkit-scrollbar-thumb {
|
|
||||||
background-color: var(--border-color);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-metadata-panel {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-color) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* NSFW Content Styles */
|
|
||||||
.media-wrapper img.blurred,
|
|
||||||
.media-wrapper video.blurred {
|
|
||||||
filter: blur(25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-wrapper .nsfw-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 2;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* NSFW Filter Notification */
|
|
||||||
.nsfw-filter-notification {
|
|
||||||
background: var(--lora-warning);
|
|
||||||
color: var(--lora-text);
|
|
||||||
padding: var(--space-2);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
transition: background-color 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-indicator:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-indicator span {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
|
||||||
|
|
||||||
/* No examples message */
|
|
||||||
.no-examples {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--space-4);
|
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lazy loading */
|
|
||||||
.lazy {
|
.lazy {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.3s;
|
transition: opacity 0.3s;
|
||||||
@@ -591,24 +386,93 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Example Import Area */
|
||||||
|
.example-import-area {
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
padding: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.example-import-area.empty {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
padding: var(--space-4) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-container {
|
||||||
|
border: 2px dashed var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-4);
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-container.highlight {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-placeholder i {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
/* color: var(--lora-accent); */
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-placeholder h3 {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-placeholder p {
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-placeholder .sub-text {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-formats {
|
||||||
|
font-size: 0.8em !important;
|
||||||
|
opacity: 0.6 !important;
|
||||||
|
margin-top: var(--space-2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-files-btn {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: var(--lora-text);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-files-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
/* For dark theme */
|
/* For dark theme */
|
||||||
[data-theme="dark"] .import-drop-zone {
|
[data-theme="dark"] .import-container {
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive design for smaller screens */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.thumbnail-sidebar {
|
|
||||||
width: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-controls {
|
|
||||||
top: var(--space-1);
|
|
||||||
right: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -56,6 +56,24 @@
|
|||||||
color: var(--lora-error);
|
color: var(--lora-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Update color scheme to include embeddings */
|
||||||
|
:root {
|
||||||
|
--embedding-color: oklch(68% 0.28 120); /* Green for embeddings */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update metric cards and chart colors to support embeddings */
|
||||||
|
.metric-card.embedding .metric-icon {
|
||||||
|
color: var(--embedding-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item.embedding {
|
||||||
|
border-left: 3px solid var(--embedding-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-item.embedding:hover {
|
||||||
|
border-color: var(--embedding-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Dashboard Content */
|
/* Dashboard Content */
|
||||||
.dashboard-content {
|
.dashboard-content {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
/* Import Components */
|
/* Import Components */
|
||||||
@import 'components/header.css';
|
@import 'components/header.css';
|
||||||
|
@import 'components/banner.css';
|
||||||
@import 'components/card.css';
|
@import 'components/card.css';
|
||||||
@import 'components/modal/_base.css';
|
@import 'components/modal/_base.css';
|
||||||
@import 'components/modal/delete-modal.css';
|
@import 'components/modal/delete-modal.css';
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const MODEL_CONFIG = {
|
|||||||
defaultPageSize: 100,
|
defaultPageSize: 100,
|
||||||
supportsLetterFilter: false,
|
supportsLetterFilter: false,
|
||||||
supportsBulkOperations: true,
|
supportsBulkOperations: true,
|
||||||
supportsMove: false,
|
supportsMove: true,
|
||||||
templateName: 'checkpoints.html'
|
templateName: 'checkpoints.html'
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
@@ -55,7 +55,7 @@ export function getApiEndpoints(modelType) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// Base CRUD operations
|
// Base CRUD operations
|
||||||
list: `/api/${modelType}`,
|
list: `/api/${modelType}/list`,
|
||||||
delete: `/api/${modelType}/delete`,
|
delete: `/api/${modelType}/delete`,
|
||||||
exclude: `/api/${modelType}/exclude`,
|
exclude: `/api/${modelType}/exclude`,
|
||||||
rename: `/api/${modelType}/rename`,
|
rename: `/api/${modelType}/rename`,
|
||||||
@@ -64,6 +64,10 @@ export function getApiEndpoints(modelType) {
|
|||||||
// Bulk operations
|
// Bulk operations
|
||||||
bulkDelete: `/api/${modelType}/bulk-delete`,
|
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
|
// CivitAI integration
|
||||||
fetchCivitai: `/api/${modelType}/fetch-civitai`,
|
fetchCivitai: `/api/${modelType}/fetch-civitai`,
|
||||||
fetchAllCivitai: `/api/${modelType}/fetch-all-civitai`,
|
fetchAllCivitai: `/api/${modelType}/fetch-all-civitai`,
|
||||||
@@ -99,14 +103,14 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
|||||||
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
previewUrl: `/api/${MODEL_TYPES.LORA}/preview-url`,
|
||||||
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
civitaiUrl: `/api/${MODEL_TYPES.LORA}/civitai-url`,
|
||||||
modelDescription: `/api/${MODEL_TYPES.LORA}/model-description`,
|
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`,
|
getTriggerWordsPost: `/api/${MODEL_TYPES.LORA}/get_trigger_words`,
|
||||||
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
civitaiModelByVersion: `/api/${MODEL_TYPES.LORA}/civitai/model/version`,
|
||||||
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
civitaiModelByHash: `/api/${MODEL_TYPES.LORA}/civitai/model/hash`,
|
||||||
},
|
},
|
||||||
[MODEL_TYPES.CHECKPOINT]: {
|
[MODEL_TYPES.CHECKPOINT]: {
|
||||||
info: `/api/${MODEL_TYPES.CHECKPOINT}/info`,
|
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]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ import {
|
|||||||
DOWNLOAD_ENDPOINTS,
|
DOWNLOAD_ENDPOINTS,
|
||||||
WS_ENDPOINTS
|
WS_ENDPOINTS
|
||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
|
import { resetAndReload } 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) {
|
constructor(modelType = null) {
|
||||||
|
if (this.constructor === BaseModelApiClient) {
|
||||||
|
throw new Error("BaseModelApiClient is abstract and cannot be instantiated directly");
|
||||||
|
}
|
||||||
this.modelType = modelType || getCurrentModelType();
|
this.modelType = modelType || getCurrentModelType();
|
||||||
this.apiConfig = getCompleteApiConfig(this.modelType);
|
this.apiConfig = getCompleteApiConfig(this.modelType);
|
||||||
}
|
}
|
||||||
@@ -42,9 +46,6 @@ class ModelApiClient {
|
|||||||
return pageState;
|
return pageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch models with pagination
|
|
||||||
*/
|
|
||||||
async fetchModelsPage(page = 1, pageSize = null) {
|
async fetchModelsPage(page = 1, pageSize = null) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
|
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) {
|
async loadMoreWithVirtualScroll(resetPage = false, updateFolders = false) {
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
@@ -93,26 +91,27 @@ class ModelApiClient {
|
|||||||
pageState.currentPage = 1; // Reset to first page
|
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 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(
|
state.virtualScroller.refreshWithData(
|
||||||
result.items,
|
result.items,
|
||||||
result.totalItems,
|
result.totalItems,
|
||||||
result.hasMore
|
result.hasMore
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update state
|
|
||||||
pageState.hasMore = result.hasMore;
|
pageState.hasMore = result.hasMore;
|
||||||
pageState.currentPage = pageState.currentPage + 1;
|
pageState.currentPage = pageState.currentPage + 1;
|
||||||
|
|
||||||
// Update folders if needed
|
if (updateFolders) {
|
||||||
if (updateFolders && result.folders) {
|
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||||
updateFolderTags(result.folders);
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
updateFolderTags(data.folders);
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMsg = errorData && errorData.error ? errorData.error : response.statusText;
|
||||||
|
console.error(`Error getting folders: ${errorMsg}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -126,9 +125,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a model
|
|
||||||
*/
|
|
||||||
async deleteModel(filePath) {
|
async deleteModel(filePath) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`);
|
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.singularName}...`);
|
||||||
@@ -163,9 +159,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exclude a model
|
|
||||||
*/
|
|
||||||
async excludeModel(filePath) {
|
async excludeModel(filePath) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`);
|
state.loadingManager.showSimpleLoading(`Excluding ${this.apiConfig.config.singularName}...`);
|
||||||
@@ -200,9 +193,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rename a model file
|
|
||||||
*/
|
|
||||||
async renameModelFile(filePath, newFileName) {
|
async renameModelFile(filePath, newFileName) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
|
||||||
@@ -239,9 +229,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace model preview
|
|
||||||
*/
|
|
||||||
replaceModelPreview(filePath) {
|
replaceModelPreview(filePath) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
@@ -257,9 +244,6 @@ class ModelApiClient {
|
|||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload preview image
|
|
||||||
*/
|
|
||||||
async uploadPreview(filePath, file, nsfwLevel = 0) {
|
async uploadPreview(filePath, file, nsfwLevel = 0) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Uploading preview...');
|
state.loadingManager.showSimpleLoading('Uploading preview...');
|
||||||
@@ -281,7 +265,6 @@ class ModelApiClient {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const pageState = this.getPageState();
|
const pageState = this.getPageState();
|
||||||
|
|
||||||
// Update the version timestamp
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
if (pageState.previewVersions) {
|
if (pageState.previewVersions) {
|
||||||
pageState.previewVersions.set(filePath, timestamp);
|
pageState.previewVersions.set(filePath, timestamp);
|
||||||
@@ -305,9 +288,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save model metadata
|
|
||||||
*/
|
|
||||||
async saveModelMetadata(filePath, data) {
|
async saveModelMetadata(filePath, data) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||||
@@ -332,9 +312,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh models (scan)
|
|
||||||
*/
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading(
|
state.loadingManager.showSimpleLoading(
|
||||||
@@ -350,6 +327,8 @@ class ModelApiClient {
|
|||||||
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetAndReload(true);
|
||||||
|
|
||||||
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
|
showToast(`${fullRebuild ? 'Full rebuild' : 'Refresh'} complete`, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Refresh failed:', error);
|
console.error('Refresh failed:', error);
|
||||||
@@ -360,9 +339,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch CivitAI metadata for single model
|
|
||||||
*/
|
|
||||||
async refreshSingleModelMetadata(filePath) {
|
async refreshSingleModelMetadata(filePath) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
||||||
@@ -399,9 +375,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch CivitAI metadata for all models
|
|
||||||
*/
|
|
||||||
async fetchCivitaiMetadata() {
|
async fetchCivitaiMetadata() {
|
||||||
let ws = null;
|
let ws = null;
|
||||||
|
|
||||||
@@ -477,9 +450,6 @@ class ModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch CivitAI metadata for multiple models with progress tracking
|
|
||||||
*/
|
|
||||||
async refreshBulkModelMetadata(filePaths) {
|
async refreshBulkModelMetadata(filePaths) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
@@ -493,7 +463,6 @@ class ModelApiClient {
|
|||||||
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
|
const progressController = state.loadingManager.showEnhancedProgress('Starting metadata refresh...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Process files sequentially to avoid overwhelming the API
|
|
||||||
for (let i = 0; i < filePaths.length; i++) {
|
for (let i = 0; i < filePaths.length; i++) {
|
||||||
const filePath = filePaths[i];
|
const filePath = filePaths[i];
|
||||||
const fileName = filePath.split('/').pop();
|
const fileName = filePath.split('/').pop();
|
||||||
@@ -535,7 +504,6 @@ class ModelApiClient {
|
|||||||
processedCount++;
|
processedCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show completion message
|
|
||||||
let completionMessage;
|
let completionMessage;
|
||||||
if (successCount === totalItems) {
|
if (successCount === totalItems) {
|
||||||
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
|
completionMessage = `Successfully refreshed all ${successCount} ${this.apiConfig.config.displayName}s`;
|
||||||
@@ -575,113 +543,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) {
|
async fetchCivitaiVersions(modelId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`);
|
const response = await fetch(`${this.apiConfig.endpoints.civitaiVersions}/${modelId}`);
|
||||||
@@ -699,9 +560,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch model roots
|
|
||||||
*/
|
|
||||||
async fetchModelRoots() {
|
async fetchModelRoots() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.apiConfig.endpoints.roots);
|
const response = await fetch(this.apiConfig.endpoints.roots);
|
||||||
@@ -715,9 +573,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch model folders
|
|
||||||
*/
|
|
||||||
async fetchModelFolders() {
|
async fetchModelFolders() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.apiConfig.endpoints.folders);
|
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||||
@@ -731,9 +586,6 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a model
|
|
||||||
*/
|
|
||||||
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
|
async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
||||||
@@ -759,13 +611,9 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build query parameters for API requests
|
|
||||||
*/
|
|
||||||
_buildQueryParams(baseParams, pageState) {
|
_buildQueryParams(baseParams, pageState) {
|
||||||
const params = new URLSearchParams(baseParams);
|
const params = new URLSearchParams(baseParams);
|
||||||
|
|
||||||
// Add common parameters
|
|
||||||
if (pageState.activeFolder !== null) {
|
if (pageState.activeFolder !== null) {
|
||||||
params.append('folder', pageState.activeFolder);
|
params.append('folder', pageState.activeFolder);
|
||||||
}
|
}
|
||||||
@@ -774,12 +622,10 @@ class ModelApiClient {
|
|||||||
params.append('favorites_only', 'true');
|
params.append('favorites_only', 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add letter filter for supported model types
|
|
||||||
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
|
||||||
params.append('first_letter', pageState.activeLetterFilter);
|
params.append('first_letter', pageState.activeLetterFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add search parameters
|
|
||||||
if (pageState.filters?.search) {
|
if (pageState.filters?.search) {
|
||||||
params.append('search', pageState.filters.search);
|
params.append('search', pageState.filters.search);
|
||||||
params.append('fuzzy', 'true');
|
params.append('fuzzy', 'true');
|
||||||
@@ -790,11 +636,13 @@ class ModelApiClient {
|
|||||||
if (pageState.searchOptions.tags !== undefined) {
|
if (pageState.searchOptions.tags !== undefined) {
|
||||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||||
}
|
}
|
||||||
|
if (pageState.searchOptions.creator !== undefined) {
|
||||||
|
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||||
|
}
|
||||||
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add filter parameters
|
|
||||||
if (pageState.filters) {
|
if (pageState.filters) {
|
||||||
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
if (pageState.filters.tags && pageState.filters.tags.length > 0) {
|
||||||
pageState.filters.tags.forEach(tag => {
|
pageState.filters.tags.forEach(tag => {
|
||||||
@@ -809,17 +657,12 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add model-specific parameters
|
|
||||||
this._addModelSpecificParams(params, pageState);
|
this._addModelSpecificParams(params, pageState);
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Add model-specific parameters to query
|
|
||||||
*/
|
|
||||||
_addModelSpecificParams(params, pageState) {
|
_addModelSpecificParams(params, pageState) {
|
||||||
// Override in specific implementations or handle via configuration
|
|
||||||
if (this.modelType === 'loras') {
|
if (this.modelType === 'loras') {
|
||||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
@@ -837,23 +680,149 @@ class ModelApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Export factory functions and utilities
|
async moveSingleModel(filePath, targetPath) {
|
||||||
export function createModelApiClient(modelType = null) {
|
// Only allow move if supported
|
||||||
return new ModelApiClient(modelType);
|
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() {
|
const result = await response.json();
|
||||||
if (!_singletonClient) {
|
|
||||||
_singletonClient = new ModelApiClient();
|
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) {
|
async moveBulkModels(filePaths, targetPath) {
|
||||||
return getModelApiClient().loadMoreWithVirtualScroll(true, updateFolders);
|
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 { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
|
||||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
export class CheckpointContextMenu extends BaseContextMenu {
|
export class CheckpointContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -54,8 +54,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
case 'move':
|
case 'move':
|
||||||
// Move to folder (placeholder)
|
moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.model_type);
|
||||||
showToast('Move to folder feature coming soon', 'info');
|
|
||||||
break;
|
break;
|
||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
|
|
||||||
export class EmbeddingContextMenu extends BaseContextMenu {
|
export class EmbeddingContextMenu extends BaseContextMenu {
|
||||||
@@ -54,8 +54,7 @@ export class EmbeddingContextMenu extends BaseContextMenu {
|
|||||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
case 'move':
|
case 'move':
|
||||||
// Move to folder (placeholder)
|
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||||
showToast('Move to folder feature coming soon', 'info');
|
|
||||||
break;
|
break;
|
||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
import { copyLoraSyntax, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
export class LoraContextMenu extends BaseContextMenu {
|
export class LoraContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -36,7 +37,7 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
break;
|
break;
|
||||||
case 'copyname':
|
case 'copyname':
|
||||||
// Generate and copy LoRA syntax
|
// Generate and copy LoRA syntax
|
||||||
this.copyLoraSyntax();
|
copyLoraSyntax(this.currentCard);
|
||||||
break;
|
break;
|
||||||
case 'sendappend':
|
case 'sendappend':
|
||||||
// Send LoRA to workflow (append mode)
|
// 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) {
|
sendLoraToWorkflow(replaceMode) {
|
||||||
const card = this.currentCard;
|
const card = this.currentCard;
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export class HeaderManager {
|
|||||||
this.filterManager = null;
|
this.filterManager = null;
|
||||||
|
|
||||||
// Initialize appropriate managers based on current page
|
// Initialize appropriate managers based on current page
|
||||||
this.initializeManagers();
|
if (this.currentPage !== 'statistics') {
|
||||||
|
this.initializeManagers();
|
||||||
|
}
|
||||||
|
|
||||||
// Set up common header functionality
|
// Set up common header functionality
|
||||||
this.initializeCommonElements();
|
this.initializeCommonElements();
|
||||||
@@ -37,11 +39,8 @@ export class HeaderManager {
|
|||||||
this.searchManager = new SearchManager({ page: this.currentPage });
|
this.searchManager = new SearchManager({ page: this.currentPage });
|
||||||
window.searchManager = this.searchManager;
|
window.searchManager = this.searchManager;
|
||||||
|
|
||||||
// Initialize FilterManager for all page types that have filters
|
this.filterManager = new FilterManager({ page: this.currentPage });
|
||||||
if (document.getElementById('filterButton')) {
|
window.filterManager = this.filterManager;
|
||||||
this.filterManager = new FilterManager({ page: this.currentPage });
|
|
||||||
window.filterManager = this.filterManager;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeCommonElements() {
|
initializeCommonElements() {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { formatDate } from '../utils/formatters.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';
|
import { LoadingManager } from '../managers/LoadingManager.js';
|
||||||
|
|
||||||
export class ModelDuplicatesManager {
|
export class ModelDuplicatesManager {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// AlphabetBar.js - Component for alphabet filtering
|
// AlphabetBar.js - Component for alphabet filtering
|
||||||
import { getCurrentPageState } from '../../state/index.js';
|
import { getCurrentPageState } from '../../state/index.js';
|
||||||
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.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
|
* AlphabetBar class - Handles the alphabet filtering UI and interactions
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// CheckpointsControls.js - Specific implementation for the Checkpoints page
|
// CheckpointsControls.js - Specific implementation for the Checkpoints page
|
||||||
import { PageControls } from './PageControls.js';
|
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 { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// EmbeddingsControls.js - Specific implementation for the Embeddings page
|
// EmbeddingsControls.js - Specific implementation for the Embeddings page
|
||||||
import { PageControls } from './PageControls.js';
|
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 { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// LorasControls.js - Specific implementation for the LoRAs page
|
// LorasControls.js - Specific implementation for the LoRAs page
|
||||||
import { PageControls } from './PageControls.js';
|
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 { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { createAlphabetBar } from '../alphabet/index.js';
|
import { createAlphabetBar } from '../alphabet/index.js';
|
||||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
|||||||
@@ -1,10 +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 { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
|
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { NSFW_LEVELS } from '../../utils/constants.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';
|
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
|
|
||||||
// Add global event delegation handlers
|
// Add global event delegation handlers
|
||||||
@@ -151,7 +153,7 @@ async function toggleFavorite(card) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSendToWorkflow(card, replaceMode, modelType) {
|
function handleSendToWorkflow(card, replaceMode, modelType) {
|
||||||
if (modelType === 'loras') {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
const strength = usageTips.strength || 1;
|
const strength = usageTips.strength || 1;
|
||||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
||||||
@@ -163,16 +165,13 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleCopyAction(card, modelType) {
|
function handleCopyAction(card, modelType) {
|
||||||
if (modelType === 'loras') {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
copyLoraSyntax(card);
|
||||||
const strength = usageTips.strength || 1;
|
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||||
const loraSyntax = `<lora:${card.dataset.file_name}:${strength}>`;
|
|
||||||
copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard');
|
|
||||||
} else if (modelType === 'checkpoints') {
|
|
||||||
// Checkpoint copy functionality - copy checkpoint name
|
// Checkpoint copy functionality - copy checkpoint name
|
||||||
const checkpointName = card.dataset.file_name;
|
const checkpointName = card.dataset.file_name;
|
||||||
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
copyToClipboard(checkpointName, 'Checkpoint name copied');
|
||||||
} else if (modelType === 'embeddings') {
|
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||||
const embeddingName = card.dataset.file_name;
|
const embeddingName = card.dataset.file_name;
|
||||||
copyToClipboard(embeddingName, 'Embedding name copied');
|
copyToClipboard(embeddingName, 'Embedding name copied');
|
||||||
}
|
}
|
||||||
@@ -242,7 +241,7 @@ function showModelModalFromCard(card, modelType) {
|
|||||||
tags: JSON.parse(card.dataset.tags || '[]'),
|
tags: JSON.parse(card.dataset.tags || '[]'),
|
||||||
modelDescription: card.dataset.modelDescription || '',
|
modelDescription: card.dataset.modelDescription || '',
|
||||||
// LoRA specific fields
|
// LoRA specific fields
|
||||||
...(modelType === 'lora' && {
|
...(modelType === MODEL_TYPES.LORA && {
|
||||||
usage_tips: card.dataset.usage_tips,
|
usage_tips: card.dataset.usage_tips,
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@@ -339,6 +338,15 @@ function showExampleAccessModal(card, modelType) {
|
|||||||
tabBtn.click();
|
tabBtn.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Then toggle showcase if collapsed
|
||||||
|
const carousel = showcaseTab.querySelector('.carousel');
|
||||||
|
if (carousel && carousel.classList.contains('collapsed')) {
|
||||||
|
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
|
||||||
|
if (scrollIndicator) {
|
||||||
|
toggleShowcase(scrollIndicator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Finally scroll to the import area
|
// Finally scroll to the import area
|
||||||
importArea.scrollIntoView({ behavior: 'smooth' });
|
importArea.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
@@ -367,10 +375,15 @@ export function createModelCard(model, modelType) {
|
|||||||
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
card.dataset.favorite = model.favorite ? 'true' : 'false';
|
||||||
|
|
||||||
// LoRA specific data
|
// LoRA specific data
|
||||||
if (modelType === 'loras') {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
card.dataset.usage_tips = model.usage_tips;
|
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
|
// Store metadata if available
|
||||||
if (model.civitai) {
|
if (model.civitai) {
|
||||||
card.dataset.meta = JSON.stringify(model.civitai || {});
|
card.dataset.meta = JSON.stringify(model.civitai || {});
|
||||||
@@ -396,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)
|
// 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');
|
card.classList.add('selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +457,7 @@ export function createModelCard(model, modelType) {
|
|||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${isVideo ?
|
${isVideo ?
|
||||||
`<video ${videoAttrs}>
|
`<video ${videoAttrs} style="pointer-events: none;">
|
||||||
<source src="${versionedPreviewUrl}" type="video/mp4">
|
<source src="${versionedPreviewUrl}" type="video/mp4">
|
||||||
</video>` :
|
</video>` :
|
||||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
|
||||||
@@ -472,6 +485,7 @@ export function createModelCard(model, modelType) {
|
|||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="model-info">
|
<div class="model-info">
|
||||||
<span class="model-name">${model.model_name}</span>
|
<span class="model-name">${model.model_name}</span>
|
||||||
|
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<i class="fas fa-folder-open"
|
<i class="fas fa-folder-open"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ModelDescription.js
|
* ModelDescription.js
|
||||||
* Handles model description related functionality - General version
|
* Handles model description related functionality - General version
|
||||||
@@ -41,3 +43,98 @@ 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 { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { BASE_MODELS } from '../../utils/constants.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
|
* Set up model name editing functionality
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import {
|
import {
|
||||||
|
toggleShowcase,
|
||||||
|
setupShowcaseScroll,
|
||||||
|
scrollToTop,
|
||||||
loadExampleImages
|
loadExampleImages
|
||||||
} from './showcase/ShowcaseView.js';
|
} from './showcase/ShowcaseView.js';
|
||||||
import { setupTabSwitching } from './ModelDescription.js';
|
import { setupTabSwitching, setupModelDescriptionEditing } from './ModelDescription.js';
|
||||||
import {
|
import {
|
||||||
setupModelNameEditing,
|
setupModelNameEditing,
|
||||||
setupBaseModelEditing,
|
setupBaseModelEditing,
|
||||||
setupFileNameEditing
|
setupFileNameEditing
|
||||||
} from './ModelMetadata.js';
|
} from './ModelMetadata.js';
|
||||||
import { setupTagEditMode } from './ModelTags.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 { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||||
@@ -180,12 +183,17 @@ export function showModelModal(model, modelType) {
|
|||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
${tabPanesContent}
|
${tabPanesContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="back-to-top" data-action="scroll-to-top">
|
||||||
|
<i class="fas fa-arrow-up"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const onCloseCallback = function() {
|
const onCloseCallback = function() {
|
||||||
|
// Clean up all handlers when modal closes for LoRA
|
||||||
const modalElement = document.getElementById(modalId);
|
const modalElement = document.getElementById(modalId);
|
||||||
if (modalElement && modalElement._clickHandler) {
|
if (modalElement && modalElement._clickHandler) {
|
||||||
modalElement.removeEventListener('click', modalElement._clickHandler);
|
modalElement.removeEventListener('click', modalElement._clickHandler);
|
||||||
@@ -195,12 +203,14 @@ export function showModelModal(model, modelType) {
|
|||||||
|
|
||||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||||
setupEditableFields(model.file_path, modelType);
|
setupEditableFields(model.file_path, modelType);
|
||||||
|
setupShowcaseScroll(modalId);
|
||||||
setupTabSwitching();
|
setupTabSwitching();
|
||||||
setupTagTooltip();
|
setupTagTooltip();
|
||||||
setupTagEditMode();
|
setupTagEditMode();
|
||||||
setupModelNameEditing(model.file_path);
|
setupModelNameEditing(model.file_path);
|
||||||
setupBaseModelEditing(model.file_path);
|
setupBaseModelEditing(model.file_path);
|
||||||
setupFileNameEditing(model.file_path);
|
setupFileNameEditing(model.file_path);
|
||||||
|
setupModelDescriptionEditing(model.file_path, model.modelDescription || '');
|
||||||
setupEventHandlers(model.file_path);
|
setupEventHandlers(model.file_path);
|
||||||
|
|
||||||
// LoRA specific setup
|
// LoRA specific setup
|
||||||
@@ -213,9 +223,10 @@ export function showModelModal(model, modelType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load example images asynchronously
|
// Load example images asynchronously - merge regular and custom images
|
||||||
const regularImages = model.civitai?.images || [];
|
const regularImages = model.civitai?.images || [];
|
||||||
const customImages = model.civitai?.customImages || [];
|
const customImages = model.civitai?.customImages || [];
|
||||||
|
// Combine images - regular images first, then custom images
|
||||||
const allImages = [...regularImages, ...customImages];
|
const allImages = [...regularImages, ...customImages];
|
||||||
loadExampleImages(allImages, model.sha256);
|
loadExampleImages(allImages, model.sha256);
|
||||||
}
|
}
|
||||||
@@ -250,14 +261,16 @@ function renderEmbeddingSpecificContent(embedding, escapedWords) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up event handlers using event delegation for modal
|
* Sets up event handlers using event delegation for LoRA modal
|
||||||
* @param {string} filePath - Path to the model file
|
* @param {string} filePath - Path to the model file
|
||||||
*/
|
*/
|
||||||
function setupEventHandlers(filePath) {
|
function setupEventHandlers(filePath) {
|
||||||
const modalElement = document.getElementById('modelModal');
|
const modalElement = document.getElementById('modelModal');
|
||||||
|
|
||||||
|
// Remove existing event listeners first
|
||||||
modalElement.removeEventListener('click', handleModalClick);
|
modalElement.removeEventListener('click', handleModalClick);
|
||||||
|
|
||||||
|
// Create and store the handler function
|
||||||
function handleModalClick(event) {
|
function handleModalClick(event) {
|
||||||
const target = event.target.closest('[data-action]');
|
const target = event.target.closest('[data-action]');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -268,6 +281,9 @@ function setupEventHandlers(filePath) {
|
|||||||
case 'close-modal':
|
case 'close-modal':
|
||||||
modalManager.closeModal('modelModal');
|
modalManager.closeModal('modelModal');
|
||||||
break;
|
break;
|
||||||
|
case 'scroll-to-top':
|
||||||
|
scrollToTop(target);
|
||||||
|
break;
|
||||||
case 'view-civitai':
|
case 'view-civitai':
|
||||||
openCivitai(target.dataset.filepath);
|
openCivitai(target.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
@@ -280,7 +296,10 @@ function setupEventHandlers(filePath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the event listener with the named function
|
||||||
modalElement.addEventListener('click', handleModalClick);
|
modalElement.addEventListener('click', handleModalClick);
|
||||||
|
|
||||||
|
// Store reference to the handler on the element for potential cleanup
|
||||||
modalElement._clickHandler = handleModalClick;
|
modalElement._clickHandler = handleModalClick;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,7 +421,9 @@ async function saveNotes(filePath) {
|
|||||||
|
|
||||||
// Export the model modal API
|
// Export the model modal API
|
||||||
const modelModal = {
|
const modelModal = {
|
||||||
show: showModelModal
|
show: showModelModal,
|
||||||
|
toggleShowcase,
|
||||||
|
scrollToTop
|
||||||
};
|
};
|
||||||
|
|
||||||
export { modelModal };
|
export { modelModal };
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* Module for handling model tag editing functionality - 共享版本
|
* Module for handling model tag editing functionality - 共享版本
|
||||||
*/
|
*/
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
// Preset tag suggestions
|
// Preset tag suggestions
|
||||||
const PRESET_TAGS = [
|
const PRESET_TAGS = [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* PresetTags.js
|
* PresetTags.js
|
||||||
* Handles LoRA model preset parameter tags - Shared version
|
* Handles LoRA model preset parameter tags - Shared version
|
||||||
*/
|
*/
|
||||||
import { getModelApiClient } from '../../api/baseModelApi.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse preset parameters
|
* Parse preset parameters
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Moved to shared directory for consistency
|
* Moved to shared directory for consistency
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
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
|
* Fetch trained words for a model
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
|
||||||
import { state } from '../../../state/index.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
|
* Try to load local image first, fall back to remote if local fails
|
||||||
@@ -182,46 +182,119 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
|
|||||||
* @param {HTMLElement} container - Container element with media wrappers
|
* @param {HTMLElement} container - Container element with media wrappers
|
||||||
*/
|
*/
|
||||||
export function initMetadataPanelHandlers(container) {
|
export function initMetadataPanelHandlers(container) {
|
||||||
// Metadata panel interaction is now handled by the info button
|
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||||
// Keep the existing copy functionality but remove hover-based visibility
|
|
||||||
const metadataPanel = container.querySelector('.image-metadata-panel');
|
|
||||||
|
|
||||||
if (metadataPanel) {
|
mediaWrappers.forEach(wrapper => {
|
||||||
// Prevent events from bubbling
|
// Get the metadata panel and media element (img or video)
|
||||||
metadataPanel.addEventListener('click', (e) => {
|
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||||
e.stopPropagation();
|
const mediaControls = wrapper.querySelector('.media-controls');
|
||||||
|
const mediaElement = wrapper.querySelector('img, video');
|
||||||
|
|
||||||
|
if (!mediaElement) return;
|
||||||
|
|
||||||
|
let isOverMetadataPanel = false;
|
||||||
|
|
||||||
|
// Add event listeners to the wrapper for mouse tracking
|
||||||
|
wrapper.addEventListener('mousemove', (e) => {
|
||||||
|
// Get mouse position relative to wrapper
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
const mouseX = e.clientX - rect.left;
|
||||||
|
const mouseY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// Get the actual displayed dimensions of the media element
|
||||||
|
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||||
|
|
||||||
|
// Check if mouse is over the actual media content
|
||||||
|
const isOverMedia = (
|
||||||
|
mouseX >= mediaRect.left &&
|
||||||
|
mouseX <= mediaRect.right &&
|
||||||
|
mouseY >= mediaRect.top &&
|
||||||
|
mouseY <= mediaRect.bottom
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show metadata panel and controls when over media content or metadata panel itself
|
||||||
|
if (isOverMedia || isOverMetadataPanel) {
|
||||||
|
if (metadataPanel) metadataPanel.classList.add('visible');
|
||||||
|
if (mediaControls) mediaControls.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
if (metadataPanel) metadataPanel.classList.remove('visible');
|
||||||
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle copy prompt buttons
|
wrapper.addEventListener('mouseleave', () => {
|
||||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
if (!isOverMetadataPanel) {
|
||||||
copyBtns.forEach(copyBtn => {
|
if (metadataPanel) metadataPanel.classList.remove('visible');
|
||||||
const promptIndex = copyBtn.dataset.promptIndex;
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async (e) => {
|
// Add mouse enter/leave events for the metadata panel itself
|
||||||
e.stopPropagation();
|
if (metadataPanel) {
|
||||||
|
metadataPanel.addEventListener('mouseenter', () => {
|
||||||
|
isOverMetadataPanel = true;
|
||||||
|
metadataPanel.classList.add('visible');
|
||||||
|
if (mediaControls) mediaControls.classList.add('visible');
|
||||||
|
});
|
||||||
|
|
||||||
if (!promptElement) return;
|
metadataPanel.addEventListener('mouseleave', () => {
|
||||||
|
isOverMetadataPanel = false;
|
||||||
|
// Only hide if mouse is not over the media
|
||||||
|
const rect = wrapper.getBoundingClientRect();
|
||||||
|
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||||
|
const mouseX = event.clientX - rect.left;
|
||||||
|
const mouseY = event.clientY - rect.top;
|
||||||
|
|
||||||
try {
|
const isOverMedia = (
|
||||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
mouseX >= mediaRect.left &&
|
||||||
} catch (err) {
|
mouseX <= mediaRect.right &&
|
||||||
console.error('Copy failed:', err);
|
mouseY >= mediaRect.top &&
|
||||||
showToast('Copy failed', 'error');
|
mouseY <= mediaRect.bottom
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isOverMedia) {
|
||||||
|
metadataPanel.classList.remove('visible');
|
||||||
|
if (mediaControls) mediaControls.classList.remove('visible');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent panel scroll from causing modal scroll
|
// Prevent events from bubbling
|
||||||
metadataPanel.addEventListener('wheel', (e) => {
|
metadataPanel.addEventListener('click', (e) => {
|
||||||
const isAtTop = metadataPanel.scrollTop === 0;
|
|
||||||
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
|
||||||
|
|
||||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
});
|
||||||
}, { passive: true });
|
|
||||||
}
|
// Handle copy prompt buttons
|
||||||
|
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||||
|
copyBtns.forEach(copyBtn => {
|
||||||
|
const promptIndex = copyBtn.dataset.promptIndex;
|
||||||
|
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!promptElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
showToast('Copy failed', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent panel scroll from causing modal scroll
|
||||||
|
metadataPanel.addEventListener('wheel', (e) => {
|
||||||
|
const isAtTop = metadataPanel.scrollTop === 0;
|
||||||
|
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
||||||
|
|
||||||
|
// Only prevent default if scrolling would cause the panel to scroll
|
||||||
|
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -293,8 +366,9 @@ export function initMediaControlHandlers(container) {
|
|||||||
btn.addEventListener('click', async function(e) {
|
btn.addEventListener('click', async function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Explicitly check for disabled state
|
||||||
if (this.classList.contains('disabled')) {
|
if (this.classList.contains('disabled')) {
|
||||||
return;
|
return; // Don't do anything if button is disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortId = this.dataset.shortId;
|
const shortId = this.dataset.shortId;
|
||||||
@@ -302,11 +376,14 @@ export function initMediaControlHandlers(container) {
|
|||||||
|
|
||||||
if (!shortId) return;
|
if (!shortId) return;
|
||||||
|
|
||||||
|
// Handle two-step confirmation
|
||||||
if (btnState === 'initial') {
|
if (btnState === 'initial') {
|
||||||
|
// First click: show confirmation state
|
||||||
this.dataset.state = 'confirm';
|
this.dataset.state = 'confirm';
|
||||||
this.classList.add('confirm');
|
this.classList.add('confirm');
|
||||||
this.title = 'Click again to confirm deletion';
|
this.title = 'Click again to confirm deletion';
|
||||||
|
|
||||||
|
// Auto-reset after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.dataset.state === 'confirm') {
|
if (this.dataset.state === 'confirm') {
|
||||||
this.dataset.state = 'initial';
|
this.dataset.state = 'initial';
|
||||||
@@ -318,16 +395,19 @@ export function initMediaControlHandlers(container) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Second click within 3 seconds: proceed with deletion
|
||||||
if (btnState === 'confirm') {
|
if (btnState === 'confirm') {
|
||||||
this.disabled = true;
|
this.disabled = true;
|
||||||
this.classList.remove('confirm');
|
this.classList.remove('confirm');
|
||||||
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||||
|
|
||||||
|
// Get model hash from URL or data attribute
|
||||||
const mediaWrapper = this.closest('.media-wrapper');
|
const mediaWrapper = this.closest('.media-wrapper');
|
||||||
const modelHashAttr = document.querySelector('.showcase-section')?.dataset;
|
const modelHashAttr = document.querySelector('.showcase-section')?.dataset;
|
||||||
const modelHash = modelHashAttr?.modelHash;
|
const modelHash = modelHashAttr?.modelHash;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Call the API to delete the custom example
|
||||||
const response = await fetch('/api/delete-example-image', {
|
const response = await fetch('/api/delete-example-image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -342,45 +422,32 @@ export function initMediaControlHandlers(container) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Remove the corresponding thumbnail and update main display
|
// Success: remove the media wrapper from the DOM
|
||||||
const thumbnailItem = container.querySelector(`.thumbnail-item[data-short-id="${shortId}"]`);
|
mediaWrapper.style.opacity = '0';
|
||||||
if (thumbnailItem) {
|
mediaWrapper.style.height = '0';
|
||||||
const wasActive = thumbnailItem.classList.contains('active');
|
mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s';
|
||||||
thumbnailItem.remove();
|
|
||||||
|
|
||||||
// If the deleted item was active, select next item
|
setTimeout(() => {
|
||||||
if (wasActive) {
|
mediaWrapper.remove();
|
||||||
const remainingThumbnails = container.querySelectorAll('.thumbnail-item');
|
}, 600);
|
||||||
if (remainingThumbnails.length > 0) {
|
|
||||||
remainingThumbnails[0].click();
|
|
||||||
} else {
|
|
||||||
// No more items, show empty state
|
|
||||||
const mainContainer = container.querySelector('#mainMediaContainer');
|
|
||||||
if (mainContainer) {
|
|
||||||
mainContainer.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="fas fa-images"></i>
|
|
||||||
<h3>No example images available</h3>
|
|
||||||
<p>Import images or videos using the sidebar</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
showToast('Example image deleted', 'success');
|
showToast('Example image deleted', 'success');
|
||||||
|
|
||||||
|
// Create an update object with only the necessary properties
|
||||||
const updateData = {
|
const updateData = {
|
||||||
civitai: {
|
civitai: {
|
||||||
customImages: result.custom_images || []
|
customImages: result.custom_images || []
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update the item in the virtual scroller
|
||||||
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
||||||
} else {
|
} else {
|
||||||
|
// Show error message
|
||||||
showToast(result.error || 'Failed to delete example image', 'error');
|
showToast(result.error || 'Failed to delete example image', 'error');
|
||||||
|
|
||||||
|
// Reset button state
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
this.dataset.state = 'initial';
|
this.dataset.state = 'initial';
|
||||||
this.classList.remove('confirm');
|
this.classList.remove('confirm');
|
||||||
@@ -391,6 +458,7 @@ export function initMediaControlHandlers(container) {
|
|||||||
console.error('Error deleting example image:', error);
|
console.error('Error deleting example image:', error);
|
||||||
showToast('Failed to delete example image', 'error');
|
showToast('Failed to delete example image', 'error');
|
||||||
|
|
||||||
|
// Reset button state
|
||||||
this.disabled = false;
|
this.disabled = false;
|
||||||
this.dataset.state = 'initial';
|
this.dataset.state = 'initial';
|
||||||
this.classList.remove('confirm');
|
this.classList.remove('confirm');
|
||||||
@@ -401,7 +469,11 @@ export function initMediaControlHandlers(container) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize set preview buttons
|
||||||
initSetPreviewHandlers(container);
|
initSetPreviewHandlers(container);
|
||||||
|
|
||||||
|
// Media control visibility is now handled in initMetadataPanelHandlers
|
||||||
|
// Any click handlers or other functionality can still be added here
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -473,3 +545,49 @@ function initSetPreviewHandlers(container) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position media controls within the actual rendered media rectangle
|
||||||
|
* @param {HTMLElement} mediaWrapper - The wrapper containing the media and controls
|
||||||
|
*/
|
||||||
|
export function positionMediaControlsInMediaRect(mediaWrapper) {
|
||||||
|
const mediaElement = mediaWrapper.querySelector('img, video');
|
||||||
|
const controlsElement = mediaWrapper.querySelector('.media-controls');
|
||||||
|
|
||||||
|
if (!mediaElement || !controlsElement) return;
|
||||||
|
|
||||||
|
// Get wrapper dimensions
|
||||||
|
const wrapperRect = mediaWrapper.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate the actual rendered media rectangle
|
||||||
|
const mediaRect = getRenderedMediaRect(
|
||||||
|
mediaElement,
|
||||||
|
wrapperRect.width,
|
||||||
|
wrapperRect.height
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate the position for controls - place them inside the actual media area
|
||||||
|
const padding = 8; // Padding from the edge of the media
|
||||||
|
|
||||||
|
// Position at top-right inside the actual media rectangle
|
||||||
|
controlsElement.style.top = `${mediaRect.top + padding}px`;
|
||||||
|
controlsElement.style.right = `${wrapperRect.width - mediaRect.right + padding}px`;
|
||||||
|
|
||||||
|
// Also position any toggle blur buttons in the same way but on the left
|
||||||
|
const toggleBlurBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
||||||
|
if (toggleBlurBtn) {
|
||||||
|
toggleBlurBtn.style.top = `${mediaRect.top + padding}px`;
|
||||||
|
toggleBlurBtn.style.left = `${mediaRect.left + padding}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position all media controls in a container
|
||||||
|
* @param {HTMLElement} container - Container with media wrappers
|
||||||
|
*/
|
||||||
|
export function positionAllMediaControls(container) {
|
||||||
|
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||||
|
mediaWrappers.forEach(wrapper => {
|
||||||
|
positionMediaControlsInMediaRect(wrapper);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,7 +23,6 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
|||||||
const promptIndex = Math.random().toString(36).substring(2, 15);
|
const promptIndex = Math.random().toString(36).substring(2, 15);
|
||||||
const negPromptIndex = Math.random().toString(36).substring(2, 15);
|
const negPromptIndex = Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
// Note: Panel visibility is now controlled by the info button, not hover
|
|
||||||
let content = '<div class="image-metadata-panel"><div class="metadata-content">';
|
let content = '<div class="image-metadata-panel"><div class="metadata-content">';
|
||||||
|
|
||||||
if (hasParams) {
|
if (hasParams) {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
initLazyLoading,
|
initLazyLoading,
|
||||||
initNsfwBlurHandlers,
|
initNsfwBlurHandlers,
|
||||||
initMetadataPanelHandlers,
|
initMetadataPanelHandlers,
|
||||||
initMediaControlHandlers
|
initMediaControlHandlers,
|
||||||
|
positionAllMediaControls
|
||||||
} from './MediaUtils.js';
|
} from './MediaUtils.js';
|
||||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||||
@@ -45,10 +46,13 @@ export async function loadExampleImages(images, modelHash) {
|
|||||||
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
|
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
|
||||||
|
|
||||||
// Re-initialize the showcase event listeners
|
// Re-initialize the showcase event listeners
|
||||||
initShowcaseContent(showcaseTab);
|
const carousel = showcaseTab.querySelector('.carousel');
|
||||||
|
if (carousel && !carousel.classList.contains('collapsed')) {
|
||||||
|
initShowcaseContent(carousel);
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the example import functionality
|
// Initialize the example import functionality
|
||||||
// initExampleImport(modelHash, showcaseTab);
|
initExampleImport(modelHash, showcaseTab);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading example images:', error);
|
console.error('Error loading example images:', error);
|
||||||
const showcaseTab = document.getElementById('showcase-tab');
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
@@ -67,13 +71,13 @@ export async function loadExampleImages(images, modelHash) {
|
|||||||
* Render showcase content
|
* Render showcase content
|
||||||
* @param {Array} images - Array of images/videos to show
|
* @param {Array} images - Array of images/videos to show
|
||||||
* @param {Array} exampleFiles - Local example files
|
* @param {Array} exampleFiles - Local example files
|
||||||
* @param {boolean} startExpanded - Whether to start in expanded state (unused in new design)
|
* @param {boolean} startExpanded - Whether to start in expanded state
|
||||||
* @returns {string} HTML content
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
|
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
|
||||||
if (!images?.length) {
|
if (!images?.length) {
|
||||||
// Show empty state with import interface
|
// Show empty state with import interface
|
||||||
return renderEmptyShowcase();
|
return renderImportInterface(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter images based on SFW setting
|
// Filter images based on SFW setting
|
||||||
@@ -108,69 +112,29 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
|
|||||||
</div>` : '';
|
</div>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
${hiddenNotification}
|
<div class="scroll-indicator">
|
||||||
<div class="showcase-container">
|
<i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i>
|
||||||
<div class="thumbnail-sidebar" id="thumbnailSidebar">
|
<span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span>
|
||||||
<div class="thumbnail-grid">
|
</div>
|
||||||
${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')}
|
<div class="carousel ${startExpanded ? '' : 'collapsed'}">
|
||||||
</div>
|
${hiddenNotification}
|
||||||
${renderImportInterface()}
|
<div class="carousel-container">
|
||||||
</div>
|
${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')}
|
||||||
<div class="main-display-area">
|
|
||||||
<div class="navigation-controls">
|
|
||||||
<button class="nav-btn prev-btn" id="prevBtn" title="Previous (←)">
|
|
||||||
<i class="fas fa-chevron-left"></i>
|
|
||||||
</button>
|
|
||||||
<button class="nav-btn next-btn" id="nextBtn" title="Next (→)">
|
|
||||||
<i class="fas fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
<button class="nav-btn info-btn" id="infoBtn" title="Show/Hide Info (i)">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="main-media-container" id="mainMediaContainer">
|
|
||||||
${filteredImages.length > 0 ? renderMainMediaItem(filteredImages[0], 0, exampleFiles) : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${renderImportInterface(false)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the matching local file for an image
|
* Render a single media item (image or video)
|
||||||
* @param {Object} img - Image metadata
|
|
||||||
* @param {number} index - Image index
|
|
||||||
* @param {Array} exampleFiles - Array of local files
|
|
||||||
* @returns {Object|null} Matching local file or null
|
|
||||||
*/
|
|
||||||
function findLocalFile(img, index, exampleFiles) {
|
|
||||||
if (!exampleFiles || exampleFiles.length === 0) return null;
|
|
||||||
|
|
||||||
let localFile = null;
|
|
||||||
|
|
||||||
if (img.id) {
|
|
||||||
// This is a custom image, find by custom_<id>
|
|
||||||
const customPrefix = `custom_${img.id}`;
|
|
||||||
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
|
|
||||||
} else {
|
|
||||||
// This is a regular image from civitai, find by index
|
|
||||||
localFile = exampleFiles.find(file => {
|
|
||||||
const match = file.name.match(/image_(\d+)\./);
|
|
||||||
return match && parseInt(match[1]) === index;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return localFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a thumbnail for the sidebar
|
|
||||||
* @param {Object} img - Image/video metadata
|
* @param {Object} img - Image/video metadata
|
||||||
* @param {number} index - Index in the array
|
* @param {number} index - Index in the array
|
||||||
* @param {Array} exampleFiles - Local files
|
* @param {Array} exampleFiles - Local files
|
||||||
* @returns {string} HTML for the thumbnail
|
* @returns {string} HTML for the media item
|
||||||
*/
|
*/
|
||||||
function renderThumbnail(img, index, exampleFiles) {
|
function renderMediaItem(img, index, exampleFiles) {
|
||||||
// Find matching file in our list of actual files
|
// Find matching file in our list of actual files
|
||||||
let localFile = findLocalFile(img, index, exampleFiles);
|
let localFile = findLocalFile(img, index, exampleFiles);
|
||||||
|
|
||||||
@@ -179,57 +143,15 @@ function renderThumbnail(img, index, exampleFiles) {
|
|||||||
const isVideo = localFile ? localFile.is_video :
|
const isVideo = localFile ? localFile.is_video :
|
||||||
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
|
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
|
||||||
|
|
||||||
// Check if media should be blurred
|
// Calculate appropriate aspect ratio
|
||||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
const aspectRatio = (img.height / img.width) * 100;
|
||||||
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
|
const containerWidth = 800; // modal content maximum width
|
||||||
|
const minHeightPercent = 40;
|
||||||
return `
|
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||||
<div class="thumbnail-item ${index === 0 ? 'active' : ''}"
|
const heightPercent = Math.max(
|
||||||
data-index="${index}"
|
minHeightPercent,
|
||||||
data-nsfw-level="${nsfwLevel}"
|
Math.min(maxHeightPercent, aspectRatio)
|
||||||
data-short-id="${img.id || ''}">
|
);
|
||||||
${isVideo ? `
|
|
||||||
<video class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
|
|
||||||
data-local-src="${localUrl || ''}"
|
|
||||||
data-remote-src="${remoteUrl}"
|
|
||||||
muted>
|
|
||||||
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
|
|
||||||
</video>
|
|
||||||
<div class="video-indicator">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<img class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
|
|
||||||
data-local-src="${localUrl || ''}"
|
|
||||||
data-remote-src="${remoteUrl}"
|
|
||||||
alt="Thumbnail"
|
|
||||||
width="${img.width}"
|
|
||||||
height="${img.height}">
|
|
||||||
`}
|
|
||||||
${shouldBlur ? `
|
|
||||||
<div class="thumbnail-nsfw-overlay">
|
|
||||||
<i class="fas fa-eye-slash"></i>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render the main media item in the display area
|
|
||||||
* @param {Object} img - Image/video metadata
|
|
||||||
* @param {number} index - Index in the array
|
|
||||||
* @param {Array} exampleFiles - Local files
|
|
||||||
* @returns {string} HTML for the main media item
|
|
||||||
*/
|
|
||||||
function renderMainMediaItem(img, index, exampleFiles) {
|
|
||||||
// Find matching file in our list of actual files
|
|
||||||
let localFile = findLocalFile(img, index, exampleFiles);
|
|
||||||
|
|
||||||
const remoteUrl = img.url || '';
|
|
||||||
const localUrl = localFile ? localFile.path : '';
|
|
||||||
const isVideo = localFile ? localFile.is_video :
|
|
||||||
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
|
|
||||||
|
|
||||||
// Check if media should be blurred
|
// Check if media should be blurred
|
||||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||||
@@ -290,252 +212,380 @@ function renderMainMediaItem(img, index, exampleFiles) {
|
|||||||
// Generate the appropriate wrapper based on media type
|
// Generate the appropriate wrapper based on media type
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return generateVideoWrapper(
|
return generateVideoWrapper(
|
||||||
img, 100, shouldBlur, nsfwText, metadataPanel,
|
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||||
localUrl, remoteUrl, mediaControlsHtml
|
localUrl, remoteUrl, mediaControlsHtml
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return generateImageWrapper(
|
return generateImageWrapper(
|
||||||
img, 100, shouldBlur, nsfwText, metadataPanel,
|
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||||
localUrl, remoteUrl, mediaControlsHtml
|
localUrl, remoteUrl, mediaControlsHtml
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render empty showcase with import interface
|
* Find the matching local file for an image
|
||||||
* @returns {string} HTML content for empty showcase
|
* @param {Object} img - Image metadata
|
||||||
|
* @param {number} index - Image index
|
||||||
|
* @param {Array} exampleFiles - Array of local files
|
||||||
|
* @returns {Object|null} Matching local file or null
|
||||||
*/
|
*/
|
||||||
function renderEmptyShowcase() {
|
function findLocalFile(img, index, exampleFiles) {
|
||||||
return `
|
if (!exampleFiles || exampleFiles.length === 0) return null;
|
||||||
<div class="showcase-container empty">
|
|
||||||
<div class="thumbnail-sidebar" id="thumbnailSidebar">
|
let localFile = null;
|
||||||
<div class="thumbnail-grid">
|
|
||||||
<!-- Empty thumbnails grid -->
|
if (img.id) {
|
||||||
</div>
|
// This is a custom image, find by custom_<id>
|
||||||
${renderImportInterface()}
|
const customPrefix = `custom_${img.id}`;
|
||||||
</div>
|
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
|
||||||
<div class="main-display-area empty">
|
} else {
|
||||||
<div class="empty-state">
|
// This is a regular image from civitai, find by index
|
||||||
<i class="fas fa-images"></i>
|
localFile = exampleFiles.find(file => {
|
||||||
<h3>No example images available</h3>
|
const match = file.name.match(/image_(\d+)\./);
|
||||||
<p>Import images or videos using the sidebar</p>
|
return match && parseInt(match[1]) === index;
|
||||||
</div>
|
});
|
||||||
</div>
|
}
|
||||||
</div>
|
|
||||||
`;
|
return localFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the import interface for example images
|
* Render the import interface for example images
|
||||||
|
* @param {boolean} isEmpty - Whether there are no existing examples
|
||||||
* @returns {string} HTML content for import interface
|
* @returns {string} HTML content for import interface
|
||||||
*/
|
*/
|
||||||
function renderImportInterface() {
|
function renderImportInterface(isEmpty) {
|
||||||
return `
|
return `
|
||||||
<div class="import-section">
|
<div class="example-import-area ${isEmpty ? 'empty' : ''}">
|
||||||
<button class="select-files-btn" id="selectExampleFilesBtn">
|
<div class="import-container" id="exampleImportContainer">
|
||||||
<i class="fas fa-plus"></i>
|
<div class="import-placeholder">
|
||||||
<span>Add Images</span>
|
|
||||||
</button>
|
|
||||||
<div class="import-drop-zone" id="importDropZone">
|
|
||||||
<div class="drop-zone-content">
|
|
||||||
<i class="fas fa-cloud-upload-alt"></i>
|
<i class="fas fa-cloud-upload-alt"></i>
|
||||||
<span>Drop here</span>
|
<h3>${isEmpty ? 'No example images available' : 'Add more examples'}</h3>
|
||||||
|
<p>Drag & drop images or videos here</p>
|
||||||
|
<p class="sub-text">or</p>
|
||||||
|
<button class="select-files-btn" id="selectExampleFilesBtn">
|
||||||
|
<i class="fas fa-folder-open"></i> Select Files
|
||||||
|
</button>
|
||||||
|
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
|
||||||
|
<div class="import-progress-container" style="display: none;">
|
||||||
|
<div class="import-progress">
|
||||||
|
<div class="progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">Importing files...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all showcase content interactions
|
* Initialize the example import functionality
|
||||||
* @param {HTMLElement} showcase - The showcase element
|
* @param {string} modelHash - The SHA256 hash of the model
|
||||||
|
* @param {Element} container - The container element for the import area
|
||||||
*/
|
*/
|
||||||
export function initShowcaseContent(showcase) {
|
export function initExampleImport(modelHash, container) {
|
||||||
if (!showcase) return;
|
|
||||||
|
|
||||||
const container = showcase.querySelector('.showcase-container');
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
initLazyLoading(container);
|
const importContainer = container.querySelector('#exampleImportContainer');
|
||||||
initNsfwBlurHandlers(container);
|
const fileInput = container.querySelector('#exampleFilesInput');
|
||||||
initThumbnailNavigation(container);
|
const selectFilesBtn = container.querySelector('#selectExampleFilesBtn');
|
||||||
initMainDisplayHandlers(container);
|
|
||||||
initMediaControlHandlers(container);
|
|
||||||
|
|
||||||
// Initialize keyboard navigation
|
// Set up file selection button
|
||||||
initKeyboardNavigation(container);
|
if (selectFilesBtn) {
|
||||||
}
|
selectFilesBtn.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
/**
|
|
||||||
* Initialize thumbnail navigation
|
|
||||||
* @param {HTMLElement} container - The showcase container
|
|
||||||
*/
|
|
||||||
function initThumbnailNavigation(container) {
|
|
||||||
const thumbnails = container.querySelectorAll('.thumbnail-item');
|
|
||||||
const mainContainer = container.querySelector('#mainMediaContainer');
|
|
||||||
|
|
||||||
if (!mainContainer) return;
|
|
||||||
|
|
||||||
thumbnails.forEach((thumbnail, index) => {
|
|
||||||
thumbnail.addEventListener('click', () => {
|
|
||||||
// Update active thumbnail
|
|
||||||
thumbnails.forEach(t => t.classList.remove('active'));
|
|
||||||
thumbnail.classList.add('active');
|
|
||||||
|
|
||||||
// Get the corresponding image data and render main media
|
|
||||||
const showcaseSection = document.querySelector('.showcase-section');
|
|
||||||
const modelHash = showcaseSection?.dataset.modelHash;
|
|
||||||
|
|
||||||
// This would need access to the filtered images array
|
|
||||||
// For now, we'll trigger a re-render of the main display
|
|
||||||
updateMainDisplay(index, container);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize main display handlers including navigation and info toggle
|
|
||||||
* @param {HTMLElement} container - The showcase container
|
|
||||||
*/
|
|
||||||
function initMainDisplayHandlers(container) {
|
|
||||||
const prevBtn = container.querySelector('#prevBtn');
|
|
||||||
const nextBtn = container.querySelector('#nextBtn');
|
|
||||||
const infoBtn = container.querySelector('#infoBtn');
|
|
||||||
|
|
||||||
if (prevBtn) {
|
|
||||||
prevBtn.addEventListener('click', () => navigateMedia(container, -1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextBtn) {
|
// Handle file selection
|
||||||
nextBtn.addEventListener('click', () => navigateMedia(container, 1));
|
if (fileInput) {
|
||||||
}
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
if (infoBtn) {
|
handleImportFiles(Array.from(e.target.files), modelHash, importContainer);
|
||||||
infoBtn.addEventListener('click', () => toggleMetadataPanel(container));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize metadata panel toggle behavior
|
|
||||||
initMetadataPanelToggle(container);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize keyboard navigation
|
|
||||||
* @param {HTMLElement} container - The showcase container
|
|
||||||
*/
|
|
||||||
function initKeyboardNavigation(container) {
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
// Only handle if showcase is visible and focused
|
|
||||||
if (!container.closest('.modal').classList.contains('show')) return;
|
|
||||||
|
|
||||||
switch(e.key) {
|
|
||||||
case 'ArrowLeft':
|
|
||||||
e.preventDefault();
|
|
||||||
navigateMedia(container, -1);
|
|
||||||
break;
|
|
||||||
case 'ArrowRight':
|
|
||||||
e.preventDefault();
|
|
||||||
navigateMedia(container, 1);
|
|
||||||
break;
|
|
||||||
case 'i':
|
|
||||||
case 'I':
|
|
||||||
e.preventDefault();
|
|
||||||
toggleMetadataPanel(container);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to previous/next media item
|
|
||||||
* @param {HTMLElement} container - The showcase container
|
|
||||||
* @param {number} direction - -1 for previous, 1 for next
|
|
||||||
*/
|
|
||||||
function navigateMedia(container, direction) {
|
|
||||||
const thumbnails = container.querySelectorAll('.thumbnail-item');
|
|
||||||
const activeThumbnail = container.querySelector('.thumbnail-item.active');
|
|
||||||
|
|
||||||
if (!activeThumbnail || thumbnails.length === 0) return;
|
|
||||||
|
|
||||||
const currentIndex = Array.from(thumbnails).indexOf(activeThumbnail);
|
|
||||||
let newIndex = currentIndex + direction;
|
|
||||||
|
|
||||||
// Wrap around
|
|
||||||
if (newIndex < 0) newIndex = thumbnails.length - 1;
|
|
||||||
if (newIndex >= thumbnails.length) newIndex = 0;
|
|
||||||
|
|
||||||
// Click the new thumbnail to trigger the display update
|
|
||||||
thumbnails[newIndex].click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle metadata panel visibility
|
|
||||||
* @param {HTMLElement} container - The showcase container
|
|
||||||
*/
|
|
||||||
function toggleMetadataPanel(container) {
|
|
||||||
const metadataPanel = container.querySelector('.image-metadata-panel');
|
|
||||||
const infoBtn = container.querySelector('#infoBtn');
|
|
||||||
|
|
||||||
if (!metadataPanel || !infoBtn) return;
|
|
||||||
|
|
||||||
const isVisible = metadataPanel.classList.contains('visible');
|
|
||||||
|
|
||||||
if (isVisible) {
|
|
||||||
metadataPanel.classList.remove('visible');
|
|
||||||
infoBtn.classList.remove('active');
|
|
||||||
} else {
|
|
||||||
metadataPanel.classList.add('visible');
|
|
||||||
infoBtn.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize metadata panel toggle behavior
|
|
||||||
* @param {HTMLElement} container - The showcase container
|
|
||||||
*/
|
|
||||||
function initMetadataPanelToggle(container) {
|
|
||||||
const metadataPanel = container.querySelector('.image-metadata-panel');
|
|
||||||
|
|
||||||
if (!metadataPanel) return;
|
|
||||||
|
|
||||||
// Handle copy prompt buttons
|
|
||||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
|
||||||
copyBtns.forEach(copyBtn => {
|
|
||||||
const promptIndex = copyBtn.dataset.promptIndex;
|
|
||||||
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
|
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!promptElement) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
showToast('Copy failed', 'error');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Prevent panel scroll from causing modal scroll
|
// Set up drag and drop
|
||||||
metadataPanel.addEventListener('wheel', (e) => {
|
if (importContainer) {
|
||||||
const isAtTop = metadataPanel.scrollTop === 0;
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
importContainer.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
function preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
|
||||||
|
// Highlight drop area on drag over
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
importContainer.addEventListener(eventName, () => {
|
||||||
|
importContainer.classList.add('highlight');
|
||||||
|
}, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove highlight on drag leave
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
importContainer.addEventListener(eventName, () => {
|
||||||
|
importContainer.classList.remove('highlight');
|
||||||
|
}, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle dropped files
|
||||||
|
importContainer.addEventListener('drop', (e) => {
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
handleImportFiles(files, modelHash, importContainer);
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update main display with new media item
|
* Handle the file import process
|
||||||
* @param {number} index - Index of the media to display
|
* @param {File[]} files - Array of files to import
|
||||||
* @param {HTMLElement} container - The showcase container
|
* @param {string} modelHash - The SHA256 hash of the model
|
||||||
|
* @param {Element} importContainer - The container element for import UI
|
||||||
*/
|
*/
|
||||||
function updateMainDisplay(index, container) {
|
async function handleImportFiles(files, modelHash, importContainer) {
|
||||||
// This function would need to re-render the main display area
|
// Filter for supported file types
|
||||||
// Implementation depends on how the image data is stored and accessed
|
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||||
console.log('Update main display to index:', index);
|
const supportedVideos = ['.mp4', '.webm'];
|
||||||
|
const supportedExtensions = [...supportedImages, ...supportedVideos];
|
||||||
|
|
||||||
|
const validFiles = files.filter(file => {
|
||||||
|
const ext = '.' + file.name.split('.').pop().toLowerCase();
|
||||||
|
return supportedExtensions.includes(ext);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
alert('No supported files selected. Please select image or video files.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use FormData to upload files
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('model_hash', modelHash);
|
||||||
|
|
||||||
|
validFiles.forEach(file => {
|
||||||
|
formData.append('files', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call API to import files
|
||||||
|
const response = await fetch('/api/import-example-images', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to import example files');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated local files
|
||||||
|
const updatedFilesResponse = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
|
||||||
|
const updatedFilesResult = await updatedFilesResponse.json();
|
||||||
|
|
||||||
|
if (!updatedFilesResult.success) {
|
||||||
|
throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render the showcase content
|
||||||
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
|
if (showcaseTab) {
|
||||||
|
// Get the updated images from the result
|
||||||
|
const regularImages = result.regular_images || [];
|
||||||
|
const customImages = result.custom_images || [];
|
||||||
|
// Combine both arrays for rendering
|
||||||
|
const allImages = [...regularImages, ...customImages];
|
||||||
|
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
|
||||||
|
|
||||||
|
// Re-initialize showcase functionality
|
||||||
|
const carousel = showcaseTab.querySelector('.carousel');
|
||||||
|
if (carousel && !carousel.classList.contains('collapsed')) {
|
||||||
|
initShowcaseContent(carousel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the import UI for the new content
|
||||||
|
initExampleImport(modelHash, showcaseTab);
|
||||||
|
|
||||||
|
showToast('Example images imported successfully', 'success');
|
||||||
|
|
||||||
|
// Update VirtualScroller if available
|
||||||
|
if (state.virtualScroller && result.model_file_path) {
|
||||||
|
// Create an update object with only the necessary properties
|
||||||
|
const updateData = {
|
||||||
|
civitai: {
|
||||||
|
images: regularImages,
|
||||||
|
customImages: customImages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the item in the virtual scroller
|
||||||
|
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error importing examples:', error);
|
||||||
|
showToast(`Failed to import example images: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle showcase expansion
|
||||||
|
* @param {HTMLElement} element - The scroll indicator element
|
||||||
|
*/
|
||||||
|
export function toggleShowcase(element) {
|
||||||
|
const carousel = element.nextElementSibling;
|
||||||
|
const isCollapsed = carousel.classList.contains('collapsed');
|
||||||
|
const indicator = element.querySelector('span');
|
||||||
|
const icon = element.querySelector('i');
|
||||||
|
|
||||||
|
carousel.classList.toggle('collapsed');
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||||
|
indicator.textContent = `Scroll or click to hide examples`;
|
||||||
|
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
|
||||||
|
initShowcaseContent(carousel);
|
||||||
|
} else {
|
||||||
|
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||||
|
indicator.textContent = `Scroll or click to show ${count} examples`;
|
||||||
|
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
|
||||||
|
|
||||||
|
// Make sure any open metadata panels get closed
|
||||||
|
const carouselContainer = carousel.querySelector('.carousel-container');
|
||||||
|
if (carouselContainer) {
|
||||||
|
carouselContainer.style.height = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
carouselContainer.style.height = '';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all showcase content interactions
|
||||||
|
* @param {HTMLElement} carousel - The carousel element
|
||||||
|
*/
|
||||||
|
export function initShowcaseContent(carousel) {
|
||||||
|
if (!carousel) return;
|
||||||
|
|
||||||
|
initLazyLoading(carousel);
|
||||||
|
initNsfwBlurHandlers(carousel);
|
||||||
|
initMetadataPanelHandlers(carousel);
|
||||||
|
initMediaControlHandlers(carousel);
|
||||||
|
positionAllMediaControls(carousel);
|
||||||
|
|
||||||
|
// Bind scroll-indicator click to toggleShowcase
|
||||||
|
const scrollIndicator = carousel.previousElementSibling;
|
||||||
|
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
|
||||||
|
// Remove previous click listeners to avoid duplicates
|
||||||
|
scrollIndicator.onclick = null;
|
||||||
|
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
|
||||||
|
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
|
||||||
|
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add window resize handler
|
||||||
|
const resizeHandler = () => positionAllMediaControls(carousel);
|
||||||
|
window.removeEventListener('resize', resizeHandler);
|
||||||
|
window.addEventListener('resize', resizeHandler);
|
||||||
|
|
||||||
|
// Handle images loading which might change dimensions
|
||||||
|
const mediaElements = carousel.querySelectorAll('img, video');
|
||||||
|
mediaElements.forEach(media => {
|
||||||
|
media.addEventListener('load', () => positionAllMediaControls(carousel));
|
||||||
|
if (media.tagName === 'VIDEO') {
|
||||||
|
media.addEventListener('loadedmetadata', () => positionAllMediaControls(carousel));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to top of modal content
|
||||||
|
* @param {HTMLElement} button - Back to top button
|
||||||
|
*/
|
||||||
|
export function scrollToTop(button) {
|
||||||
|
const modalContent = button.closest('.modal-content');
|
||||||
|
if (modalContent) {
|
||||||
|
modalContent.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up showcase scroll functionality
|
||||||
|
* @param {string} modalId - ID of the modal element
|
||||||
|
*/
|
||||||
|
export function setupShowcaseScroll(modalId) {
|
||||||
|
// Listen for wheel events
|
||||||
|
document.addEventListener('wheel', (event) => {
|
||||||
|
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||||
|
if (!modalContent) return;
|
||||||
|
|
||||||
|
const showcase = modalContent.querySelector('.showcase-section');
|
||||||
|
if (!showcase) return;
|
||||||
|
|
||||||
|
const carousel = showcase.querySelector('.carousel');
|
||||||
|
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
||||||
|
|
||||||
|
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
||||||
|
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
||||||
|
|
||||||
|
if (isNearBottom) {
|
||||||
|
toggleShowcase(scrollIndicator);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Use MutationObserver to set up back-to-top button when modal content is added
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.type === 'childList' && mutation.addedNodes.length) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal && modal.querySelector('.modal-content')) {
|
||||||
|
setupBackToTopButton(modal.querySelector('.modal-content'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Try to set up the button immediately in case the modal is already open
|
||||||
|
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||||
|
if (modalContent) {
|
||||||
|
setupBackToTopButton(modalContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up back-to-top button
|
||||||
|
* @param {HTMLElement} modalContent - Modal content element
|
||||||
|
*/
|
||||||
|
function setupBackToTopButton(modalContent) {
|
||||||
|
// Remove any existing scroll listeners to avoid duplicates
|
||||||
|
modalContent.onscroll = null;
|
||||||
|
|
||||||
|
// Add new scroll listener
|
||||||
|
modalContent.addEventListener('scroll', () => {
|
||||||
|
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
||||||
|
if (backToTopBtn) {
|
||||||
|
if (modalContent.scrollTop > 300) {
|
||||||
|
backToTopBtn.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
backToTopBtn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a scroll event to check initial position
|
||||||
|
modalContent.dispatchEvent(new Event('scroll'));
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,11 @@ import { modalManager } from './managers/ModalManager.js';
|
|||||||
import { updateService } from './managers/UpdateService.js';
|
import { updateService } from './managers/UpdateService.js';
|
||||||
import { HeaderManager } from './components/Header.js';
|
import { HeaderManager } from './components/Header.js';
|
||||||
import { settingsManager } from './managers/SettingsManager.js';
|
import { settingsManager } from './managers/SettingsManager.js';
|
||||||
|
import { moveManager } from './managers/MoveManager.js';
|
||||||
|
import { bulkManager } from './managers/BulkManager.js';
|
||||||
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||||
import { helpManager } from './managers/HelpManager.js';
|
import { helpManager } from './managers/HelpManager.js';
|
||||||
|
import { bannerService } from './managers/BannerService.js';
|
||||||
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
|
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||||
@@ -27,16 +30,22 @@ export class AppCore {
|
|||||||
state.loadingManager = new LoadingManager();
|
state.loadingManager = new LoadingManager();
|
||||||
modalManager.initialize();
|
modalManager.initialize();
|
||||||
updateService.initialize();
|
updateService.initialize();
|
||||||
|
bannerService.initialize();
|
||||||
window.modalManager = modalManager;
|
window.modalManager = modalManager;
|
||||||
window.settingsManager = settingsManager;
|
window.settingsManager = settingsManager;
|
||||||
window.exampleImagesManager = exampleImagesManager;
|
window.exampleImagesManager = exampleImagesManager;
|
||||||
window.helpManager = helpManager;
|
window.helpManager = helpManager;
|
||||||
|
window.moveManager = moveManager;
|
||||||
|
window.bulkManager = bulkManager;
|
||||||
|
|
||||||
// Initialize UI components
|
// Initialize UI components
|
||||||
window.headerManager = new HeaderManager();
|
window.headerManager = new HeaderManager();
|
||||||
initTheme();
|
initTheme();
|
||||||
initBackToTop();
|
initBackToTop();
|
||||||
|
|
||||||
|
// Initialize the bulk manager
|
||||||
|
bulkManager.initialize();
|
||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
// Initialize the help manager
|
// Initialize the help manager
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { state } from './state/index.js';
|
import { state } from './state/index.js';
|
||||||
import { updateCardsForBulkMode } from './components/shared/ModelCard.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 { LoraContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
@@ -33,15 +31,6 @@ class LoraPageManager {
|
|||||||
window.closeDeleteModal = closeDeleteModal;
|
window.closeDeleteModal = closeDeleteModal;
|
||||||
window.confirmExclude = confirmExclude;
|
window.confirmExclude = confirmExclude;
|
||||||
window.closeExcludeModal = closeExcludeModal;
|
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
|
// Expose duplicates manager
|
||||||
window.modelDuplicatesManager = this.duplicatesManager;
|
window.modelDuplicatesManager = this.duplicatesManager;
|
||||||
@@ -56,9 +45,6 @@ class LoraPageManager {
|
|||||||
// Initialize cards for current bulk mode state (should be false initially)
|
// Initialize cards for current bulk mode state (should be false initially)
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
// Initialize the bulk manager
|
|
||||||
bulkManager.initialize();
|
|
||||||
|
|
||||||
// Initialize common page features (virtual scroll)
|
// Initialize common page features (virtual scroll)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
}
|
}
|
||||||
|
|||||||
176
static/js/managers/BannerService.js
Normal file
176
static/js/managers/BannerService.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner Service for managing notification banners
|
||||||
|
*/
|
||||||
|
class BannerService {
|
||||||
|
constructor() {
|
||||||
|
this.banners = new Map();
|
||||||
|
this.container = null;
|
||||||
|
this.initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the banner service
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
if (this.initialized) return;
|
||||||
|
|
||||||
|
this.container = document.getElementById('banner-container');
|
||||||
|
if (!this.container) {
|
||||||
|
console.warn('Banner container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register default banners
|
||||||
|
this.registerBanner('civitai-extension', {
|
||||||
|
id: 'civitai-extension',
|
||||||
|
title: 'New Tool Available: LM Civitai Extension!',
|
||||||
|
content: 'LM Civitai Extension is a browser extension designed to work seamlessly with LoRA Manager to significantly enhance your Civitai browsing experience! See which models you already have, download new ones with a single click, and manage your downloads efficiently.',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: 'Chrome Web Store',
|
||||||
|
icon: 'fab fa-chrome',
|
||||||
|
url: 'https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb',
|
||||||
|
type: 'secondary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Firefox Extension',
|
||||||
|
icon: 'fab fa-firefox-browser',
|
||||||
|
url: 'https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi',
|
||||||
|
type: 'secondary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Read more...',
|
||||||
|
icon: 'fas fa-book',
|
||||||
|
url: 'https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension)',
|
||||||
|
type: 'tertiary'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dismissible: true,
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
this.showActiveBanners();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new banner
|
||||||
|
* @param {string} id - Unique banner ID
|
||||||
|
* @param {Object} bannerConfig - Banner configuration
|
||||||
|
*/
|
||||||
|
registerBanner(id, bannerConfig) {
|
||||||
|
this.banners.set(id, bannerConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a banner has been dismissed
|
||||||
|
* @param {string} bannerId - Banner ID
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isBannerDismissed(bannerId) {
|
||||||
|
const dismissedBanners = getStorageItem('dismissed_banners', []);
|
||||||
|
return dismissedBanners.includes(bannerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss a banner
|
||||||
|
* @param {string} bannerId - Banner ID
|
||||||
|
*/
|
||||||
|
dismissBanner(bannerId) {
|
||||||
|
const dismissedBanners = getStorageItem('dismissed_banners', []);
|
||||||
|
if (!dismissedBanners.includes(bannerId)) {
|
||||||
|
dismissedBanners.push(bannerId);
|
||||||
|
setStorageItem('dismissed_banners', dismissedBanners);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove banner from DOM
|
||||||
|
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
|
||||||
|
if (bannerElement) {
|
||||||
|
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
|
||||||
|
setTimeout(() => {
|
||||||
|
bannerElement.remove();
|
||||||
|
this.updateContainerVisibility();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show all active (non-dismissed) banners
|
||||||
|
*/
|
||||||
|
showActiveBanners() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const activeBanners = Array.from(this.banners.values())
|
||||||
|
.filter(banner => !this.isBannerDismissed(banner.id))
|
||||||
|
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
|
||||||
|
|
||||||
|
activeBanners.forEach(banner => {
|
||||||
|
this.renderBanner(banner);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateContainerVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a banner to the DOM
|
||||||
|
* @param {Object} banner - Banner configuration
|
||||||
|
*/
|
||||||
|
renderBanner(banner) {
|
||||||
|
const bannerElement = document.createElement('div');
|
||||||
|
bannerElement.className = 'banner-item';
|
||||||
|
bannerElement.setAttribute('data-banner-id', banner.id);
|
||||||
|
|
||||||
|
const actionsHtml = banner.actions ? banner.actions.map(action =>
|
||||||
|
`<a href="${action.url}" target="_blank" class="banner-action banner-action-${action.type}" rel="noopener noreferrer">
|
||||||
|
<i class="${action.icon}"></i>
|
||||||
|
<span>${action.text}</span>
|
||||||
|
</a>`
|
||||||
|
).join('') : '';
|
||||||
|
|
||||||
|
const dismissButtonHtml = banner.dismissible ?
|
||||||
|
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}')" title="Dismiss">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>` : '';
|
||||||
|
|
||||||
|
bannerElement.innerHTML = `
|
||||||
|
<div class="banner-content">
|
||||||
|
<div class="banner-text">
|
||||||
|
<h4 class="banner-title">${banner.title}</h4>
|
||||||
|
<p class="banner-description">${banner.content}</p>
|
||||||
|
</div>
|
||||||
|
<div class="banner-actions">
|
||||||
|
${actionsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${dismissButtonHtml}
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.container.appendChild(bannerElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update container visibility based on active banners
|
||||||
|
*/
|
||||||
|
updateContainerVisibility() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const hasActiveBanners = this.container.children.length > 0;
|
||||||
|
this.container.style.display = hasActiveBanners ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all dismissed banners (for testing/admin purposes)
|
||||||
|
*/
|
||||||
|
clearDismissedBanners() {
|
||||||
|
setStorageItem('dismissed_banners', []);
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and export singleton instance
|
||||||
|
export const bannerService = new BannerService();
|
||||||
|
|
||||||
|
// Make it globally available
|
||||||
|
window.bannerService = bannerService;
|
||||||
@@ -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 { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.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 {
|
export class BulkManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.bulkBtn = document.getElementById('bulkOperationsBtn');
|
this.bulkBtn = document.getElementById('bulkOperationsBtn');
|
||||||
this.bulkPanel = document.getElementById('bulkOperationsPanel');
|
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
|
this.stripMaxThumbnails = 50;
|
||||||
if (!state.selectedLoras) {
|
|
||||||
state.selectedLoras = new Set();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for lora metadata to handle non-visible selected loras
|
// Model type specific action configurations
|
||||||
if (!state.loraMetadataCache) {
|
this.actionConfig = {
|
||||||
state.loraMetadataCache = new Map();
|
[MODEL_TYPES.LORA]: {
|
||||||
}
|
sendToWorkflow: true,
|
||||||
|
copyAll: true,
|
||||||
this.stripMaxThumbnails = 50; // Maximum thumbnails to show in strip
|
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() {
|
initialize() {
|
||||||
// Add event listeners if needed
|
this.setupEventListeners();
|
||||||
// (Already handled via onclick attributes in HTML, but could be moved here)
|
this.setupGlobalKeyboardListeners();
|
||||||
|
}
|
||||||
|
|
||||||
// Add event listeners for the selected count to toggle thumbnail strip
|
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');
|
const selectedCount = document.getElementById('selectedCount');
|
||||||
if (selectedCount) {
|
if (selectedCount) {
|
||||||
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
|
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add global keyboard event listener for Ctrl+A
|
setupGlobalKeyboardListeners() {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
// First check if any modal is currently open - if so, don't handle Ctrl+A
|
|
||||||
if (modalManager.isAnyModalOpen()) {
|
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');
|
const searchInput = document.getElementById('searchInput');
|
||||||
if (searchInput && document.activeElement === 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') {
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
|
||||||
|
|
||||||
// Prevent default browser "Select All" behavior
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// If not in bulk mode, enable it first
|
|
||||||
if (!state.bulkMode) {
|
if (!state.bulkMode) {
|
||||||
this.toggleBulkMode();
|
this.toggleBulkMode();
|
||||||
// Small delay to ensure DOM is updated
|
setTimeout(() => this.selectAllVisibleModels(), 50);
|
||||||
setTimeout(() => this.selectAllVisibleLoras(), 50);
|
|
||||||
} else {
|
} else {
|
||||||
this.selectAllVisibleLoras();
|
this.selectAllVisibleModels();
|
||||||
}
|
}
|
||||||
} else if (e.key === 'Escape' && state.bulkMode) {
|
} else if (e.key === 'Escape' && state.bulkMode) {
|
||||||
// If in bulk mode, exit it on Escape
|
|
||||||
this.toggleBulkMode();
|
this.toggleBulkMode();
|
||||||
} else if (e.key.toLowerCase() === 'b') {
|
} else if (e.key.toLowerCase() === 'b') {
|
||||||
// If 'b' is pressed, toggle bulk mode
|
|
||||||
this.toggleBulkMode();
|
this.toggleBulkMode();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleBulkMode() {
|
toggleBulkMode() {
|
||||||
// Toggle the state
|
|
||||||
state.bulkMode = !state.bulkMode;
|
state.bulkMode = !state.bulkMode;
|
||||||
|
|
||||||
// Update UI
|
|
||||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||||
|
|
||||||
// Important: Remove the hidden class when entering bulk mode
|
|
||||||
if (state.bulkMode) {
|
if (state.bulkMode) {
|
||||||
this.bulkPanel.classList.remove('hidden');
|
this.bulkPanel.classList.remove('hidden');
|
||||||
// Use setTimeout to ensure the DOM updates before adding visible class
|
this.updateActionButtonsVisibility();
|
||||||
// This helps with the transition animation
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.bulkPanel.classList.add('visible');
|
this.bulkPanel.classList.add('visible');
|
||||||
}, 10);
|
}, 10);
|
||||||
} else {
|
} else {
|
||||||
this.bulkPanel.classList.remove('visible');
|
this.bulkPanel.classList.remove('visible');
|
||||||
// Add hidden class back after transition completes
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.bulkPanel.classList.add('hidden');
|
this.bulkPanel.classList.add('hidden');
|
||||||
}, 400); // Match this with the transition duration in CSS
|
}, 400);
|
||||||
|
|
||||||
// Hide thumbnail strip if it's visible
|
|
||||||
this.hideThumbnailStrip();
|
this.hideThumbnailStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
// First update all cards' visual state before clearing selection
|
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
// Clear selection if exiting bulk mode - do this after updating cards
|
|
||||||
if (!state.bulkMode) {
|
if (!state.bulkMode) {
|
||||||
this.clearSelection();
|
this.clearSelection();
|
||||||
|
|
||||||
// TODO: fix this, no DOM manipulation should be done here
|
// TODO:
|
||||||
// Force a lightweight refresh of the cards to ensure proper display
|
|
||||||
// This is less disruptive than a full resetAndReload()
|
|
||||||
document.querySelectorAll('.model-card').forEach(card => {
|
document.querySelectorAll('.model-card').forEach(card => {
|
||||||
// Re-apply normal display mode to all card actions
|
|
||||||
const actions = card.querySelectorAll('.card-actions, .card-button');
|
const actions = card.querySelectorAll('.card-actions, .card-button');
|
||||||
actions.forEach(action => action.style.display = 'flex');
|
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() {
|
clearSelection() {
|
||||||
document.querySelectorAll('.model-card.selected').forEach(card => {
|
document.querySelectorAll('.model-card.selected').forEach(card => {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
});
|
});
|
||||||
state.selectedLoras.clear();
|
state.selectedModels.clear();
|
||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
|
|
||||||
// Hide thumbnail strip if it's visible
|
|
||||||
this.hideThumbnailStrip();
|
this.hideThumbnailStrip();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedCount() {
|
updateSelectedCount() {
|
||||||
const countElement = document.getElementById('selectedCount');
|
const countElement = document.getElementById('selectedCount');
|
||||||
|
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||||
|
const displayName = currentConfig?.displayName || 'Models';
|
||||||
|
|
||||||
if (countElement) {
|
if (countElement) {
|
||||||
// Set text content without the icon
|
countElement.textContent = `${state.selectedModels.size} ${displayName.toLowerCase()}(s) selected `;
|
||||||
countElement.textContent = `${state.selectedLoras.size} selected `;
|
|
||||||
|
|
||||||
// Update caret icon if it exists
|
|
||||||
const existingCaret = countElement.querySelector('.dropdown-caret');
|
const existingCaret = countElement.querySelector('.dropdown-caret');
|
||||||
if (existingCaret) {
|
if (existingCaret) {
|
||||||
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
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 {
|
} else {
|
||||||
// Create new caret icon if it doesn't exist
|
|
||||||
const caretIcon = document.createElement('i');
|
const caretIcon = document.createElement('i');
|
||||||
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
|
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);
|
countElement.appendChild(caretIcon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,16 +203,18 @@ export class BulkManager {
|
|||||||
|
|
||||||
toggleCardSelection(card) {
|
toggleCardSelection(card) {
|
||||||
const filepath = card.dataset.filepath;
|
const filepath = card.dataset.filepath;
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
|
||||||
if (card.classList.contains('selected')) {
|
if (card.classList.contains('selected')) {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
state.selectedLoras.delete(filepath);
|
state.selectedModels.delete(filepath);
|
||||||
} else {
|
} else {
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
state.selectedLoras.add(filepath);
|
state.selectedModels.add(filepath);
|
||||||
|
|
||||||
// Cache the metadata for this lora
|
// Cache the metadata for this model
|
||||||
state.loraMetadataCache.set(filepath, {
|
const metadataCache = this.getMetadataCache();
|
||||||
|
metadataCache.set(filepath, {
|
||||||
fileName: card.dataset.file_name,
|
fileName: card.dataset.file_name,
|
||||||
usageTips: card.dataset.usage_tips,
|
usageTips: card.dataset.usage_tips,
|
||||||
previewUrl: this.getCardPreviewUrl(card),
|
previewUrl: this.getCardPreviewUrl(card),
|
||||||
@@ -169,35 +225,49 @@ export class BulkManager {
|
|||||||
|
|
||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
|
|
||||||
// Update thumbnail strip if it's visible
|
|
||||||
if (this.isStripVisible) {
|
if (this.isStripVisible) {
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to get preview URL from a card
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getCardPreviewUrl(card) {
|
getCardPreviewUrl(card) {
|
||||||
const img = card.querySelector('img');
|
const img = card.querySelector('img');
|
||||||
const video = card.querySelector('video source');
|
const video = card.querySelector('video source');
|
||||||
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
|
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to check if preview is a video
|
|
||||||
isCardPreviewVideo(card) {
|
isCardPreviewVideo(card) {
|
||||||
return card.querySelector('video') !== null;
|
return card.querySelector('video') !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply selection state to cards after they are refreshed
|
|
||||||
applySelectionState() {
|
applySelectionState() {
|
||||||
if (!state.bulkMode) return;
|
if (!state.bulkMode) return;
|
||||||
|
|
||||||
document.querySelectorAll('.model-card').forEach(card => {
|
document.querySelectorAll('.model-card').forEach(card => {
|
||||||
const filepath = card.dataset.filepath;
|
const filepath = card.dataset.filepath;
|
||||||
if (state.selectedLoras.has(filepath)) {
|
if (state.selectedModels.has(filepath)) {
|
||||||
card.classList.add('selected');
|
card.classList.add('selected');
|
||||||
|
|
||||||
// Update the cache with latest data
|
const metadataCache = this.getMetadataCache();
|
||||||
state.loraMetadataCache.set(filepath, {
|
metadataCache.set(filepath, {
|
||||||
fileName: card.dataset.file_name,
|
fileName: card.dataset.file_name,
|
||||||
usageTips: card.dataset.usage_tips,
|
usageTips: card.dataset.usage_tips,
|
||||||
previewUrl: this.getCardPreviewUrl(card),
|
previewUrl: this.getCardPreviewUrl(card),
|
||||||
@@ -212,30 +282,33 @@ export class BulkManager {
|
|||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyAllLorasSyntax() {
|
async copyAllModelsSyntax() {
|
||||||
if (state.selectedLoras.size === 0) {
|
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');
|
showToast('No LoRAs selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loraSyntaxes = [];
|
const loraSyntaxes = [];
|
||||||
const missingLoras = [];
|
const missingLoras = [];
|
||||||
|
const metadataCache = this.getMetadataCache();
|
||||||
|
|
||||||
// Process all selected loras using our metadata cache
|
for (const filepath of state.selectedModels) {
|
||||||
for (const filepath of state.selectedLoras) {
|
const metadata = metadataCache.get(filepath);
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
|
||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
const strength = usageTips.strength || 1;
|
const strength = usageTips.strength || 1;
|
||||||
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||||
} else {
|
} else {
|
||||||
// If we don't have metadata, this is an error case
|
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle any loras with missing metadata
|
|
||||||
if (missingLoras.length > 0) {
|
if (missingLoras.length > 0) {
|
||||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
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`);
|
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add method to send all selected loras to workflow
|
async sendAllModelsToWorkflow() {
|
||||||
async sendAllLorasToWorkflow() {
|
if (state.currentPageType !== MODEL_TYPES.LORA) {
|
||||||
if (state.selectedLoras.size === 0) {
|
showToast('Send to workflow is only available for LoRAs', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No LoRAs selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loraSyntaxes = [];
|
const loraSyntaxes = [];
|
||||||
const missingLoras = [];
|
const missingLoras = [];
|
||||||
|
const metadataCache = this.getMetadataCache();
|
||||||
|
|
||||||
// Process all selected loras using our metadata cache
|
for (const filepath of state.selectedModels) {
|
||||||
for (const filepath of state.selectedLoras) {
|
const metadata = metadataCache.get(filepath);
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
|
||||||
|
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
const strength = usageTips.strength || 1;
|
const strength = usageTips.strength || 1;
|
||||||
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||||
} else {
|
} else {
|
||||||
// If we don't have metadata, this is an error case
|
|
||||||
missingLoras.push(filepath);
|
missingLoras.push(filepath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle any loras with missing metadata
|
|
||||||
if (missingLoras.length > 0) {
|
if (missingLoras.length > 0) {
|
||||||
console.warn('Missing metadata for some selected loras:', missingLoras);
|
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||||
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||||
@@ -284,82 +359,48 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the loras to the workflow
|
|
||||||
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
|
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the bulk delete confirmation modal
|
|
||||||
showBulkDeleteModal() {
|
showBulkDeleteModal() {
|
||||||
if (state.selectedLoras.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the count in the modal
|
|
||||||
const countElement = document.getElementById('bulkDeleteCount');
|
const countElement = document.getElementById('bulkDeleteCount');
|
||||||
if (countElement) {
|
if (countElement) {
|
||||||
countElement.textContent = state.selectedLoras.size;
|
countElement.textContent = state.selectedModels.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show the modal
|
|
||||||
modalManager.showModal('bulkDeleteModal');
|
modalManager.showModal('bulkDeleteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm bulk delete action
|
|
||||||
async confirmBulkDelete() {
|
async confirmBulkDelete() {
|
||||||
if (state.selectedLoras.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
modalManager.closeModal('bulkDeleteModal');
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close the modal first before showing loading indicator
|
|
||||||
modalManager.closeModal('bulkDeleteModal');
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show loading indicator
|
const apiClient = getModelApiClient();
|
||||||
state.loadingManager.showSimpleLoading('Deleting models...');
|
const filePaths = Array.from(state.selectedModels);
|
||||||
|
|
||||||
// Gather all file paths for deletion
|
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||||
const filePaths = Array.from(state.selectedLoras);
|
|
||||||
|
|
||||||
// Call the backend API
|
|
||||||
const response = await fetch('/api/loras/bulk-delete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_paths: filePaths
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.success) {
|
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
|
filePaths.forEach(path => {
|
||||||
if (state.virtualScroller) {
|
state.virtualScroller.removeItemByFilePath(path);
|
||||||
// Remove each deleted item from the virtual scroller
|
});
|
||||||
filePaths.forEach(path => {
|
this.clearSelection();
|
||||||
state.virtualScroller.removeItemByFilePath(path);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear the selection
|
|
||||||
this.clearSelection();
|
|
||||||
} else {
|
|
||||||
// Clear the selection
|
|
||||||
this.clearSelection();
|
|
||||||
|
|
||||||
// Fall back to page reload for non-virtual scroll mode
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.modelDuplicatesManager) {
|
if (window.modelDuplicatesManager) {
|
||||||
// Update duplicates badge after refresh
|
|
||||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -368,16 +409,11 @@ export class BulkManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during bulk delete:', error);
|
console.error('Error during bulk delete:', error);
|
||||||
showToast('Failed to delete models', 'error');
|
showToast('Failed to delete models', 'error');
|
||||||
} finally {
|
|
||||||
// Hide loading indicator
|
|
||||||
state.loadingManager.hide();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create and show the thumbnail strip of selected LoRAs
|
|
||||||
toggleThumbnailStrip() {
|
toggleThumbnailStrip() {
|
||||||
// If no items are selected, do nothing
|
if (state.selectedModels.size === 0) return;
|
||||||
if (state.selectedLoras.size === 0) return;
|
|
||||||
|
|
||||||
const existing = document.querySelector('.selected-thumbnails-strip');
|
const existing = document.querySelector('.selected-thumbnails-strip');
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -388,38 +424,30 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showThumbnailStrip() {
|
showThumbnailStrip() {
|
||||||
// Create the thumbnail strip container
|
|
||||||
const strip = document.createElement('div');
|
const strip = document.createElement('div');
|
||||||
strip.className = 'selected-thumbnails-strip';
|
strip.className = 'selected-thumbnails-strip';
|
||||||
|
|
||||||
// Create a container for the thumbnails (for scrolling)
|
|
||||||
const thumbnailContainer = document.createElement('div');
|
const thumbnailContainer = document.createElement('div');
|
||||||
thumbnailContainer.className = 'thumbnails-container';
|
thumbnailContainer.className = 'thumbnails-container';
|
||||||
strip.appendChild(thumbnailContainer);
|
strip.appendChild(thumbnailContainer);
|
||||||
|
|
||||||
// Position the strip above the bulk operations panel
|
|
||||||
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
|
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
|
||||||
|
|
||||||
// Populate the thumbnails
|
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
|
|
||||||
// Update strip visibility state and caret direction
|
|
||||||
this.isStripVisible = true;
|
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);
|
setTimeout(() => strip.classList.add('visible'), 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
hideThumbnailStrip() {
|
hideThumbnailStrip() {
|
||||||
const strip = document.querySelector('.selected-thumbnails-strip');
|
const strip = document.querySelector('.selected-thumbnails-strip');
|
||||||
if (strip && this.isStripVisible) { // Only hide if actually visible
|
if (strip && this.isStripVisible) {
|
||||||
strip.classList.remove('visible');
|
strip.classList.remove('visible');
|
||||||
|
|
||||||
// Update strip visibility state
|
|
||||||
this.isStripVisible = false;
|
this.isStripVisible = false;
|
||||||
|
|
||||||
// Update caret without triggering another hide
|
|
||||||
const countElement = document.getElementById('selectedCount');
|
const countElement = document.getElementById('selectedCount');
|
||||||
if (countElement) {
|
if (countElement) {
|
||||||
const caret = countElement.querySelector('.dropdown-caret');
|
const caret = countElement.querySelector('.dropdown-caret');
|
||||||
@@ -428,7 +456,6 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for animation to complete before removing
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (strip.parentNode) {
|
if (strip.parentNode) {
|
||||||
strip.parentNode.removeChild(strip);
|
strip.parentNode.removeChild(strip);
|
||||||
@@ -441,33 +468,28 @@ export class BulkManager {
|
|||||||
const container = document.querySelector('.thumbnails-container');
|
const container = document.querySelector('.thumbnails-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
// Clear existing thumbnails
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
// Get all selected loras
|
const selectedModels = Array.from(state.selectedModels);
|
||||||
const selectedLoras = Array.from(state.selectedLoras);
|
|
||||||
|
|
||||||
// Create counter if we have more thumbnails than we'll show
|
if (selectedModels.length > this.stripMaxThumbnails) {
|
||||||
if (selectedLoras.length > this.stripMaxThumbnails) {
|
|
||||||
const counter = document.createElement('div');
|
const counter = document.createElement('div');
|
||||||
counter.className = 'strip-counter';
|
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);
|
container.appendChild(counter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit the number of thumbnails to display
|
const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails);
|
||||||
const thumbnailsToShow = selectedLoras.slice(0, this.stripMaxThumbnails);
|
const metadataCache = this.getMetadataCache();
|
||||||
|
|
||||||
// Add a thumbnail for each selected LoRA (limited to max)
|
|
||||||
thumbnailsToShow.forEach(filepath => {
|
thumbnailsToShow.forEach(filepath => {
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
const metadata = metadataCache.get(filepath);
|
||||||
if (!metadata) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
const thumbnail = document.createElement('div');
|
const thumbnail = document.createElement('div');
|
||||||
thumbnail.className = 'selected-thumbnail';
|
thumbnail.className = 'selected-thumbnail';
|
||||||
thumbnail.dataset.filepath = filepath;
|
thumbnail.dataset.filepath = filepath;
|
||||||
|
|
||||||
// Create the visual element (image or video)
|
|
||||||
if (metadata.isVideo) {
|
if (metadata.isVideo) {
|
||||||
thumbnail.innerHTML = `
|
thumbnail.innerHTML = `
|
||||||
<video autoplay loop muted playsinline>
|
<video autoplay loop muted playsinline>
|
||||||
@@ -484,14 +506,12 @@ export class BulkManager {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler for deselection
|
|
||||||
thumbnail.addEventListener('click', (e) => {
|
thumbnail.addEventListener('click', (e) => {
|
||||||
if (!e.target.closest('.thumbnail-remove')) {
|
if (!e.target.closest('.thumbnail-remove')) {
|
||||||
this.deselectItem(filepath);
|
this.deselectItem(filepath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add click handler for the remove button
|
|
||||||
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
|
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.deselectItem(filepath);
|
this.deselectItem(filepath);
|
||||||
@@ -502,43 +522,36 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deselectItem(filepath) {
|
deselectItem(filepath) {
|
||||||
// Find and deselect the corresponding card if it's in the DOM
|
|
||||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||||
if (card) {
|
if (card) {
|
||||||
card.classList.remove('selected');
|
card.classList.remove('selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from the selection set
|
state.selectedModels.delete(filepath);
|
||||||
state.selectedLoras.delete(filepath);
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
this.updateSelectedCount();
|
this.updateSelectedCount();
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
|
|
||||||
// Hide the strip if no more selections
|
if (state.selectedModels.size === 0) {
|
||||||
if (state.selectedLoras.size === 0) {
|
|
||||||
this.hideThumbnailStrip();
|
this.hideThumbnailStrip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add method to select all visible loras
|
selectAllVisibleModels() {
|
||||||
selectAllVisibleLoras() {
|
|
||||||
// Only select loras already in the VirtualScroller's data model
|
|
||||||
if (!state.virtualScroller || !state.virtualScroller.items) {
|
if (!state.virtualScroller || !state.virtualScroller.items) {
|
||||||
showToast('Unable to select all items', 'error');
|
showToast('Unable to select all items', 'error');
|
||||||
return;
|
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 => {
|
state.virtualScroller.items.forEach(item => {
|
||||||
if (item && item.file_path) {
|
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 (!metadataCache.has(item.file_path)) {
|
||||||
if (!state.loraMetadataCache.has(item.file_path)) {
|
metadataCache.set(item.file_path, {
|
||||||
state.loraMetadataCache.set(item.file_path, {
|
|
||||||
fileName: item.file_name,
|
fileName: item.file_name,
|
||||||
usageTips: item.usage_tips || '{}',
|
usageTips: item.usage_tips || '{}',
|
||||||
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
|
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
|
||||||
@@ -549,45 +562,37 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update visual state
|
|
||||||
this.applySelectionState();
|
this.applySelectionState();
|
||||||
|
|
||||||
// Show success message
|
const newlySelected = state.selectedModels.size - oldCount;
|
||||||
const newlySelected = state.selectedLoras.size - oldCount;
|
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||||
showToast(`Selected ${newlySelected} additional LoRAs`, 'success');
|
showToast(`Selected ${newlySelected} additional ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
|
||||||
|
|
||||||
// Update thumbnail strip if visible
|
|
||||||
if (this.isStripVisible) {
|
if (this.isStripVisible) {
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add method to refresh metadata for all selected models
|
|
||||||
async refreshAllMetadata() {
|
async refreshAllMetadata() {
|
||||||
if (state.selectedLoras.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('No models selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the API client for the current model type
|
|
||||||
const apiClient = getModelApiClient();
|
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);
|
const result = await apiClient.refreshBulkModelMetadata(filePaths);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Update the metadata cache for successfully refreshed items
|
const metadataCache = this.getMetadataCache();
|
||||||
for (const filepath of state.selectedLoras) {
|
for (const filepath of state.selectedModels) {
|
||||||
const metadata = state.loraMetadataCache.get(filepath);
|
const metadata = metadataCache.get(filepath);
|
||||||
if (metadata) {
|
if (metadata) {
|
||||||
// Find the corresponding card to get updated data
|
|
||||||
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
|
||||||
if (card) {
|
if (card) {
|
||||||
state.loraMetadataCache.set(filepath, {
|
metadataCache.set(filepath, {
|
||||||
...metadata,
|
...metadata,
|
||||||
fileName: card.dataset.file_name,
|
fileName: card.dataset.file_name,
|
||||||
usageTips: card.dataset.usage_tips,
|
usageTips: card.dataset.usage_tips,
|
||||||
@@ -599,7 +604,6 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update thumbnail strip if visible
|
|
||||||
if (this.isStripVisible) {
|
if (this.isStripVisible) {
|
||||||
this.updateThumbnailStrip();
|
this.updateThumbnailStrip();
|
||||||
}
|
}
|
||||||
@@ -612,5 +616,4 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a singleton instance
|
|
||||||
export const bulkManager = new BulkManager();
|
export const bulkManager = new BulkManager();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { LoadingManager } from './LoadingManager.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';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
@@ -297,7 +297,10 @@ export class DownloadManager {
|
|||||||
// Set default root if available
|
// Set default root if available
|
||||||
const defaultRootKey = `default_${this.apiClient.modelType}_root`;
|
const defaultRootKey = `default_${this.apiClient.modelType}_root`;
|
||||||
const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
|
const defaultRoot = getStorageItem('settings', {})[defaultRootKey];
|
||||||
|
console.log(`Default root for ${this.apiClient.modelType}:`, defaultRoot);
|
||||||
|
console.log('Available roots:', rootsData.roots);
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
|
console.log(`Setting default root: ${defaultRoot}`);
|
||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { state } from '../state/index.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
// ExampleImagesManager.js
|
// ExampleImagesManager.js
|
||||||
@@ -14,6 +15,12 @@ class ExampleImagesManager {
|
|||||||
this.isMigrating = false; // Track migration state separately from downloading
|
this.isMigrating = false; // Track migration state separately from downloading
|
||||||
this.hasShownCompletionToast = false; // Flag to track if completion toast has been shown
|
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
|
// Initialize download path field and check download status
|
||||||
this.initializePathOptions();
|
this.initializePathOptions();
|
||||||
this.checkDownloadStatus();
|
this.checkDownloadStatus();
|
||||||
@@ -48,6 +55,14 @@ class ExampleImagesManager {
|
|||||||
if (collapseBtn) {
|
if (collapseBtn) {
|
||||||
collapseBtn.onclick = () => this.toggleProgressPanel();
|
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
|
// Initialize event listeners for buttons
|
||||||
@@ -133,6 +148,15 @@ class ExampleImagesManager {
|
|||||||
console.error('Failed to update example images path:', error);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to initialize path options:', error);
|
console.error('Failed to initialize path options:', error);
|
||||||
@@ -646,6 +670,106 @@ 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) {
|
||||||
|
console.warn('Auto download check failed:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auto download check error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create singleton instance
|
// Create singleton instance
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { BASE_MODEL_CLASSES } from '../utils/constants.js';
|
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, updatePanelPositions } from '../utils/uiHelpers.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';
|
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export class FilterManager {
|
export class FilterManager {
|
||||||
@@ -67,14 +66,7 @@ export class FilterManager {
|
|||||||
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
|
||||||
|
|
||||||
// Determine the API endpoint based on the page type
|
// Determine the API endpoint based on the page type
|
||||||
let tagsEndpoint = '/api/loras/top-tags?limit=20';
|
const tagsEndpoint = `/api/${this.currentPage}/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 response = await fetch(tagsEndpoint);
|
const response = await fetch(tagsEndpoint);
|
||||||
if (!response.ok) throw new Error('Failed to fetch tags');
|
if (!response.ok) throw new Error('Failed to fetch tags');
|
||||||
@@ -141,19 +133,8 @@ export class FilterManager {
|
|||||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||||
if (!baseModelTagsContainer) return;
|
if (!baseModelTagsContainer) return;
|
||||||
|
|
||||||
// Set the appropriate API endpoint based on current page
|
// Set the API endpoint based on current page
|
||||||
let apiEndpoint = '';
|
const apiEndpoint = `/api/${this.currentPage}/base-models`;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch base models
|
// Fetch base models
|
||||||
fetch(apiEndpoint)
|
fetch(apiEndpoint)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
|
||||||
import { LoadingManager } from './LoadingManager.js';
|
import { LoadingManager } from './LoadingManager.js';
|
||||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
|
||||||
import { ImportStepManager } from './import/ImportStepManager.js';
|
import { ImportStepManager } from './import/ImportStepManager.js';
|
||||||
import { ImageProcessor } from './import/ImageProcessor.js';
|
import { ImageProcessor } from './import/ImageProcessor.js';
|
||||||
import { RecipeDataManager } from './import/RecipeDataManager.js';
|
import { RecipeDataManager } from './import/RecipeDataManager.js';
|
||||||
@@ -86,8 +84,8 @@ export class ImportManager {
|
|||||||
const uploadError = document.getElementById('uploadError');
|
const uploadError = document.getElementById('uploadError');
|
||||||
if (uploadError) uploadError.textContent = '';
|
if (uploadError) uploadError.textContent = '';
|
||||||
|
|
||||||
const urlError = document.getElementById('urlError');
|
const importUrlError = document.getElementById('importUrlError');
|
||||||
if (urlError) urlError.textContent = '';
|
if (importUrlError) importUrlError.textContent = '';
|
||||||
|
|
||||||
const recipeName = document.getElementById('recipeName');
|
const recipeName = document.getElementById('recipeName');
|
||||||
if (recipeName) recipeName.value = '';
|
if (recipeName) recipeName.value = '';
|
||||||
@@ -167,10 +165,10 @@ export class ImportManager {
|
|||||||
|
|
||||||
// Clear error messages
|
// Clear error messages
|
||||||
const uploadError = document.getElementById('uploadError');
|
const uploadError = document.getElementById('uploadError');
|
||||||
const urlError = document.getElementById('urlError');
|
const importUrlError = document.getElementById('importUrlError');
|
||||||
|
|
||||||
if (uploadError) uploadError.textContent = '';
|
if (uploadError) uploadError.textContent = '';
|
||||||
if (urlError) urlError.textContent = '';
|
if (importUrlError) importUrlError.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
handleImageUpload(event) {
|
handleImageUpload(event) {
|
||||||
@@ -224,8 +222,8 @@ export class ImportManager {
|
|||||||
const uploadError = document.getElementById('uploadError');
|
const uploadError = document.getElementById('uploadError');
|
||||||
if (uploadError) uploadError.textContent = '';
|
if (uploadError) uploadError.textContent = '';
|
||||||
|
|
||||||
const urlError = document.getElementById('urlError');
|
const importUrlError = document.getElementById('importUrlError');
|
||||||
if (urlError) urlError.textContent = '';
|
if (importUrlError) importUrlError.textContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
backToDetails() {
|
backToDetails() {
|
||||||
|
|||||||
@@ -1,107 +1,116 @@
|
|||||||
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
|
import { bulkManager } from './BulkManager.js';
|
||||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
|
||||||
class MoveManager {
|
class MoveManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.currentFilePath = null;
|
this.currentFilePath = null;
|
||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
this.modal = document.getElementById('moveModal');
|
this.modal = document.getElementById('moveModal');
|
||||||
this.loraRootSelect = document.getElementById('moveLoraRoot');
|
this.modelRootSelect = document.getElementById('moveModelRoot');
|
||||||
this.folderBrowser = document.getElementById('moveFolderBrowser');
|
this.folderBrowser = document.getElementById('moveFolderBrowser');
|
||||||
this.newFolderInput = document.getElementById('moveNewFolder');
|
this.newFolderInput = document.getElementById('moveNewFolder');
|
||||||
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
|
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||||
this.modalTitle = document.getElementById('moveModalTitle');
|
this.modalTitle = document.getElementById('moveModalTitle');
|
||||||
|
this.rootLabel = document.getElementById('moveRootLabel');
|
||||||
|
|
||||||
this.initializeEventListeners();
|
this.initializeEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeEventListeners() {
|
initializeEventListeners() {
|
||||||
// 初始化LoRA根目录选择器
|
// Initialize model root directory selector
|
||||||
this.loraRootSelect.addEventListener('change', () => this.updatePathPreview());
|
this.modelRootSelect.addEventListener('change', () => this.updatePathPreview());
|
||||||
|
|
||||||
// 文件夹选择事件
|
// Folder selection event
|
||||||
this.folderBrowser.addEventListener('click', (e) => {
|
this.folderBrowser.addEventListener('click', (e) => {
|
||||||
const folderItem = e.target.closest('.folder-item');
|
const folderItem = e.target.closest('.folder-item');
|
||||||
if (!folderItem) return;
|
if (!folderItem) return;
|
||||||
|
|
||||||
// 如果点击已选中的文件夹,则取消选择
|
// If clicking already selected folder, deselect it
|
||||||
if (folderItem.classList.contains('selected')) {
|
if (folderItem.classList.contains('selected')) {
|
||||||
folderItem.classList.remove('selected');
|
folderItem.classList.remove('selected');
|
||||||
} else {
|
} else {
|
||||||
// 取消其他选中状态
|
// Deselect other folders
|
||||||
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
||||||
item.classList.remove('selected');
|
item.classList.remove('selected');
|
||||||
});
|
});
|
||||||
// 设置当前选中状态
|
// Select current folder
|
||||||
folderItem.classList.add('selected');
|
folderItem.classList.add('selected');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updatePathPreview();
|
this.updatePathPreview();
|
||||||
});
|
});
|
||||||
|
|
||||||
// 新文件夹输入事件
|
// New folder input event
|
||||||
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
|
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
|
||||||
}
|
}
|
||||||
|
|
||||||
async showMoveModal(filePath) {
|
async showMoveModal(filePath, modelType = null) {
|
||||||
// Reset state
|
// Reset state
|
||||||
this.currentFilePath = null;
|
this.currentFilePath = null;
|
||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
|
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
const currentPageType = state.currentPageType;
|
||||||
|
const modelConfig = apiClient.apiConfig.config;
|
||||||
|
|
||||||
// Handle bulk mode
|
// Handle bulk mode
|
||||||
if (filePath === 'bulk') {
|
if (filePath === 'bulk') {
|
||||||
const selectedPaths = Array.from(state.selectedLoras);
|
const selectedPaths = Array.from(state.selectedModels);
|
||||||
if (selectedPaths.length === 0) {
|
if (selectedPaths.length === 0) {
|
||||||
showToast('No LoRAs selected', 'warning');
|
showToast('No models selected', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.bulkFilePaths = selectedPaths;
|
this.bulkFilePaths = selectedPaths;
|
||||||
this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`;
|
this.modalTitle.textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`;
|
||||||
} else {
|
} else {
|
||||||
// Single file mode
|
// Single file mode
|
||||||
this.currentFilePath = filePath;
|
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 => {
|
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
|
||||||
item.classList.remove('selected');
|
item.classList.remove('selected');
|
||||||
});
|
});
|
||||||
this.newFolderInput.value = '';
|
this.newFolderInput.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch LoRA roots
|
// Fetch model roots
|
||||||
const rootsResponse = await fetch('/api/loras/roots');
|
let rootsData;
|
||||||
if (!rootsResponse.ok) {
|
if (modelType) {
|
||||||
throw new Error('Failed to fetch LoRA roots');
|
// 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) {
|
if (!rootsData.roots || rootsData.roots.length === 0) {
|
||||||
throw new Error('No LoRA roots found');
|
throw new Error(`No ${modelConfig.displayName.toLowerCase()} roots found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 填充LoRA根目录选择器
|
// Populate model root selector
|
||||||
this.loraRootSelect.innerHTML = rootsData.roots.map(root =>
|
this.modelRootSelect.innerHTML = rootsData.roots.map(root =>
|
||||||
`<option value="${root}">${root}</option>`
|
`<option value="${root}">${root}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default lora root if available
|
// Set default root if available
|
||||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; // Remove 's' from plural
|
||||||
|
const defaultRoot = getStorageItem('settings', {})[settingsKey];
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
this.loraRootSelect.value = defaultRoot;
|
this.modelRootSelect.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders dynamically
|
// Fetch folders dynamically
|
||||||
const foldersResponse = await fetch('/api/loras/folders');
|
const foldersData = await apiClient.fetchModelFolders();
|
||||||
if (!foldersResponse.ok) {
|
|
||||||
throw new Error('Failed to fetch folders');
|
|
||||||
}
|
|
||||||
|
|
||||||
const foldersData = await foldersResponse.json();
|
|
||||||
|
|
||||||
// Update folder browser with dynamic content
|
// Update folder browser with dynamic content
|
||||||
this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
||||||
@@ -112,13 +121,13 @@ class MoveManager {
|
|||||||
modalManager.showModal('moveModal');
|
modalManager.showModal('moveModal');
|
||||||
|
|
||||||
} catch (error) {
|
} 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');
|
showToast(error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePathPreview() {
|
updatePathPreview() {
|
||||||
const selectedRoot = this.loraRootSelect.value;
|
const selectedRoot = this.modelRootSelect.value;
|
||||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
||||||
const newFolder = this.newFolderInput.value.trim();
|
const newFolder = this.newFolderInput.value.trim();
|
||||||
|
|
||||||
@@ -134,7 +143,7 @@ class MoveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async moveModel() {
|
async moveModel() {
|
||||||
const selectedRoot = this.loraRootSelect.value;
|
const selectedRoot = this.modelRootSelect.value;
|
||||||
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
|
||||||
const newFolder = this.newFolderInput.value.trim();
|
const newFolder = this.newFolderInput.value.trim();
|
||||||
|
|
||||||
@@ -191,11 +200,8 @@ class MoveManager {
|
|||||||
|
|
||||||
// Refresh folder tags after successful move
|
// Refresh folder tags after successful move
|
||||||
try {
|
try {
|
||||||
const foldersResponse = await fetch('/api/loras/folders');
|
const foldersData = await apiClient.fetchModelFolders();
|
||||||
if (foldersResponse.ok) {
|
updateFolderTags(foldersData.folders);
|
||||||
const foldersData = await foldersResponse.json();
|
|
||||||
updateFolderTags(foldersData.folders);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing folder tags:', 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 we were in bulk mode, exit it after successful move
|
||||||
if (this.bulkFilePaths && state.bulkMode) {
|
if (this.bulkFilePaths && state.bulkMode) {
|
||||||
toggleBulkMode();
|
bulkManager.toggleBulkMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { updatePanelPositions } from "../utils/uiHelpers.js";
|
import { updatePanelPositions } from "../utils/uiHelpers.js";
|
||||||
import { getCurrentPageState } from "../state/index.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";
|
import { setStorageItem, getStorageItem } from "../utils/storageHelpers.js";
|
||||||
/**
|
/**
|
||||||
* SearchManager - Handles search functionality across different pages
|
* SearchManager - Handles search functionality across different pages
|
||||||
@@ -318,6 +318,7 @@ export class SearchManager {
|
|||||||
filename: options.filename || false,
|
filename: options.filename || false,
|
||||||
modelname: options.modelname || false,
|
modelname: options.modelname || false,
|
||||||
tags: options.tags || false,
|
tags: options.tags || false,
|
||||||
|
creator: options.creator || false,
|
||||||
recursive: recursive
|
recursive: recursive
|
||||||
};
|
};
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
@@ -325,6 +326,7 @@ export class SearchManager {
|
|||||||
filename: options.filename || false,
|
filename: options.filename || false,
|
||||||
modelname: options.modelname || false,
|
modelname: options.modelname || false,
|
||||||
tags: options.tags || false,
|
tags: options.tags || false,
|
||||||
|
creator: options.creator || false,
|
||||||
recursive: recursive
|
recursive: recursive
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state } from '../state/index.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 { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js';
|
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js';
|
||||||
|
|
||||||
@@ -16,6 +16,9 @@ export class SettingsManager {
|
|||||||
// Ensure settings are loaded from localStorage
|
// Ensure settings are loaded from localStorage
|
||||||
this.loadSettingsFromStorage();
|
this.loadSettingsFromStorage();
|
||||||
|
|
||||||
|
// Sync settings to backend if needed
|
||||||
|
this.syncSettingsToBackendIfNeeded();
|
||||||
|
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +26,13 @@ export class SettingsManager {
|
|||||||
// Get saved settings from localStorage
|
// Get saved settings from localStorage
|
||||||
const savedSettings = getStorageItem('settings');
|
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
|
// Apply saved settings to state if available
|
||||||
if (savedSettings) {
|
if (savedSettings) {
|
||||||
state.global.settings = { ...state.global.settings, ...savedSettings };
|
state.global.settings = { ...state.global.settings, ...savedSettings };
|
||||||
@@ -38,6 +48,11 @@ export class SettingsManager {
|
|||||||
state.global.settings.optimizeExampleImages = true;
|
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
|
// Set default for cardInfoDisplay if undefined
|
||||||
if (state.global.settings.cardInfoDisplay === undefined) {
|
if (state.global.settings.cardInfoDisplay === undefined) {
|
||||||
state.global.settings.cardInfoDisplay = 'always';
|
state.global.settings.cardInfoDisplay = 'always';
|
||||||
@@ -67,6 +82,60 @@ export class SettingsManager {
|
|||||||
if (state.global.settings.base_model_path_mappings === undefined) {
|
if (state.global.settings.base_model_path_mappings === undefined) {
|
||||||
state.global.settings.base_model_path_mappings = {};
|
state.global.settings.base_model_path_mappings = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set default for defaultEmbeddingRoot if undefined
|
||||||
|
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() {
|
initialize() {
|
||||||
@@ -136,6 +205,12 @@ export class SettingsManager {
|
|||||||
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
|
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
|
// Set download path template setting
|
||||||
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate');
|
const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate');
|
||||||
if (downloadPathTemplateSelect) {
|
if (downloadPathTemplateSelect) {
|
||||||
@@ -143,6 +218,12 @@ export class SettingsManager {
|
|||||||
this.updatePathTemplatePreview();
|
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
|
// Load base model path mappings
|
||||||
this.loadBaseModelMappings();
|
this.loadBaseModelMappings();
|
||||||
|
|
||||||
@@ -152,7 +233,8 @@ export class SettingsManager {
|
|||||||
// Load default checkpoint root
|
// Load default checkpoint root
|
||||||
await this.loadCheckpointRoots();
|
await this.loadCheckpointRoots();
|
||||||
|
|
||||||
// Backend settings are loaded from the template directly
|
// Load default embedding root
|
||||||
|
await this.loadEmbeddingRoots();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadLoraRoots() {
|
async loadLoraRoots() {
|
||||||
@@ -185,7 +267,7 @@ export class SettingsManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set selected value from settings
|
// Set selected value from settings
|
||||||
const defaultRoot = state.global.settings.default_loras_root || '';
|
const defaultRoot = state.global.settings.default_lora_root || '';
|
||||||
defaultLoraRootSelect.value = defaultRoot;
|
defaultLoraRootSelect.value = defaultRoot;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -233,6 +315,45 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadEmbeddingRoots() {
|
||||||
|
try {
|
||||||
|
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||||
|
if (!defaultEmbeddingRootSelect) return;
|
||||||
|
|
||||||
|
// Fetch embedding roots
|
||||||
|
const response = await fetch('/api/embeddings/roots');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch embedding roots');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.roots || data.roots.length === 0) {
|
||||||
|
throw new Error('No embedding roots found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing options except the first one (No Default)
|
||||||
|
const noDefaultOption = defaultEmbeddingRootSelect.querySelector('option[value=""]');
|
||||||
|
defaultEmbeddingRootSelect.innerHTML = '';
|
||||||
|
defaultEmbeddingRootSelect.appendChild(noDefaultOption);
|
||||||
|
|
||||||
|
// Add options for each root
|
||||||
|
data.roots.forEach(root => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = root;
|
||||||
|
option.textContent = root;
|
||||||
|
defaultEmbeddingRootSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set selected value from settings
|
||||||
|
const defaultRoot = state.global.settings.default_embedding_root || '';
|
||||||
|
defaultEmbeddingRootSelect.value = defaultRoot;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading embedding roots:', error);
|
||||||
|
showToast('Failed to load embedding roots: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadBaseModelMappings() {
|
loadBaseModelMappings() {
|
||||||
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
|
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
|
||||||
if (!mappingsContainer) return;
|
if (!mappingsContainer) return;
|
||||||
@@ -448,8 +569,12 @@ export class SettingsManager {
|
|||||||
state.global.settings.autoplayOnHover = value;
|
state.global.settings.autoplayOnHover = value;
|
||||||
} else if (settingKey === 'optimize_example_images') {
|
} else if (settingKey === 'optimize_example_images') {
|
||||||
state.global.settings.optimizeExampleImages = value;
|
state.global.settings.optimizeExampleImages = value;
|
||||||
|
} else if (settingKey === 'auto_download_example_images') {
|
||||||
|
state.global.settings.autoDownloadExampleImages = value;
|
||||||
} else if (settingKey === 'compact_mode') {
|
} else if (settingKey === 'compact_mode') {
|
||||||
state.global.settings.compactMode = value;
|
state.global.settings.compactMode = value;
|
||||||
|
} else if (settingKey === 'include_trigger_words') {
|
||||||
|
state.global.settings.includeTriggerWords = value;
|
||||||
} else {
|
} else {
|
||||||
// For any other settings that might be added in the future
|
// For any other settings that might be added in the future
|
||||||
state.global.settings[settingKey] = value;
|
state.global.settings[settingKey] = value;
|
||||||
@@ -460,7 +585,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// For backend settings, make API call
|
// For backend settings, make API call
|
||||||
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover', 'optimize_example_images', 'use_centralized_examples'].includes(settingKey)) {
|
if (['show_only_sfw'].includes(settingKey)) {
|
||||||
const payload = {};
|
const payload = {};
|
||||||
payload[settingKey] = value;
|
payload[settingKey] = value;
|
||||||
|
|
||||||
@@ -475,14 +600,23 @@ export class SettingsManager {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to save setting');
|
throw new Error('Failed to save setting');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||||
|
|
||||||
// Apply frontend settings immediately
|
// Apply frontend settings immediately
|
||||||
this.applyFrontendSettings();
|
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();
|
this.reloadContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,9 +639,11 @@ export class SettingsManager {
|
|||||||
|
|
||||||
// Update frontend state
|
// Update frontend state
|
||||||
if (settingKey === 'default_lora_root') {
|
if (settingKey === 'default_lora_root') {
|
||||||
state.global.settings.default_loras_root = value;
|
state.global.settings.default_lora_root = value;
|
||||||
} else if (settingKey === 'default_checkpoint_root') {
|
} else if (settingKey === 'default_checkpoint_root') {
|
||||||
state.global.settings.default_checkpoint_root = value;
|
state.global.settings.default_checkpoint_root = value;
|
||||||
|
} else if (settingKey === 'default_embedding_root') {
|
||||||
|
state.global.settings.default_embedding_root = value;
|
||||||
} else if (settingKey === 'display_density') {
|
} else if (settingKey === 'display_density') {
|
||||||
state.global.settings.displayDensity = value;
|
state.global.settings.displayDensity = value;
|
||||||
|
|
||||||
@@ -528,7 +664,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// For backend settings, make API call
|
// For backend settings, make API call
|
||||||
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'download_path_template') {
|
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_template') {
|
||||||
const payload = {};
|
const payload = {};
|
||||||
payload[settingKey] = value;
|
payload[settingKey] = value;
|
||||||
|
|
||||||
@@ -583,10 +719,7 @@ export class SettingsManager {
|
|||||||
// Update state
|
// Update state
|
||||||
state.global.settings[settingKey] = value;
|
state.global.settings[settingKey] = value;
|
||||||
|
|
||||||
// Save to localStorage if appropriate
|
setStorageItem('settings', state.global.settings);
|
||||||
if (!settingKey.includes('api_key')) { // Don't store API keys in localStorage for security
|
|
||||||
setStorageItem('settings', state.global.settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For backend settings, make API call
|
// For backend settings, make API call
|
||||||
const payload = {};
|
const payload = {};
|
||||||
@@ -665,83 +798,13 @@ export class SettingsManager {
|
|||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
}
|
} else if (this.currentPage === 'embeddings') {
|
||||||
}
|
// Reload the embeddings 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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFrontendSettings() {
|
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
|
// Apply autoplay setting to existing videos in card previews
|
||||||
const autoplayOnHover = state.global.settings.autoplayOnHover;
|
const autoplayOnHover = state.global.settings.autoplayOnHover;
|
||||||
document.querySelectorAll('.card-preview video').forEach(video => {
|
document.querySelectorAll('.card-preview video').forEach(video => {
|
||||||
|
|||||||
@@ -358,9 +358,10 @@ export class UpdateService {
|
|||||||
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
|
<i class="fas fa-check-circle" style="margin-right: 8px;"></i>
|
||||||
Successfully updated to ${newVersion}!
|
Successfully updated to ${newVersion}!
|
||||||
<br><br>
|
<br><br>
|
||||||
<small style="opacity: 0.8;">
|
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
|
||||||
Please restart ComfyUI to complete the update process.
|
Please restart ComfyUI or LoRA Manager to apply update.<br>
|
||||||
</small>
|
Make sure to reload your browser for both LoRA Manager and ComfyUI.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -370,10 +371,10 @@ export class UpdateService {
|
|||||||
this.updateAvailable = false;
|
this.updateAvailable = false;
|
||||||
|
|
||||||
// Refresh the modal content
|
// Refresh the modal content
|
||||||
setTimeout(() => {
|
// setTimeout(() => {
|
||||||
this.updateModalContent();
|
// this.updateModalContent();
|
||||||
this.showUpdateProgress(false);
|
// this.showUpdateProgress(false);
|
||||||
}, 2000);
|
// }, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple markdown parser for changelog items
|
// Simple markdown parser for changelog items
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export class FolderBrowser {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default lora root if available
|
// 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)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
loraRoot.value = defaultRoot;
|
loraRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class ImageProcessor {
|
|||||||
|
|
||||||
async handleUrlInput() {
|
async handleUrlInput() {
|
||||||
const urlInput = document.getElementById('imageUrlInput');
|
const urlInput = document.getElementById('imageUrlInput');
|
||||||
const errorElement = document.getElementById('urlError');
|
const errorElement = document.getElementById('importUrlError');
|
||||||
const input = urlInput.value.trim();
|
const input = urlInput.value.trim();
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const state = {
|
|||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
@@ -83,12 +84,17 @@ export const state = {
|
|||||||
searchOptions: {
|
searchOptions: {
|
||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
|
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||||
|
bulkMode: false,
|
||||||
|
selectedModels: new Set(),
|
||||||
|
metadataCache: new Map(),
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
},
|
},
|
||||||
@@ -106,12 +112,16 @@ export const state = {
|
|||||||
filename: true,
|
filename: true,
|
||||||
modelname: true,
|
modelname: true,
|
||||||
tags: false,
|
tags: false,
|
||||||
|
creator: false,
|
||||||
recursive: false
|
recursive: false
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
|
bulkMode: false,
|
||||||
|
selectedModels: new Set(),
|
||||||
|
metadataCache: new Map(),
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
}
|
}
|
||||||
@@ -154,12 +164,43 @@ export const state = {
|
|||||||
get filters() { return this.pages[this.currentPageType].filters; },
|
get filters() { return this.pages[this.currentPageType].filters; },
|
||||||
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
||||||
|
|
||||||
get bulkMode() { return this.pages.loras.bulkMode; },
|
get bulkMode() {
|
||||||
set bulkMode(value) { this.pages.loras.bulkMode = value; },
|
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; },
|
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
||||||
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
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; },
|
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
||||||
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ class StatisticsManager {
|
|||||||
value: this.data.collection.checkpoint_count,
|
value: this.data.collection.checkpoint_count,
|
||||||
label: 'Checkpoints',
|
label: 'Checkpoints',
|
||||||
format: 'number'
|
format: 'number'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'fas fa-code',
|
||||||
|
value: this.data.collection.embedding_count,
|
||||||
|
label: 'Embeddings',
|
||||||
|
format: 'number'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -195,7 +201,9 @@ class StatisticsManager {
|
|||||||
if (!this.data.collection) return 0;
|
if (!this.data.collection) return 0;
|
||||||
|
|
||||||
const totalModels = this.data.collection.total_models;
|
const totalModels = this.data.collection.total_models;
|
||||||
const unusedModels = this.data.collection.unused_loras + this.data.collection.unused_checkpoints;
|
const unusedModels = this.data.collection.unused_loras +
|
||||||
|
this.data.collection.unused_checkpoints +
|
||||||
|
this.data.collection.unused_embeddings;
|
||||||
const usedModels = totalModels - unusedModels;
|
const usedModels = totalModels - unusedModels;
|
||||||
|
|
||||||
return totalModels > 0 ? (usedModels / totalModels) * 100 : 0;
|
return totalModels > 0 ? (usedModels / totalModels) * 100 : 0;
|
||||||
@@ -233,12 +241,17 @@ class StatisticsManager {
|
|||||||
if (!ctx || !this.data.collection) return;
|
if (!ctx || !this.data.collection) return;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: ['LoRAs', 'Checkpoints'],
|
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [this.data.collection.lora_count, this.data.collection.checkpoint_count],
|
data: [
|
||||||
|
this.data.collection.lora_count,
|
||||||
|
this.data.collection.checkpoint_count,
|
||||||
|
this.data.collection.embedding_count
|
||||||
|
],
|
||||||
backgroundColor: [
|
backgroundColor: [
|
||||||
'oklch(68% 0.28 256)',
|
'oklch(68% 0.28 256)',
|
||||||
'oklch(68% 0.28 200)'
|
'oklch(68% 0.28 200)',
|
||||||
|
'oklch(68% 0.28 120)'
|
||||||
],
|
],
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color')
|
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color')
|
||||||
@@ -266,8 +279,13 @@ class StatisticsManager {
|
|||||||
|
|
||||||
const loraData = this.data.baseModels.loras;
|
const loraData = this.data.baseModels.loras;
|
||||||
const checkpointData = this.data.baseModels.checkpoints;
|
const checkpointData = this.data.baseModels.checkpoints;
|
||||||
|
const embeddingData = this.data.baseModels.embeddings;
|
||||||
|
|
||||||
const allModels = new Set([...Object.keys(loraData), ...Object.keys(checkpointData)]);
|
const allModels = new Set([
|
||||||
|
...Object.keys(loraData),
|
||||||
|
...Object.keys(checkpointData),
|
||||||
|
...Object.keys(embeddingData)
|
||||||
|
]);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: Array.from(allModels),
|
labels: Array.from(allModels),
|
||||||
@@ -281,6 +299,11 @@ class StatisticsManager {
|
|||||||
label: 'Checkpoints',
|
label: 'Checkpoints',
|
||||||
data: Array.from(allModels).map(model => checkpointData[model] || 0),
|
data: Array.from(allModels).map(model => checkpointData[model] || 0),
|
||||||
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
|
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Embeddings',
|
||||||
|
data: Array.from(allModels).map(model => embeddingData[model] || 0),
|
||||||
|
backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -325,6 +348,13 @@ class StatisticsManager {
|
|||||||
borderColor: 'oklch(68% 0.28 200)',
|
borderColor: 'oklch(68% 0.28 200)',
|
||||||
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
|
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
|
||||||
fill: true
|
fill: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Embedding Usage',
|
||||||
|
data: timeline.map(item => item.embedding_usage),
|
||||||
|
borderColor: 'oklch(68% 0.28 120)',
|
||||||
|
backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
|
||||||
|
fill: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -365,11 +395,13 @@ class StatisticsManager {
|
|||||||
|
|
||||||
const topLoras = this.data.usage.top_loras || [];
|
const topLoras = this.data.usage.top_loras || [];
|
||||||
const topCheckpoints = this.data.usage.top_checkpoints || [];
|
const topCheckpoints = this.data.usage.top_checkpoints || [];
|
||||||
|
const topEmbeddings = this.data.usage.top_embeddings || [];
|
||||||
|
|
||||||
// Combine and sort all models by usage
|
// Combine and sort all models by usage
|
||||||
const allModels = [
|
const allModels = [
|
||||||
...topLoras.map(m => ({ ...m, type: 'LoRA' })),
|
...topLoras.map(m => ({ ...m, type: 'LoRA' })),
|
||||||
...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' }))
|
...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' })),
|
||||||
|
...topEmbeddings.map(m => ({ ...m, type: 'Embedding' }))
|
||||||
].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10);
|
].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
@@ -377,9 +409,14 @@ class StatisticsManager {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Usage Count',
|
label: 'Usage Count',
|
||||||
data: allModels.map(model => model.usage_count),
|
data: allModels.map(model => model.usage_count),
|
||||||
backgroundColor: allModels.map(model =>
|
backgroundColor: allModels.map(model => {
|
||||||
model.type === 'LoRA' ? 'oklch(68% 0.28 256)' : 'oklch(68% 0.28 200)'
|
switch(model.type) {
|
||||||
)
|
case 'LoRA': return 'oklch(68% 0.28 256)';
|
||||||
|
case 'Checkpoint': return 'oklch(68% 0.28 200)';
|
||||||
|
case 'Embedding': return 'oklch(68% 0.28 120)';
|
||||||
|
default: return 'oklch(68% 0.28 256)';
|
||||||
|
}
|
||||||
|
})
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -404,12 +441,17 @@ class StatisticsManager {
|
|||||||
if (!ctx || !this.data.collection) return;
|
if (!ctx || !this.data.collection) return;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: ['LoRAs', 'Checkpoints'],
|
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [this.data.collection.lora_size, this.data.collection.checkpoint_size],
|
data: [
|
||||||
|
this.data.collection.lora_size,
|
||||||
|
this.data.collection.checkpoint_size,
|
||||||
|
this.data.collection.embedding_size
|
||||||
|
],
|
||||||
backgroundColor: [
|
backgroundColor: [
|
||||||
'oklch(68% 0.28 256)',
|
'oklch(68% 0.28 256)',
|
||||||
'oklch(68% 0.28 200)'
|
'oklch(68% 0.28 200)',
|
||||||
|
'oklch(68% 0.28 120)'
|
||||||
]
|
]
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
@@ -443,10 +485,12 @@ class StatisticsManager {
|
|||||||
|
|
||||||
const loraData = this.data.storage.loras || [];
|
const loraData = this.data.storage.loras || [];
|
||||||
const checkpointData = this.data.storage.checkpoints || [];
|
const checkpointData = this.data.storage.checkpoints || [];
|
||||||
|
const embeddingData = this.data.storage.embeddings || [];
|
||||||
|
|
||||||
const allData = [
|
const allData = [
|
||||||
...loraData.map(item => ({ ...item, type: 'LoRA' })),
|
...loraData.map(item => ({ ...item, type: 'LoRA' })),
|
||||||
...checkpointData.map(item => ({ ...item, type: 'Checkpoint' }))
|
...checkpointData.map(item => ({ ...item, type: 'Checkpoint' })),
|
||||||
|
...embeddingData.map(item => ({ ...item, type: 'Embedding' }))
|
||||||
];
|
];
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
@@ -458,9 +502,14 @@ class StatisticsManager {
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
type: item.type
|
type: item.type
|
||||||
})),
|
})),
|
||||||
backgroundColor: allData.map(item =>
|
backgroundColor: allData.map(item => {
|
||||||
item.type === 'LoRA' ? 'oklch(68% 0.28 256 / 0.6)' : 'oklch(68% 0.28 200 / 0.6)'
|
switch(item.type) {
|
||||||
)
|
case 'LoRA': return 'oklch(68% 0.28 256 / 0.6)';
|
||||||
|
case 'Checkpoint': return 'oklch(68% 0.28 200 / 0.6)';
|
||||||
|
case 'Embedding': return 'oklch(68% 0.28 120 / 0.6)';
|
||||||
|
default: return 'oklch(68% 0.28 256 / 0.6)';
|
||||||
|
}
|
||||||
|
})
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -502,6 +551,7 @@ class StatisticsManager {
|
|||||||
renderTopModelsLists() {
|
renderTopModelsLists() {
|
||||||
this.renderTopLorasList();
|
this.renderTopLorasList();
|
||||||
this.renderTopCheckpointsList();
|
this.renderTopCheckpointsList();
|
||||||
|
this.renderTopEmbeddingsList();
|
||||||
this.renderLargestModelsList();
|
this.renderLargestModelsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,17 +605,44 @@ class StatisticsManager {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderTopEmbeddingsList() {
|
||||||
|
const container = document.getElementById('topEmbeddingsList');
|
||||||
|
if (!container || !this.data.usage?.top_embeddings) return;
|
||||||
|
|
||||||
|
const topEmbeddings = this.data.usage.top_embeddings;
|
||||||
|
|
||||||
|
if (topEmbeddings.length === 0) {
|
||||||
|
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = topEmbeddings.map(embedding => `
|
||||||
|
<div class="model-item">
|
||||||
|
<img src="${embedding.preview_url || '/loras_static/images/no-preview.png'}"
|
||||||
|
alt="${embedding.name}" class="model-preview"
|
||||||
|
onerror="this.src='/loras_static/images/no-preview.png'">
|
||||||
|
<div class="model-info">
|
||||||
|
<div class="model-name" title="${embedding.name}">${embedding.name}</div>
|
||||||
|
<div class="model-meta">${embedding.base_model} • ${embedding.folder}</div>
|
||||||
|
</div>
|
||||||
|
<div class="model-usage">${embedding.usage_count}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
renderLargestModelsList() {
|
renderLargestModelsList() {
|
||||||
const container = document.getElementById('largestModelsList');
|
const container = document.getElementById('largestModelsList');
|
||||||
if (!container || !this.data.storage) return;
|
if (!container || !this.data.storage) return;
|
||||||
|
|
||||||
const loraModels = this.data.storage.loras || [];
|
const loraModels = this.data.storage.loras || [];
|
||||||
const checkpointModels = this.data.storage.checkpoints || [];
|
const checkpointModels = this.data.storage.checkpoints || [];
|
||||||
|
const embeddingModels = this.data.storage.embeddings || [];
|
||||||
|
|
||||||
// Combine and sort by size
|
// Combine and sort by size
|
||||||
const allModels = [
|
const allModels = [
|
||||||
...loraModels.map(m => ({ ...m, type: 'LoRA' })),
|
...loraModels.map(m => ({ ...m, type: 'LoRA' })),
|
||||||
...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' }))
|
...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' })),
|
||||||
|
...embeddingModels.map(m => ({ ...m, type: 'Embedding' }))
|
||||||
].sort((a, b) => b.size - a.size).slice(0, 10);
|
].sort((a, b) => b.size - a.size).slice(0, 10);
|
||||||
|
|
||||||
if (allModels.length === 0) {
|
if (allModels.length === 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { VirtualScroller } from './VirtualScroller.js';
|
import { VirtualScroller } from './VirtualScroller.js';
|
||||||
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.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';
|
import { showToast } from './uiHelpers.js';
|
||||||
|
|
||||||
// Function to dynamically import the appropriate card creator based on page type
|
// Function to dynamically import the appropriate card creator based on page type
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getModelApiClient } from '../api/baseModelApi.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = getModelApiClient();
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ export function migrateStorageItems() {
|
|||||||
'recipes_search_prefs',
|
'recipes_search_prefs',
|
||||||
'checkpoints_search_prefs',
|
'checkpoints_search_prefs',
|
||||||
'show_update_notifications',
|
'show_update_notifications',
|
||||||
'last_update_check'
|
'last_update_check',
|
||||||
|
'dismissed_banners'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Migrate each known key
|
// Migrate each known key
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||||
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
|
||||||
|
|
||||||
@@ -285,6 +285,76 @@ export function getNSFWLevelName(level) {
|
|||||||
return 'Unknown';
|
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
|
* Sends LoRA syntax to the active ComfyUI workflow
|
||||||
* @param {string} loraSyntax - The LoRA syntax to send
|
* @param {string} loraSyntax - The LoRA syntax to send
|
||||||
|
|||||||
@@ -82,6 +82,11 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<!-- Banner component -->
|
||||||
|
<div id="banner-container" class="banner-container" style="display: none;">
|
||||||
|
<!-- Banners will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if is_initializing %}
|
{% if is_initializing %}
|
||||||
<!-- Show initialization component when initializing -->
|
<!-- Show initialization component when initializing -->
|
||||||
{% include 'components/initialization.html' %}
|
{% include 'components/initialization.html' %}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
{% block init_title %}Initializing Checkpoints Manager{% endblock %}
|
{% block init_title %}Initializing Checkpoints Manager{% endblock %}
|
||||||
{% block init_message %}Scanning and building checkpoints cache. This may take a few moments...{% endblock %}
|
{% block init_message %}Scanning and building checkpoints cache. This may take a few moments...{% endblock %}
|
||||||
{% block init_check_url %}/api/checkpoints?page=1&page_size=1{% endblock %}
|
{% block init_check_url %}/api/checkpoints/list?page=1&page_size=1{% endblock %}
|
||||||
|
|
||||||
{% block additional_components %}
|
{% block additional_components %}
|
||||||
|
|
||||||
|
|||||||
@@ -50,14 +50,11 @@
|
|||||||
<i class="fas fa-cloud-download-alt"></i> Download
|
<i class="fas fa-cloud-download-alt"></i> Download
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Conditional buttons based on page -->
|
|
||||||
{% if request.path == '/loras' %}
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button id="bulkOperationsBtn" data-action="bulk" title="Bulk Operations (Press B)">
|
<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>
|
<i class="fas fa-th-large"></i> <span>Bulk <div class="shortcut-key">B</div></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
|
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
|
||||||
<i class="fas fa-clone"></i> Duplicates
|
<i class="fas fa-clone"></i> Duplicates
|
||||||
@@ -119,22 +116,22 @@
|
|||||||
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
|
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
|
||||||
</span>
|
</span>
|
||||||
<div class="bulk-operations-actions">
|
<div class="bulk-operations-actions">
|
||||||
<button onclick="bulkManager.sendAllLorasToWorkflow()" title="Send all selected LoRAs to workflow">
|
<button data-action="send-to-workflow" title="Send all selected LoRAs to workflow">
|
||||||
<i class="fas fa-arrow-right"></i> Send to Workflow
|
<i class="fas fa-arrow-right"></i> Send to Workflow
|
||||||
</button>
|
</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
|
<i class="fas fa-copy"></i> Copy All
|
||||||
</button>
|
</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
|
<i class="fas fa-sync-alt"></i> Refresh All
|
||||||
</button>
|
</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
|
<i class="fas fa-folder-open"></i> Move All
|
||||||
</button>
|
</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
|
<i class="fas fa-trash"></i> Delete All
|
||||||
</button>
|
</button>
|
||||||
<button onclick="bulkManager.clearSelection()" title="Clear selection">
|
<button data-action="clear" title="Clear selection">
|
||||||
<i class="fas fa-times"></i> Clear
|
<i class="fas fa-times"></i> Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -86,15 +86,18 @@
|
|||||||
<div class="search-option-tag active" data-option="filename">Filename</div>
|
<div class="search-option-tag active" data-option="filename">Filename</div>
|
||||||
<div class="search-option-tag active" data-option="modelname">Checkpoint Name</div>
|
<div class="search-option-tag active" data-option="modelname">Checkpoint Name</div>
|
||||||
<div class="search-option-tag active" data-option="tags">Tags</div>
|
<div class="search-option-tag active" data-option="tags">Tags</div>
|
||||||
|
<div class="search-option-tag" data-option="creator">Creator</div>
|
||||||
{% elif request.path == '/embeddings' %}
|
{% elif request.path == '/embeddings' %}
|
||||||
<div class="search-option-tag active" data-option="filename">Filename</div>
|
<div class="search-option-tag active" data-option="filename">Filename</div>
|
||||||
<div class="search-option-tag active" data-option="modelname">Embedding Name</div>
|
<div class="search-option-tag active" data-option="modelname">Embedding Name</div>
|
||||||
<div class="search-option-tag active" data-option="tags">Tags</div>
|
<div class="search-option-tag active" data-option="tags">Tags</div>
|
||||||
|
<div class="search-option-tag" data-option="creator">Creator</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Default options for LoRAs page -->
|
<!-- Default options for LoRAs page -->
|
||||||
<div class="search-option-tag active" data-option="filename">Filename</div>
|
<div class="search-option-tag active" data-option="filename">Filename</div>
|
||||||
<div class="search-option-tag active" data-option="modelname">Model Name</div>
|
<div class="search-option-tag active" data-option="modelname">Model Name</div>
|
||||||
<div class="search-option-tag active" data-option="tags">Tags</div>
|
<div class="search-option-tag active" data-option="tags">Tags</div>
|
||||||
|
<div class="search-option-tag" data-option="creator">Creator</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<i class="fas fa-download"></i> Fetch Image
|
<i class="fas fa-download"></i> Fetch Image
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="error-message" id="urlError"></div>
|
<div class="error-message" id="importUrlError"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
<div class="path-preview">
|
<div class="path-preview">
|
||||||
<label>Target Location Preview:</label>
|
<label>Target Location Preview:</label>
|
||||||
<div class="path-display" id="moveTargetPathDisplay">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>Select LoRA Root:</label>
|
<label id="moveRootLabel">Select Model Root:</label>
|
||||||
<select id="moveLoraRoot"></select>
|
<select id="moveModelRoot"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>Target Folder:</label>
|
<label>Target Folder:</label>
|
||||||
|
|||||||
@@ -128,6 +128,23 @@
|
|||||||
Set the default checkpoint root directory for downloads, imports and moves
|
Set the default checkpoint root directory for downloads, imports and moves
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="defaultEmbeddingRoot">Default Embedding Root</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control select-control">
|
||||||
|
<select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')">
|
||||||
|
<option value="">No Default</option>
|
||||||
|
<!-- Options will be loaded dynamically -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-help">
|
||||||
|
Set the default embedding root directory for downloads, imports and moves
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Default Path Customization Section -->
|
<!-- Default Path Customization Section -->
|
||||||
@@ -255,6 +272,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
@@ -273,6 +308,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
{% block init_title %}Initializing Embeddings Manager{% endblock %}
|
{% block init_title %}Initializing Embeddings Manager{% endblock %}
|
||||||
{% block init_message %}Scanning and building embeddings cache. This may take a few moments...{% endblock %}
|
{% block init_message %}Scanning and building embeddings cache. This may take a few moments...{% endblock %}
|
||||||
{% block init_check_url %}/api/embeddings?page=1&page_size=1{% endblock %}
|
{% block init_check_url %}/api/embeddings/list?page=1&page_size=1{% endblock %}
|
||||||
|
|
||||||
{% block additional_components %}
|
{% block additional_components %}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
{% block init_title %}Initializing LoRA Manager{% endblock %}
|
{% block init_title %}Initializing LoRA Manager{% endblock %}
|
||||||
{% block init_message %}Scanning and building LoRA cache. This may take a few minutes...{% endblock %}
|
{% block init_message %}Scanning and building LoRA cache. This may take a few minutes...{% endblock %}
|
||||||
{% block init_check_url %}/api/loras?page=1&page_size=1{% endblock %}
|
{% block init_check_url %}/api/loras/list?page=1&page_size=1{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
|||||||
@@ -98,6 +98,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Used Embeddings -->
|
||||||
|
<div class="list-container">
|
||||||
|
<h3><i class="fas fa-code"></i> Most Used Embeddings</h3>
|
||||||
|
<div class="model-list" id="topEmbeddingsList">
|
||||||
|
<!-- List will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Usage Distribution Chart -->
|
<!-- Usage Distribution Chart -->
|
||||||
<div class="chart-container full-width">
|
<div class="chart-container full-width">
|
||||||
<h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3>
|
<h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3>
|
||||||
|
|||||||
@@ -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
|
### v0.8.9
|
||||||
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
|
* **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
|
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ export function addJsonDisplayWidget(node, name, opts) {
|
|||||||
|
|
||||||
// Set initial height
|
// Set initial height
|
||||||
const defaultHeight = 200;
|
const defaultHeight = 200;
|
||||||
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
|
||||||
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
|
|
||||||
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
|
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "block",
|
display: "block",
|
||||||
@@ -113,16 +110,6 @@ export function addJsonDisplayWidget(node, name, opts) {
|
|||||||
widgetValue = v;
|
widgetValue = v;
|
||||||
displayJson(widgetValue, widget);
|
displayJson(widgetValue, widget);
|
||||||
},
|
},
|
||||||
getMinHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
|
|
||||||
},
|
|
||||||
getMaxHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
|
|
||||||
},
|
|
||||||
getHeight: function() {
|
|
||||||
// Return actual container height to reduce the gap
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
|
|
||||||
},
|
|
||||||
hideOnZoom: true
|
hideOnZoom: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,39 +4,11 @@ import {
|
|||||||
LORA_PATTERN,
|
LORA_PATTERN,
|
||||||
collectActiveLorasFromChain,
|
collectActiveLorasFromChain,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback
|
chainCallback,
|
||||||
|
mergeLoras
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.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({
|
app.registerExtension({
|
||||||
name: "LoraManager.LoraLoader",
|
name: "LoraManager.LoraLoader",
|
||||||
|
|
||||||
@@ -184,6 +156,7 @@ app.registerExtension({
|
|||||||
|
|
||||||
// Update input widget callback
|
// Update input widget callback
|
||||||
const inputWidget = this.widgets[0];
|
const inputWidget = this.widgets[0];
|
||||||
|
inputWidget.options.getMaxHeight = () => 100;
|
||||||
this.inputWidget = inputWidget;
|
this.inputWidget = inputWidget;
|
||||||
inputWidget.callback = (value) => {
|
inputWidget.callback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
|
|||||||
@@ -4,39 +4,11 @@ import {
|
|||||||
getActiveLorasFromNode,
|
getActiveLorasFromNode,
|
||||||
collectActiveLorasFromChain,
|
collectActiveLorasFromChain,
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback
|
chainCallback,
|
||||||
|
mergeLoras
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.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({
|
app.registerExtension({
|
||||||
name: "LoraManager.LoraStacker",
|
name: "LoraManager.LoraStacker",
|
||||||
|
|
||||||
@@ -105,6 +77,7 @@ app.registerExtension({
|
|||||||
|
|
||||||
// Update input widget callback
|
// Update input widget callback
|
||||||
const inputWidget = this.widgets[0];
|
const inputWidget = this.widgets[0];
|
||||||
|
inputWidget.options.getMaxHeight = () => 100;
|
||||||
this.inputWidget = inputWidget;
|
this.inputWidget = inputWidget;
|
||||||
inputWidget.callback = (value) => {
|
inputWidget.callback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection } from "./loras_widget_components.js";
|
||||||
import { createToggle, createArrowButton, PreviewTooltip } from "./loras_widget_components.js";
|
|
||||||
import {
|
import {
|
||||||
parseLoraValue,
|
parseLoraValue,
|
||||||
formatLoraValue,
|
formatLoraValue,
|
||||||
@@ -11,7 +10,7 @@ import {
|
|||||||
CONTAINER_PADDING,
|
CONTAINER_PADDING,
|
||||||
EMPTY_CONTAINER_HEIGHT
|
EMPTY_CONTAINER_HEIGHT
|
||||||
} from "./loras_widget_utils.js";
|
} from "./loras_widget_utils.js";
|
||||||
import { initDrag, createContextMenu, initHeaderDrag } from "./loras_widget_events.js";
|
import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
|
||||||
|
|
||||||
export function addLorasWidget(node, name, opts, callback) {
|
export function addLorasWidget(node, name, opts, callback) {
|
||||||
// Create container for loras
|
// Create container for loras
|
||||||
@@ -20,9 +19,6 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
// Set initial height using CSS variables approach
|
// Set initial height using CSS variables approach
|
||||||
const defaultHeight = 200;
|
const defaultHeight = 200;
|
||||||
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
|
||||||
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
|
|
||||||
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
|
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -42,6 +38,30 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
// Create preview tooltip instance
|
// Create preview tooltip instance
|
||||||
const previewTooltip = new PreviewTooltip();
|
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
|
// Function to render loras from data
|
||||||
const renderLoras = (value, widget) => {
|
const renderLoras = (value, widget) => {
|
||||||
// Clear existing content
|
// Clear existing content
|
||||||
@@ -185,6 +205,26 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
marginBottom: "4px",
|
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
|
// Add double-click handler to toggle clip entry
|
||||||
loraEl.addEventListener('dblclick', (e) => {
|
loraEl.addEventListener('dblclick', (e) => {
|
||||||
// Skip if clicking on toggle or strength control areas
|
// Skip if clicking on toggle or strength control areas
|
||||||
@@ -220,6 +260,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
|
// Create toggle for this lora
|
||||||
const toggle = createToggle(active, (newActive) => {
|
const toggle = createToggle(active, (newActive) => {
|
||||||
// Update this lora's active state
|
// Update this lora's active state
|
||||||
@@ -416,6 +462,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
minWidth: "0", // Allow shrinking
|
minWidth: "0", // Allow shrinking
|
||||||
});
|
});
|
||||||
|
|
||||||
|
leftSection.appendChild(dragHandle); // Add drag handle first
|
||||||
leftSection.appendChild(toggle);
|
leftSection.appendChild(toggle);
|
||||||
leftSection.appendChild(nameEl);
|
leftSection.appendChild(nameEl);
|
||||||
|
|
||||||
@@ -424,6 +471,9 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
container.appendChild(loraEl);
|
container.appendChild(loraEl);
|
||||||
|
|
||||||
|
// Update selection state
|
||||||
|
updateEntrySelection(loraEl, name === selectedLora);
|
||||||
|
|
||||||
// If expanded, show the clip entry
|
// If expanded, show the clip entry
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
totalVisibleEntries++;
|
totalVisibleEntries++;
|
||||||
@@ -444,6 +494,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
marginTop: "-2px"
|
marginTop: "-2px"
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store the same lora name in clip entry dataset
|
||||||
|
clipEl.dataset.loraName = name;
|
||||||
|
clipEl.dataset.active = active;
|
||||||
|
|
||||||
// Create clip name display
|
// Create clip name display
|
||||||
const clipNameEl = document.createElement("div");
|
const clipNameEl = document.createElement("div");
|
||||||
clipNameEl.textContent = "[clip] " + name;
|
clipNameEl.textContent = "[clip] " + name;
|
||||||
@@ -601,7 +655,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Calculate height based on number of loras and fixed sizes
|
// Calculate height based on number of loras and fixed sizes
|
||||||
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 10) * LORA_ENTRY_HEIGHT);
|
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 12) * LORA_ENTRY_HEIGHT);
|
||||||
updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
|
updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -655,23 +709,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
widgetValue = updatedValue;
|
widgetValue = updatedValue;
|
||||||
renderLoras(widgetValue, widget);
|
renderLoras(widgetValue, widget);
|
||||||
},
|
},
|
||||||
getMinHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
|
|
||||||
},
|
|
||||||
getMaxHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
|
|
||||||
},
|
|
||||||
getHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
|
|
||||||
},
|
|
||||||
hideOnZoom: true,
|
hideOnZoom: true,
|
||||||
selectOn: ['click', 'focus'],
|
selectOn: ['click', 'focus']
|
||||||
afterResize: function(node) {
|
|
||||||
// Re-render after node resize
|
|
||||||
if (this.value && this.value.length > 0) {
|
|
||||||
renderLoras(this.value, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
widget.value = defaultValue;
|
widget.value = defaultValue;
|
||||||
@@ -685,6 +724,8 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
widget.onRemove = () => {
|
widget.onRemove = () => {
|
||||||
container.remove();
|
container.remove();
|
||||||
previewTooltip.cleanup();
|
previewTooltip.cleanup();
|
||||||
|
// Remove keyboard event listener
|
||||||
|
container.removeEventListener('keydown', handleKeyboardNavigation);
|
||||||
};
|
};
|
||||||
|
|
||||||
return { minWidth: 400, minHeight: defaultHeight, widget };
|
return { minWidth: 400, minHeight: defaultHeight, widget };
|
||||||
|
|||||||
@@ -78,6 +78,87 @@ export function createArrowButton(direction, onClick) {
|
|||||||
return button;
|
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
|
// Function to create menu item
|
||||||
export function createMenuItem(text, icon, onClick) {
|
export function createMenuItem(text, icon, onClick) {
|
||||||
const menuItem = document.createElement('div');
|
const menuItem = document.createElement('div');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
import { createMenuItem } from "./loras_widget_components.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast } from "./loras_widget_utils.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
|
// Function to handle strength adjustment via dragging
|
||||||
export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false) {
|
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
|
// Function to create context menu
|
||||||
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
|
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
|
||||||
// Hide preview tooltip first
|
// Hide preview tooltip first
|
||||||
@@ -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
|
// Add separator
|
||||||
const separator1 = document.createElement('div');
|
const separator1 = document.createElement('div');
|
||||||
Object.assign(separator1.style, {
|
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)',
|
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(viewOnCivitaiOption);
|
||||||
menu.appendChild(deleteOption);
|
menu.appendChild(deleteOption);
|
||||||
menu.appendChild(separator1);
|
menu.appendChild(separator1);
|
||||||
|
menu.appendChild(moveUpOption);
|
||||||
|
menu.appendChild(moveDownOption);
|
||||||
|
menu.appendChild(moveTopOption);
|
||||||
|
menu.appendChild(moveBottomOption);
|
||||||
|
menu.appendChild(orderSeparator);
|
||||||
menu.appendChild(copyNotesOption);
|
menu.appendChild(copyNotesOption);
|
||||||
menu.appendChild(copyTriggerWordsOption);
|
menu.appendChild(copyTriggerWordsOption);
|
||||||
menu.appendChild(separator2);
|
menu.appendChild(separator2);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
|
|||||||
// Fixed sizes for component calculations
|
// Fixed sizes for component calculations
|
||||||
export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
|
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 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 CONTAINER_PADDING = 12; // Top and bottom padding
|
||||||
export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ export function addTagsWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
// Set initial height
|
// Set initial height
|
||||||
const defaultHeight = 150;
|
const defaultHeight = 150;
|
||||||
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
|
||||||
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
|
|
||||||
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
|
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -199,23 +196,8 @@ export function addTagsWidget(node, name, opts, callback) {
|
|||||||
widgetValue = v;
|
widgetValue = v;
|
||||||
renderTags(widgetValue, widget);
|
renderTags(widgetValue, widget);
|
||||||
},
|
},
|
||||||
getMinHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
|
|
||||||
},
|
|
||||||
getMaxHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
|
|
||||||
},
|
|
||||||
getHeight: function() {
|
|
||||||
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
|
|
||||||
},
|
|
||||||
hideOnZoom: true,
|
hideOnZoom: true,
|
||||||
selectOn: ['click', 'focus'],
|
selectOn: ['click', 'focus']
|
||||||
afterResize: function(node) {
|
|
||||||
// Re-render tags after node resize
|
|
||||||
if (this.value && this.value.length > 0) {
|
|
||||||
renderTags(this.value, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set initial value
|
// Set initial value
|
||||||
|
|||||||
@@ -184,3 +184,46 @@ export function updateConnectedTriggerWords(node, loraNames) {
|
|||||||
}).catch(err => console.error("Error fetching trigger words:", err));
|
}).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 {
|
import {
|
||||||
LORA_PATTERN,
|
LORA_PATTERN,
|
||||||
getActiveLorasFromNode,
|
getActiveLorasFromNode,
|
||||||
collectActiveLorasFromChain,
|
|
||||||
updateConnectedTriggerWords,
|
updateConnectedTriggerWords,
|
||||||
chainCallback
|
chainCallback,
|
||||||
|
mergeLoras
|
||||||
} from "./utils.js";
|
} from "./utils.js";
|
||||||
import { addLorasWidget } from "./loras_widget.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({
|
app.registerExtension({
|
||||||
name: "LoraManager.WanVideoLoraSelect",
|
name: "LoraManager.WanVideoLoraSelect",
|
||||||
|
|
||||||
@@ -107,6 +78,7 @@ app.registerExtension({
|
|||||||
|
|
||||||
// Update input widget callback
|
// Update input widget callback
|
||||||
const inputWidget = this.widgets[1];
|
const inputWidget = this.widgets[1];
|
||||||
|
inputWidget.options.getMaxHeight = () => 100;
|
||||||
this.inputWidget = inputWidget;
|
this.inputWidget = inputWidget;
|
||||||
inputWidget.callback = (value) => {
|
inputWidget.callback = (value) => {
|
||||||
if (isUpdating) return;
|
if (isUpdating) return;
|
||||||
|
|||||||
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