Compare commits

..

1 Commits

Author SHA1 Message Date
Will Miao
68c5f79a67 Refactor showcase and modal components for improved functionality and performance
- Removed unused showcase toggle functionality from ModelCard and ModelModal.
- Simplified metadata panel handling in MediaUtils and MetadataPanel, transitioning to button-based visibility instead of hover.
- Enhanced showcase rendering logic in ShowcaseView to support new layout and navigation features.
- Updated event handling for media controls and thumbnail navigation to streamline user interactions.
- Improved example image import functionality and error handling.
- Cleaned up redundant code and comments across various components for better readability and maintainability.
2025-07-27 15:52:09 +08:00
114 changed files with 2575 additions and 6692 deletions

View File

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

View File

@@ -5,7 +5,6 @@ from typing import List
import logging import logging
import sys import sys
import json import json
import urllib.parse
# 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
@@ -61,9 +60,6 @@ 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)
@@ -205,20 +201,16 @@ 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_paths = sorted(merged_map.values(), key=lambda p: p.lower()) unique_checkpoint_paths = sorted(checkpoint_map.values(), key=lambda p: p.lower())
unique_unet_paths = sorted(unet_map.values(), key=lambda p: p.lower())
# Split back into checkpoints and unet roots for class properties # Store individual paths in class properties
self.checkpoints_roots = [p for p in unique_paths if p in checkpoint_map.values()] self.checkpoints_roots = unique_checkpoint_paths
self.unet_roots = [p for p in unique_paths if p in unet_map.values()] self.unet_roots = unique_unet_paths
all_paths = unique_paths # Combine all checkpoint-related paths for return value
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 "[]"))
@@ -276,10 +268,8 @@ class Config:
for path, route in self._route_mappings.items(): for path, route in self._route_mappings.items():
if real_path.startswith(path): if real_path.startswith(path):
relative_path = os.path.relpath(real_path, path).replace(os.sep, '/') relative_path = os.path.relpath(real_path, path)
safe_parts = [urllib.parse.quote(part) for part in relative_path.split('/')] return f'{route}/{relative_path.replace(os.sep, "/")}'
safe_path = '/'.join(safe_parts)
return f'{route}/{safe_path}'
return "" return ""

View File

@@ -146,33 +146,45 @@ 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)
# Wrapped async function, compatible with both stable and nightly # Define the wrapped async function - NOTE: Updated signature with prompt_id and unique_id!
async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None, *args, **kwargs): 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):
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)}")
# Call original function with all args/kwargs # Execute the original async function with ALL parameters in the correct order
results = await original_map_node_over_list( results = await original_map_node_over_list(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb)
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:

View File

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

View File

@@ -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_hash(lora_entry['hash']) exists_locally = lora_scanner.has_lora_hash(lora_entry['hash'])
if exists_locally: if exists_locally:
try: try:
local_path = lora_scanner.get_path_by_hash(lora_entry['hash']) local_path = lora_scanner.get_lora_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]

View File

@@ -181,30 +181,13 @@ 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", resource.get("versionName", "")), 'version': resource.get("modelVersionName", ""),
'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,

View File

@@ -101,11 +101,6 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource.get("type", "lora") == "lora": if resource.get("type", "lora") == "lora":
lora_hash = resource.get("hash", "") lora_hash = resource.get("hash", "")
# Skip LoRAs without proper identification (hash or modelVersionId)
if not lora_hash and not resource.get("modelVersionId"):
logger.debug(f"Skipping LoRA resource '{resource.get('name', 'Unknown')}' - no hash or modelVersionId")
continue
# Skip if we've already added this LoRA by hash # Skip if we've already added this LoRA by hash
if lora_hash and lora_hash in added_loras: if lora_hash and lora_hash in added_loras:
continue continue
@@ -158,6 +153,10 @@ 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", ""))
@@ -276,66 +275,6 @@ 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]

View File

@@ -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_hash(lora['hash']) exists_locally = lora_scanner.has_lora_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)

View File

@@ -1,6 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import os
import json import json
import logging import logging
from aiohttp import web from aiohttp import web
@@ -11,8 +10,6 @@ import jinja2
from ..utils.routes_common import ModelRouteUtils from ..utils.routes_common import ModelRouteUtils
from ..services.websocket_manager import ws_manager from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings from ..services.settings_manager import settings
from ..utils.utils import calculate_relative_path_for_model
from ..utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ..config import config from ..config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,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}/list', self.get_models) app.router.add_get(f'/api/{prefix}', 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)
@@ -51,9 +48,6 @@ 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)
app.router.add_get(f'/api/{prefix}/auto-organize', self.auto_organize_models)
# 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)
@@ -61,8 +55,6 @@ class BaseModelRoutes(ABC):
app.router.add_get(f'/api/{prefix}/scan', self.scan_models) app.router.add_get(f'/api/{prefix}/scan', self.scan_models)
app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots) app.router.add_get(f'/api/{prefix}/roots', self.get_model_roots)
app.router.add_get(f'/api/{prefix}/folders', self.get_folders) app.router.add_get(f'/api/{prefix}/folders', self.get_folders)
app.router.add_get(f'/api/{prefix}/folder-tree', self.get_folder_tree)
app.router.add_get(f'/api/{prefix}/unified-folder-tree', self.get_unified_folder_tree)
app.router.add_get(f'/api/{prefix}/find-duplicates', self.find_duplicate_models) app.router.add_get(f'/api/{prefix}/find-duplicates', self.find_duplicate_models)
app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts) app.router.add_get(f'/api/{prefix}/find-filename-conflicts', self.find_filename_conflicts)
@@ -183,7 +175,6 @@ 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',
} }
@@ -352,43 +343,6 @@ class BaseModelRoutes(ABC):
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response:
"""Get hierarchical folder tree structure for download modal"""
try:
model_root = request.query.get('model_root')
if not model_root:
return web.json_response({
'success': False,
'error': 'model_root parameter is required'
}, status=400)
folder_tree = await self.service.get_folder_tree(model_root)
return web.json_response({
'success': True,
'tree': folder_tree
})
except Exception as e:
logger.error(f"Error getting folder tree: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
"""Get unified folder tree across all model roots"""
try:
unified_tree = await self.service.get_unified_folder_tree()
return web.json_response({
'success': True,
'tree': unified_tree
})
except Exception as e:
logger.error(f"Error getting unified folder tree: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def find_duplicate_models(self, request: web.Request) -> web.Response: async def find_duplicate_models(self, request: web.Request) -> web.Response:
"""Find models with duplicate SHA256 hashes""" """Find models with duplicate SHA256 hashes"""
try: try:
@@ -454,7 +408,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.get_hash_by_filename(filename) hash_val = self.service.scanner._hash_index.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:
@@ -663,321 +617,3 @@ 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)
async def auto_organize_models(self, request: web.Request) -> web.Response:
"""Auto-organize all models based on current settings"""
try:
# Get all models from cache
cache = await self.service.scanner.get_cached_data()
all_models = cache.raw_data
# Get model roots for this scanner
model_roots = self.service.get_model_roots()
if not model_roots:
return web.json_response({
'success': False,
'error': 'No model roots configured'
}, status=400)
# Check if flat structure is configured for this model type
path_template = settings.get_download_path_template(self.service.model_type)
is_flat_structure = not path_template
# Prepare results tracking
results = []
total_models = len(all_models)
processed = 0
success_count = 0
failure_count = 0
skipped_count = 0
# Send initial progress via WebSocket
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'started',
'total': total_models,
'processed': 0,
'success': 0,
'failures': 0,
'skipped': 0
})
# Process models in batches
for i in range(0, total_models, AUTO_ORGANIZE_BATCH_SIZE):
batch = all_models[i:i + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch:
try:
file_path = model.get('file_path')
if not file_path:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "No file path found"
})
failure_count += 1
processed += 1
continue
# Find which model root this file belongs to
current_root = None
for root in model_roots:
# Normalize paths for comparison
normalized_root = os.path.normpath(root).replace(os.sep, '/')
normalized_file = os.path.normpath(file_path).replace(os.sep, '/')
if normalized_file.startswith(normalized_root):
current_root = root
break
if not current_root:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Model file not found in any configured root directory"
})
failure_count += 1
processed += 1
continue
# Handle flat structure case
if is_flat_structure:
current_dir = os.path.dirname(file_path)
# Check if already in root directory
if os.path.normpath(current_dir) == os.path.normpath(current_root):
skipped_count += 1
processed += 1
continue
# Move to root directory for flat structure
target_dir = current_root
else:
# Calculate new relative path based on settings
new_relative_path = calculate_relative_path_for_model(model, self.service.model_type)
# If no relative path calculated (insufficient metadata), skip
if not new_relative_path:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Skipped - insufficient metadata for organization"
})
skipped_count += 1
processed += 1
continue
# Calculate target directory
target_dir = os.path.join(current_root, new_relative_path).replace(os.sep, '/')
current_dir = os.path.dirname(file_path)
# Skip if already in correct location
if current_dir.replace(os.sep, '/') == target_dir.replace(os.sep, '/'):
skipped_count += 1
processed += 1
continue
# Check if target file would conflict
file_name = os.path.basename(file_path)
target_file_path = os.path.join(target_dir, file_name)
if os.path.exists(target_file_path):
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": f"Target file already exists: {target_file_path}"
})
failure_count += 1
processed += 1
continue
# Perform the move
success = await self.service.scanner.move_model(file_path, target_dir)
if success:
success_count += 1
else:
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": "Failed to move model"
})
failure_count += 1
processed += 1
except Exception as e:
logger.error(f"Error processing model {model.get('model_name', 'Unknown')}: {e}", exc_info=True)
if len(results) < 100: # Limit detailed results
results.append({
"model": model.get('model_name', 'Unknown'),
"success": False,
"message": f"Error: {str(e)}"
})
failure_count += 1
processed += 1
# Send progress update after each batch
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'processing',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count
})
# Small delay between batches to prevent overwhelming the system
await asyncio.sleep(0.1)
# Send completion message
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'cleaning',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'message': 'Cleaning up empty directories...'
})
# Clean up empty directories after organizing
from ..utils.utils import remove_empty_dirs
cleanup_counts = {}
for root in model_roots:
removed = remove_empty_dirs(root)
cleanup_counts[root] = removed
# Send cleanup completed message
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'completed',
'total': total_models,
'processed': processed,
'success': success_count,
'failures': failure_count,
'skipped': skipped_count,
'cleanup': cleanup_counts
})
# Prepare response with limited details
response_data = {
'success': True,
'message': f'Auto-organize completed: {success_count} moved, {skipped_count} skipped, {failure_count} failed out of {total_models} total',
'summary': {
'total': total_models,
'success': success_count,
'skipped': skipped_count,
'failures': failure_count,
'organization_type': 'flat' if is_flat_structure else 'structured',
'cleaned_dirs': cleanup_counts
}
}
# Only include detailed results if under limit
if len(results) <= 100:
response_data['results'] = results
else:
response_data['results_truncated'] = True
response_data['sample_results'] = results[:50] # Show first 50 as sample
return web.json_response(response_data)
except Exception as e:
logger.error(f"Error in auto_organize_models: {e}", exc_info=True)
# Send error message via WebSocket
await ws_manager.broadcast({
'type': 'auto_organize_progress',
'status': 'error',
'error': str(e)
})
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -4,7 +4,6 @@ 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__)
@@ -43,10 +42,6 @@ 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:
@@ -108,33 +103,3 @@ 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)

View File

@@ -49,6 +49,10 @@ 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)
@@ -280,6 +284,105 @@ 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:

View File

@@ -167,9 +167,6 @@ 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):
@@ -184,7 +181,7 @@ class MiscRoutes:
logger.info(f"Example images path changed to {value} - server restart required") logger.info(f"Example images path changed to {value} - server restart required")
# Special handling for base_model_path_mappings - parse JSON string # Special handling for base_model_path_mappings - parse JSON string
if (key == 'base_model_path_mappings' or key == 'download_path_templates') and value: if key == 'base_model_path_mappings' and value:
try: try:
value = json.loads(value) value = json.loads(value)
except json.JSONDecodeError: except json.JSONDecodeError:
@@ -654,13 +651,13 @@ class MiscRoutes:
exists = False exists = False
model_type = None model_type = None
if await lora_scanner.check_model_version_exists(model_version_id): if await lora_scanner.check_model_version_exists(model_id, model_version_id):
exists = True exists = True
model_type = 'lora' model_type = 'lora'
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_version_id): elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_id, model_version_id):
exists = True exists = True
model_type = 'checkpoint' model_type = 'checkpoint'
elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_version_id): elif embedding_scanner and await embedding_scanner.check_model_version_exists(model_id, model_version_id):
exists = True exists = True
model_type = 'embedding' model_type = 'embedding'

View File

@@ -22,6 +22,7 @@ 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
@@ -376,6 +377,16 @@ 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
@@ -627,6 +638,21 @@ 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)

View File

@@ -20,7 +20,6 @@ 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),
@@ -31,7 +30,6 @@ 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:
@@ -51,12 +49,7 @@ 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)
) )
embedding_initializing = ( is_initializing = lora_initializing or checkpoint_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(
@@ -92,29 +85,21 @@ 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 + embedding_count, 'total_models': lora_count + checkpoint_count,
'lora_count': lora_count, 'lora_count': lora_count,
'checkpoint_count': checkpoint_count, 'checkpoint_count': checkpoint_count,
'embedding_count': embedding_count, 'total_size': lora_size + checkpoint_size,
'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', {}))
} }
}) })
@@ -136,17 +121,14 @@ 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)
@@ -156,7 +138,6 @@ 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)
} }
@@ -177,19 +158,16 @@ 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)
} }
}) })
@@ -208,7 +186,6 @@ 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 = []
@@ -216,8 +193,6 @@ 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)
@@ -250,7 +225,6 @@ 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 = []
@@ -281,31 +255,15 @@ 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]
} }
}) })
@@ -327,18 +285,15 @@ 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
@@ -360,20 +315,9 @@ 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({
@@ -446,7 +390,6 @@ 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():
@@ -457,16 +400,11 @@ 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,
'embedding_usage': embedding_usage, 'total_usage': lora_usage + checkpoint_usage
'total_usage': lora_usage + checkpoint_usage + embedding_usage
}) })
return list(reversed(timeline)) # Oldest to newest return list(reversed(timeline)) # Oldest to newest

View File

@@ -1,11 +1,10 @@
import os import os
import subprocess
import aiohttp import aiohttp
import logging import logging
import toml import toml
import git import git
import zipfile from datetime import datetime
import shutil
import tempfile
from aiohttp import web from aiohttp import web
from typing import Dict, List from typing import Dict, List
@@ -102,16 +101,18 @@ 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):
@@ -119,14 +120,10 @@ class UpdateRoutes:
settings_backup = f.read() settings_backup = f.read()
logger.info("Backed up settings.json") logger.info("Backed up settings.json")
git_folder = os.path.join(plugin_root, '.git') # Perform Git update
if os.path.exists(git_folder): success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
# 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)
@@ -141,7 +138,7 @@ class UpdateRoutes:
else: else:
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': 'Failed to complete update' 'error': 'Failed to complete Git update'
}) })
except Exception as e: except Exception as e:
@@ -151,87 +148,6 @@ 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]]:
""" """
@@ -375,7 +291,7 @@ class UpdateRoutes:
git_info = { git_info = {
'commit_hash': 'unknown', 'commit_hash': 'unknown',
'short_hash': 'stable', 'short_hash': 'unknown',
'branch': 'unknown', 'branch': 'unknown',
'commit_date': 'unknown' 'commit_date': 'unknown'
} }
@@ -385,12 +301,49 @@ 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
repo = git.Repo(plugin_root) # Get current commit hash
commit = repo.head.commit result = subprocess.run(
git_info['commit_hash'] = commit.hexsha ['git', 'rev-parse', 'HEAD'],
git_info['short_hash'] = commit.hexsha[:7] cwd=plugin_root,
git_info['branch'] = repo.active_branch.name if not repo.head.is_detached else 'detached' stdout=subprocess.PIPE,
git_info['commit_date'] = commit.committed_datetime.strftime('%Y-%m-%d') stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode == 0:
git_info['commit_hash'] = result.stdout.strip()
git_info['short_hash'] = git_info['commit_hash'][:7]
# Get current branch name
result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
cwd=plugin_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode == 0:
git_info['branch'] = result.stdout.strip()
# Get commit date
result = subprocess.run(
['git', 'show', '-s', '--format=%ci', 'HEAD'],
cwd=plugin_root,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=False
)
if result.returncode == 0:
commit_date = result.stdout.strip()
# Format the date nicely if possible
try:
date_obj = datetime.strptime(commit_date, '%Y-%m-%d %H:%M:%S %z')
git_info['commit_date'] = date_obj.strftime('%Y-%m-%d')
except:
git_info['commit_date'] = commit_date
except Exception as e: except Exception as e:
logger.warning(f"Error getting git info: {e}") logger.warning(f"Error getting git info: {e}")

View File

@@ -200,22 +200,6 @@ 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]:
@@ -273,61 +257,3 @@ class BaseModelService(ABC):
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get model root directories""" """Get model root directories"""
return self.scanner.get_model_roots() return self.scanner.get_model_roots()
async def get_folder_tree(self, model_root: str) -> Dict:
"""Get hierarchical folder tree for a specific model root"""
cache = await self.scanner.get_cached_data()
# Build tree structure from folders
tree = {}
for folder in cache.folders:
# Check if this folder belongs to the specified model root
folder_belongs_to_root = False
for root in self.scanner.get_model_roots():
if root == model_root:
folder_belongs_to_root = True
break
if not folder_belongs_to_root:
continue
# Split folder path into components
parts = folder.split('/') if folder else []
current_level = tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return tree
async def get_unified_folder_tree(self) -> Dict:
"""Get unified folder tree across all model roots"""
cache = await self.scanner.get_cached_data()
# Build unified tree structure by analyzing all relative paths
unified_tree = {}
# Get all model roots for path normalization
model_roots = self.scanner.get_model_roots()
for folder in cache.folders:
if not folder: # Skip empty folders
continue
# Find which root this folder belongs to by checking the actual file paths
# This is a simplified approach - we'll use the folder as-is since it should already be relative
relative_path = folder
# Split folder path into components
parts = relative_path.split('/')
current_level = unified_tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return unified_tree

View File

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

View File

@@ -223,11 +223,11 @@ class CivitaiClient:
logger.error(f"Error fetching model versions: {e}") logger.error(f"Error fetching model versions: {e}")
return None return None
async def get_model_version(self, model_id: int = None, version_id: int = None) -> Optional[Dict]: async def get_model_version(self, model_id: int, version_id: int = None) -> Optional[Dict]:
"""Get specific model version with additional metadata """Get specific model version with additional metadata
Args: Args:
model_id: The Civitai model ID (optional if version_id is provided) model_id: The Civitai model ID
version_id: Optional specific version ID to retrieve version_id: Optional specific version ID to retrieve
Returns: Returns:
@@ -235,72 +235,37 @@ class CivitaiClient:
""" """
try: try:
session = await self._ensure_fresh_session() session = await self._ensure_fresh_session()
# Step 1: Get model data to find version_id if not provided and get additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
model_versions = data.get('modelVersions', [])
# Step 2: Determine the version_id to use
target_version_id = version_id
if target_version_id is None:
target_version_id = model_versions[0].get('id')
# Step 3: Get detailed version info using the version_id
headers = self._get_request_headers() headers = self._get_request_headers()
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
if response.status != 200:
return None
# Case 1: Only version_id is provided version = await response.json()
if model_id is None and version_id is not None:
# First get the version info to extract model_id
async with session.get(f"{self.base_url}/model-versions/{version_id}", headers=headers) as response:
if response.status != 200:
return None
version = await response.json() # Step 4: Enrich version_info with model data
model_id = version.get('modelId') # Add description and tags from model data
version['model']['description'] = data.get("description")
version['model']['tags'] = data.get("tags", [])
if not model_id: # Add creator from model data
logger.error(f"No modelId found in version {version_id}") version['creator'] = data.get("creator")
return None
# Now get the model data for additional metadata return version
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return version # Return version without additional metadata
model_data = await response.json()
# Enrich version with model data
version['model']['description'] = model_data.get("description")
version['model']['tags'] = model_data.get("tags", [])
version['creator'] = model_data.get("creator")
return version
# Case 2: model_id is provided (with or without version_id)
elif model_id is not None:
# Step 1: Get model data to find version_id if not provided and get additional metadata
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
model_versions = data.get('modelVersions', [])
# Step 2: Determine the version_id to use
target_version_id = version_id
if target_version_id is None:
target_version_id = model_versions[0].get('id')
# Step 3: Get detailed version info using the version_id
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
if response.status != 200:
return None
version = await response.json()
# Step 4: Enrich version_info with model data
# Add description and tags from model data
version['model']['description'] = data.get("description")
version['model']['tags'] = data.get("tags", [])
# Add creator from model data
version['creator'] = data.get("creator")
return version
# Case 3: Neither model_id nor version_id provided
else:
logger.error("Either model_id or version_id must be provided")
return None
except Exception as e: except Exception as e:
logger.error(f"Error fetching model version: {e}") logger.error(f"Error fetching model version: {e}")

View File

@@ -54,15 +54,15 @@ class DownloadManager:
"""Get the checkpoint scanner from registry""" """Get the checkpoint scanner from registry"""
return await ServiceRegistry.get_checkpoint_scanner() return await ServiceRegistry.get_checkpoint_scanner()
async def download_from_civitai(self, model_id: int = None, model_version_id: int = None, async def download_from_civitai(self, model_id: int, model_version_id: int,
save_dir: str = None, relative_path: str = '', save_dir: str = None, relative_path: str = '',
progress_callback=None, use_default_paths: bool = False, progress_callback=None, use_default_paths: bool = False,
download_id: str = None) -> Dict: download_id: str = None) -> Dict:
"""Download model from Civitai with task tracking and concurrency control """Download model from Civitai with task tracking and concurrency control
Args: Args:
model_id: Civitai model ID (optional if model_version_id is provided) model_id: Civitai model ID
model_version_id: Civitai model version ID (optional if model_id is provided) model_version_id: Civitai model version ID
save_dir: Directory to save the model save_dir: Directory to save the model
relative_path: Relative path within save_dir relative_path: Relative path within save_dir
progress_callback: Callback function for progress updates progress_callback: Callback function for progress updates
@@ -72,10 +72,6 @@ class DownloadManager:
Returns: Returns:
Dict with download result Dict with download result
""" """
# Validate that at least one identifier is provided
if not model_id and not model_version_id:
return {'success': False, 'error': 'Either model_id or model_version_id must be provided'}
# Use provided download_id or generate new one # Use provided download_id or generate new one
task_id = download_id or str(uuid.uuid4()) task_id = download_id or str(uuid.uuid4())
@@ -185,20 +181,15 @@ class DownloadManager:
# Check both scanners # Check both scanners
lora_scanner = await self._get_lora_scanner() lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner() checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
# Check lora scanner first # Check lora scanner first
if await lora_scanner.check_model_version_exists(model_version_id): if await lora_scanner.check_model_version_exists(model_id, model_version_id):
return {'success': False, 'error': 'Model version already exists in lora library'} return {'success': False, 'error': 'Model version already exists in lora library'}
# Check checkpoint scanner # Check checkpoint scanner
if await checkpoint_scanner.check_model_version_exists(model_version_id): if await checkpoint_scanner.check_model_version_exists(model_id, model_version_id):
return {'success': False, 'error': 'Model version already exists in checkpoint library'} return {'success': False, 'error': 'Model version already exists in checkpoint library'}
# Check embedding scanner
if await embedding_scanner.check_model_version_exists(model_version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'}
# Get civitai client # Get civitai client
civitai_client = await self._get_civitai_client() civitai_client = await self._get_civitai_client()
@@ -220,22 +211,23 @@ class DownloadManager:
# Case 2: model_version_id was None, check after getting version_info # Case 2: model_version_id was None, check after getting version_info
if model_version_id is None: if model_version_id is None:
version_model_id = version_info.get('modelId')
version_id = version_info.get('id') version_id = version_info.get('id')
if model_type == 'lora': if model_type == 'lora':
# Check lora scanner # Check lora scanner
lora_scanner = await self._get_lora_scanner() lora_scanner = await self._get_lora_scanner()
if await lora_scanner.check_model_version_exists(version_id): if await lora_scanner.check_model_version_exists(version_model_id, version_id):
return {'success': False, 'error': 'Model version already exists in lora library'} return {'success': False, 'error': 'Model version already exists in lora library'}
elif model_type == 'checkpoint': elif model_type == 'checkpoint':
# Check checkpoint scanner # Check checkpoint scanner
checkpoint_scanner = await self._get_checkpoint_scanner() checkpoint_scanner = await self._get_checkpoint_scanner()
if await checkpoint_scanner.check_model_version_exists(version_id): if await checkpoint_scanner.check_model_version_exists(version_model_id, version_id):
return {'success': False, 'error': 'Model version already exists in checkpoint library'} return {'success': False, 'error': 'Model version already exists in checkpoint library'}
elif model_type == 'embedding': elif model_type == 'embedding':
# Embeddings are not checked in scanners, but we can still check if it exists # Embeddings are not checked in scanners, but we can still check if it exists
embedding_scanner = await ServiceRegistry.get_embedding_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner()
if await embedding_scanner.check_model_version_exists(version_id): if await embedding_scanner.check_model_version_exists(version_model_id, version_id):
return {'success': False, 'error': 'Model version already exists in embedding library'} return {'success': False, 'error': 'Model version already exists in embedding library'}
# Handle use_default_paths # Handle use_default_paths
@@ -258,7 +250,7 @@ class DownloadManager:
save_dir = default_path save_dir = default_path
# Calculate relative path using template # Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type) relative_path = self._calculate_relative_path(version_info)
# Update save directory with relative path if provided # Update save directory with relative path if provided
if relative_path: if relative_path:
@@ -331,18 +323,17 @@ class DownloadManager:
return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."} return {'success': False, 'error': f"Early access restriction: {str(e)}. Please ensure you have purchased early access and are logged in to Civitai."}
return {'success': False, 'error': str(e)} return {'success': False, 'error': str(e)}
def _calculate_relative_path(self, version_info: Dict, model_type: str = 'lora') -> str: def _calculate_relative_path(self, version_info: Dict) -> str:
"""Calculate relative path using template from settings """Calculate relative path using template from settings
Args: Args:
version_info: Version info from Civitai API version_info: Version info from Civitai API
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns: Returns:
Relative path string Relative path string
""" """
# Get path template from settings for specific model type # Get path template from settings, default to '{base_model}/{first_tag}'
path_template = settings.get_download_path_template(model_type) path_template = settings.get('download_path_template', '{base_model}/{first_tag}')
# If template is empty, return empty path (flat structure) # If template is empty, return empty path (flat structure)
if not path_template: if not path_template:
@@ -351,9 +342,6 @@ class DownloadManager:
# Get base model name # Get base model name
base_model = version_info.get('baseModel', '') base_model = version_info.get('baseModel', '')
# Get author from creator data
author = version_info.get('creator', {}).get('username', 'Anonymous')
# Apply mapping if available # Apply mapping if available
base_model_mappings = settings.get('base_model_path_mappings', {}) base_model_mappings = settings.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model) mapped_base_model = base_model_mappings.get(base_model, base_model)
@@ -376,7 +364,6 @@ class DownloadManager:
formatted_path = path_template formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model) formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag) formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path return formatted_path

View File

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

View File

@@ -302,13 +302,6 @@ class ModelScanner:
for tag in model_data['tags']: for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
duplicate_filenames = self._hash_index.get_duplicate_filenames()
if duplicate_filenames:
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
for filename, paths in duplicate_filenames.items():
logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache # Update cache
self._cache.raw_data = raw_data self._cache.raw_data = raw_data
loop.run_until_complete(self._cache.resort()) loop.run_until_complete(self._cache.resort())
@@ -374,13 +367,6 @@ class ModelScanner:
for tag in model_data['tags']: for tag in model_data['tags']:
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
# Log duplicate filename warnings after building the index
duplicate_filenames = self._hash_index.get_duplicate_filenames()
if duplicate_filenames:
logger.warning(f"Found {len(duplicate_filenames)} filename(s) with duplicates during {self.model_type} cache build:")
for filename, paths in duplicate_filenames.items():
logger.warning(f" Duplicate filename '{filename}': {paths}")
# Update cache # Update cache
self._cache = ModelCache( self._cache = ModelCache(
raw_data=raw_data, raw_data=raw_data,
@@ -583,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, "/")
result = await self._process_model_file(file_path, original_root) await self._process_single_file(file_path, original_root, models)
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}")
@@ -598,6 +584,15 @@ 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
@@ -618,10 +613,7 @@ class ModelScanner:
return os.path.dirname(rel_path).replace(os.path.sep, '/') return os.path.dirname(rel_path).replace(os.path.sep, '/')
return '' return ''
def adjust_metadata(self, metadata, file_path, root_path): # Common methods shared between scanners
"""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)
@@ -675,9 +667,6 @@ 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
@@ -685,14 +674,6 @@ class ModelScanner:
self._excluded_models.append(model_data['file_path']) self._excluded_models.append(model_data['file_path'])
return None return None
# Check for duplicate filename before adding to hash index
filename = os.path.splitext(os.path.basename(file_path))[0]
existing_hash = self._hash_index.get_hash_by_filename(filename)
if existing_hash and existing_hash != model_data.get('sha256', '').lower():
existing_path = self._hash_index.get_path(existing_hash)
if existing_path and existing_path != file_path:
logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
await self._fetch_missing_metadata(file_path, model_data) await self._fetch_missing_metadata(file_path, model_data)
rel_path = os.path.relpath(file_path, root_path) rel_path = os.path.relpath(file_path, root_path)
folder = os.path.dirname(rel_path) folder = os.path.dirname(rel_path)
@@ -751,6 +732,48 @@ 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
@@ -1171,10 +1194,11 @@ class ModelScanner:
if len(self._hash_index._duplicate_filenames[file_name]) <= 1: if len(self._hash_index._duplicate_filenames[file_name]) <= 1:
del self._hash_index._duplicate_filenames[file_name] del self._hash_index._duplicate_filenames[file_name]
async def check_model_version_exists(self, model_version_id: int) -> bool: async def check_model_version_exists(self, model_id: int, model_version_id: int) -> bool:
"""Check if a specific model version exists in the cache """Check if a specific model version exists in the cache
Args: Args:
model_id: Civitai model ID
model_version_id: Civitai model version ID model_version_id: Civitai model version ID
Returns: Returns:
@@ -1186,7 +1210,9 @@ class ModelScanner:
return False return False
for item in cache.raw_data: for item in cache.raw_data:
if item.get('civitai') and item['civitai'].get('id') == model_version_id: if (item.get('civitai') and
item['civitai'].get('modelId') == model_id and
item['civitai'].get('id') == model_version_id):
return True return True
return False return False

View File

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

View File

@@ -48,13 +48,9 @@ SUPPORTED_MEDIA_EXTENSIONS = {
# Valid Lora types # Valid Lora types
VALID_LORA_TYPES = ['lora', 'locon', 'dora'] VALID_LORA_TYPES = ['lora', 'locon', 'dora']
# Auto-organize settings
AUTO_ORGANIZE_BATCH_SIZE = 50 # Process models in batches to avoid overwhelming the system
# Civitai model tags in priority order for subfolder organization # Civitai model tags in priority order for subfolder organization
CIVITAI_MODEL_TAGS = [ CIVITAI_MODEL_TAGS = [
'character', 'style', 'concept', 'clothing', 'character', 'style', 'concept', 'clothing', 'base model',
# 'base model', # exclude 'base model'
'poses', 'background', 'tool', 'vehicle', 'buildings', 'poses', 'background', 'tool', 'vehicle', 'buildings',
'objects', 'assets', 'animal', 'action' 'objects', 'assets', 'animal', 'action'
] ]

View File

@@ -24,8 +24,7 @@ download_progress = {
'start_time': None, 'start_time': None,
'end_time': None, 'end_time': None,
'processed_models': set(), # Track models that have been processed 'processed_models': set(), # Track models that have been processed
'refreshed_models': set(), # Track models that had metadata refreshed 'refreshed_models': set() # Track models that had metadata refreshed
'failed_models': set() # Track models that failed to download after metadata refresh
} }
class DownloadManager: class DownloadManager:
@@ -51,7 +50,6 @@ class DownloadManager:
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({ return web.json_response({
'success': False, 'success': False,
@@ -93,15 +91,12 @@ 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', []))
download_progress['failed_models'] = set(saved_progress.get('failed_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, {len(download_progress['failed_models'])} models marked as failed")
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()
download_progress['failed_models'] = set()
else: else:
download_progress['processed_models'] = set() download_progress['processed_models'] = set()
download_progress['failed_models'] = set()
# Start the download task # Start the download task
is_downloading = True is_downloading = True
@@ -118,7 +113,6 @@ class DownloadManager:
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({ return web.json_response({
'success': True, 'success': True,
@@ -142,7 +136,6 @@ class DownloadManager:
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models']) response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
response_progress['failed_models'] = list(download_progress['failed_models'])
return web.json_response({ return web.json_response({
'success': True, 'success': True,
@@ -237,7 +230,7 @@ class DownloadManager:
# Update total count # Update total count
download_progress['total'] = len(all_models) download_progress['total'] = len(all_models)
logger.debug(f"Found {download_progress['total']} models to process") logger.info(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):
@@ -257,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.debug(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed") logger.info(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)}"
@@ -306,11 +299,6 @@ class DownloadManager:
# Update current model info # Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})" download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already in failed models
if model_hash in download_progress['failed_models']:
logger.debug(f"Skipping known failed model: {model_name}")
return False
# Skip if already processed AND directory exists with files # Skip if already processed AND directory exists with files
if model_hash in download_progress['processed_models']: if model_hash in download_progress['processed_models']:
model_dir = os.path.join(output_dir, model_hash) model_dir = os.path.join(output_dir, model_hash)
@@ -320,8 +308,6 @@ class DownloadManager:
return False return False
else: else:
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing") logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
# Remove from processed models since we need to reprocess
download_progress['processed_models'].discard(model_hash)
# Create model directory # Create model directory
model_dir = os.path.join(output_dir, model_hash) model_dir = os.path.join(output_dir, model_hash)
@@ -366,22 +352,11 @@ class DownloadManager:
model_hash, model_name, updated_images, model_dir, optimize, independent_session model_hash, model_name, updated_images, model_dir, optimize, independent_session
) )
download_progress['refreshed_models'].add(model_hash) # Only mark as processed if all images were downloaded successfully
# Mark as processed if successful, or as failed if unsuccessful after refresh
if success: if success:
download_progress['processed_models'].add(model_hash) download_progress['processed_models'].add(model_hash)
else:
# If we refreshed metadata and still failed, mark as permanently failed
if model_hash in download_progress['refreshed_models']:
download_progress['failed_models'].add(model_hash)
logger.info(f"Marking model {model_name} as failed after metadata refresh")
return True # Return True to indicate a remote download happened return True # Return True to indicate a remote download happened
else:
# No civitai data or images available, mark as failed to avoid future attempts
download_progress['failed_models'].add(model_hash)
logger.debug(f"No civitai images available for model {model_name}, marking as failed")
# Save progress periodically # Save progress periodically
if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1: if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1:
@@ -416,7 +391,6 @@ class DownloadManager:
progress_data = { progress_data = {
'processed_models': list(download_progress['processed_models']), 'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']), 'refreshed_models': list(download_progress['refreshed_models']),
'failed_models': list(download_progress['failed_models']),
'completed': download_progress['completed'], 'completed': download_progress['completed'],
'total': download_progress['total'], 'total': download_progress['total'],
'last_update': time.time() 'last_update': time.time()

View File

@@ -43,14 +43,6 @@ 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):

View File

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

View File

@@ -1,10 +1,10 @@
from difflib import SequenceMatcher from difflib import SequenceMatcher
import requests
import tempfile
import os import os
from typing import Dict from bs4 import BeautifulSoup
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..config import config from ..config import config
from ..services.settings_manager import settings
from .constants import CIVITAI_MODEL_TAGS
import asyncio import asyncio
def get_lora_info(lora_name): def get_lora_info(lora_name):
@@ -50,7 +50,82 @@ 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 fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: def download_twitter_image(url):
"""Download image from a URL containing twitter:image meta tag
Args:
url (str): The URL to download image from
Returns:
str: Path to downloaded temporary image file
"""
try:
# Download page content
response = requests.get(url)
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.text, 'html.parser')
# Find twitter:image meta tag
meta_tag = soup.find('meta', attrs={'property': 'twitter:image'})
if not meta_tag:
return None
image_url = meta_tag['content']
# Download image
image_response = requests.get(image_url)
image_response.raise_for_status()
# Save to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
temp_file.write(image_response.content)
return temp_file.name
except Exception as e:
print(f"Error downloading twitter image: {e}")
return None
def download_civitai_image(url):
"""Download image from a URL containing avatar image with specific class and style attributes
Args:
url (str): The URL to download image from
Returns:
str: Path to downloaded temporary image file
"""
try:
# Download page content
response = requests.get(url)
response.raise_for_status()
# Parse HTML
soup = BeautifulSoup(response.text, 'html.parser')
# Find image with specific class and style attributes
image = soup.select_one('img.EdgeImage_image__iH4_q.max-h-full.w-auto.max-w-full')
if not image or 'src' not in image.attrs:
return None
image_url = image['src']
# Download image
image_response = requests.get(image_url)
image_response.raise_for_status()
# Save to temp file
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
temp_file.write(image_response.content)
return temp_file.name
except Exception as e:
print(f"Error downloading civitai avatar: {e}")
return None
def fuzzy_match(text: str, pattern: str, threshold: float = 0.7) -> bool:
""" """
Check if text matches pattern using fuzzy matching. Check if text matches pattern using fuzzy matching.
Returns True if similarity ratio is above threshold. Returns True if similarity ratio is above threshold.
@@ -131,94 +206,3 @@ def calculate_recipe_fingerprint(loras):
fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras]) fingerprint = "|".join([f"{hash_value}:{strength}" for hash_value, strength in valid_loras])
return fingerprint return fingerprint
def calculate_relative_path_for_model(model_data: Dict, model_type: str = 'lora') -> str:
"""Calculate relative path for existing model using template from settings
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Relative path string (empty string for flat structure)
"""
# Get path template from settings for specific model type
path_template = settings.get_download_path_template(model_type)
# If template is empty, return empty path (flat structure)
if not path_template:
return ''
# Get base model name from model metadata
civitai_data = model_data.get('civitai', {})
# For CivitAI models, prefer civitai data only if 'id' exists; for non-CivitAI models, use model_data directly
if civitai_data and civitai_data.get('id') is not None:
base_model = civitai_data.get('baseModel', '')
# Get author from civitai creator data
author = civitai_data.get('creator', {}).get('username', 'Anonymous')
else:
# Fallback to model_data fields for non-CivitAI models
base_model = model_data.get('base_model', '')
author = 'Anonymous' # Default for non-CivitAI models
model_tags = model_data.get('tags', [])
# Apply mapping if available
base_model_mappings = settings.get('base_model_path_mappings', {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
# Find the first Civitai model tag that exists in model_tags
first_tag = ''
for civitai_tag in CIVITAI_MODEL_TAGS:
if civitai_tag in model_tags:
first_tag = civitai_tag
break
# If no Civitai model tag found, fallback to first tag
if not first_tag and model_tags:
first_tag = model_tags[0]
if not first_tag:
first_tag = 'no tags' # Default if no tags available
# Format the template with available data
formatted_path = path_template
formatted_path = formatted_path.replace('{base_model}', mapped_base_model)
formatted_path = formatted_path.replace('{first_tag}', first_tag)
formatted_path = formatted_path.replace('{author}', author)
return formatted_path
def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path.
Args:
path (str): Root directory to start cleaning from
Returns:
int: Number of empty directories removed
"""
removed_count = 0
if not os.path.isdir(path):
return removed_count
# List all files in directory
files = os.listdir(path)
# Process all subdirectories first
for file in files:
full_path = os.path.join(path, file)
if os.path.isdir(full_path):
removed_count += remove_empty_dirs(full_path)
# Check if directory is now empty (after processing subdirectories)
if not os.listdir(path):
try:
os.rmdir(path)
removed_count += 1
except OSError:
pass
return removed_count

View File

@@ -1,15 +1,17 @@
[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.27" version = "0.8.21"
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"

View File

@@ -1,10 +1,13 @@
aiohttp aiohttp
jinja2 jinja2
safetensors safetensors
beautifulsoup4
piexif piexif
Pillow Pillow
olefile olefile
requests
toml toml
numpy numpy
natsort natsort
pyyaml
GitPython GitPython

View File

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

View File

@@ -1,245 +0,0 @@
/* 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;
}

View File

@@ -424,33 +424,6 @@
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 *,

View File

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

View File

@@ -19,18 +19,6 @@
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;

View File

@@ -123,42 +123,6 @@
} }
} }
/* 修改 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;
@@ -183,11 +147,7 @@
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);
@@ -199,28 +159,17 @@
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,
.edit-base-model-btn.visible, .file-name-wrapper:hover .edit-file-name-btn {
.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-model-name-btn:hover, .edit-file-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-model-name-btn:hover, [data-theme="dark"] .edit-file-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);
} }
@@ -249,6 +198,32 @@
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;
@@ -305,6 +280,32 @@
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;

View File

@@ -4,31 +4,254 @@
margin-top: var(--space-4); margin-top: var(--space-4);
} }
.carousel { /* Main showcase container */
transition: max-height 0.3s ease-in-out; .showcase-container {
display: flex;
height: 750px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
overflow: hidden; overflow: hidden;
background: var(--lora-surface);
} }
.carousel.collapsed { .showcase-container.empty {
max-height: 0; height: 400px;
} }
.carousel-container { /* Thumbnail Sidebar */
.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);
margin-bottom: var(--space-2); overflow: hidden;
overflow: hidden; /* Ensure metadata panel is contained */
}
.media-wrapper:last-child {
margin-bottom: 0;
} }
.media-wrapper img, .media-wrapper img,
@@ -41,50 +264,11 @@
object-fit: contain; object-fit: contain;
} }
.no-examples { /* Media Controls for main display */
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;
@@ -94,15 +278,15 @@
pointer-events: none; pointer-events: none;
} }
.media-controls.visible { .media-wrapper:hover .media-controls {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
pointer-events: auto; pointer-events: auto;
} }
.media-control-btn { .media-control-btn {
width: 28px; width: 32px;
height: 28px; height: 32px;
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);
@@ -135,13 +319,11 @@
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;
@@ -172,16 +354,29 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
@keyframes pulse { /* Toggle blur button for main display */
0% { .showcase-toggle-btn {
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); position: absolute;
} top: calc(var(--space-2) + 44px);
70% { left: var(--space-2);
box-shadow: 0 0 0 5px rgba(220, 53, 69, 0); z-index: 3;
} width: 32px;
100% { height: 32px;
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); 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.15);
opacity: 0;
}
.media-wrapper:hover .showcase-toggle-btn {
opacity: 1;
} }
/* Image Metadata Panel Styles */ /* Image Metadata Panel Styles */
@@ -195,22 +390,20 @@
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: 5; z-index: 15;
max-height: 50%; /* Reduced to take less space */ max-height: 50%;
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;
} }
/* Show metadata panel only when the 'visible' class is added */ .image-metadata-panel.visible {
.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);
@@ -222,7 +415,6 @@
gap: 10px; gap: 10px;
} }
/* Styling for parameters tags */
.params-tags { .params-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -255,7 +447,6 @@
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;
@@ -281,7 +472,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; /* Reduced from 120px */ max-height: 80px;
overflow-y: auto; overflow-y: auto;
word-break: break-word; word-break: break-word;
width: 100%; width: 100%;
@@ -313,27 +504,6 @@
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;
@@ -352,31 +522,66 @@
opacity: 0.8; opacity: 0.8;
} }
/* Scroll Indicator */ /* Scrollbar styling for metadata panel */
.scroll-indicator { .image-metadata-panel::-webkit-scrollbar {
cursor: pointer; width: 6px;
padding: var(--space-2); }
background: var(--lora-surface);
border: 1px solid var(--lora-border); .image-metadata-panel::-webkit-scrollbar-track {
border-radius: var(--border-radius-sm); background: transparent;
}
.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;
gap: 8px; 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); margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s; display: flex;
} align-items: center;
gap: 8px;
.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;
color: var(--text-color);
} }
/* No examples message */
.no-examples {
text-align: center;
padding: var(--space-4);
color: var(--text-color);
opacity: 0.7;
}
/* Lazy loading */
.lazy { .lazy {
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
@@ -386,93 +591,24 @@
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-container { [data-theme="dark"] .import-drop-zone {
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;
}
}

View File

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

View File

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

View File

@@ -56,24 +56,6 @@
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);

View File

@@ -6,7 +6,6 @@
/* 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';
@@ -16,7 +15,7 @@
@import 'components/modal/relink-civitai-modal.css'; @import 'components/modal/relink-civitai-modal.css';
@import 'components/modal/example-access-modal.css'; @import 'components/modal/example-access-modal.css';
@import 'components/modal/support-modal.css'; @import 'components/modal/support-modal.css';
@import 'components/modal/download-modal.css'; @import 'components/download-modal.css';
@import 'components/toast.css'; @import 'components/toast.css';
@import 'components/loading.css'; @import 'components/loading.css';
@import 'components/menu.css'; @import 'components/menu.css';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -29,7 +29,7 @@ export const MODEL_CONFIG = {
defaultPageSize: 100, defaultPageSize: 100,
supportsLetterFilter: false, supportsLetterFilter: false,
supportsBulkOperations: true, supportsBulkOperations: true,
supportsMove: true, supportsMove: false,
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`, list: `/api/${modelType}`,
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,10 +64,6 @@ 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`,
@@ -83,8 +79,6 @@ export function getApiEndpoints(modelType) {
baseModels: `/api/${modelType}/base-models`, baseModels: `/api/${modelType}/base-models`,
roots: `/api/${modelType}/roots`, roots: `/api/${modelType}/roots`,
folders: `/api/${modelType}/folders`, folders: `/api/${modelType}/folders`,
folderTree: `/api/${modelType}/folder-tree`,
unifiedFolderTree: `/api/${modelType}/unified-folder-tree`,
duplicates: `/api/${modelType}/find-duplicates`, duplicates: `/api/${modelType}/find-duplicates`,
conflicts: `/api/${modelType}/find-filename-conflicts`, conflicts: `/api/${modelType}/find-filename-conflicts`,
verify: `/api/${modelType}/verify-duplicates`, verify: `/api/${modelType}/verify-duplicates`,
@@ -105,14 +99,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]: {
} }

View File

@@ -8,16 +8,12 @@ import {
DOWNLOAD_ENDPOINTS, DOWNLOAD_ENDPOINTS,
WS_ENDPOINTS WS_ENDPOINTS
} from './apiConfig.js'; } from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
/** /**
* Abstract base class for all model API clients * Universal API client for all model types
*/ */
export class BaseModelApiClient { class ModelApiClient {
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);
} }
@@ -46,6 +42,9 @@ export class BaseModelApiClient {
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;
@@ -80,6 +79,9 @@ export class BaseModelApiClient {
} }
} }
/**
* 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();
@@ -91,27 +93,26 @@ export class BaseModelApiClient {
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;
if (updateFolders) { // Update folders if needed
const response = await fetch(this.apiConfig.endpoints.folders); if (updateFolders && result.folders) {
if (response.ok) { updateFolderTags(result.folders);
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;
@@ -125,6 +126,9 @@ export class BaseModelApiClient {
} }
} }
/**
* 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}...`);
@@ -159,6 +163,9 @@ export class BaseModelApiClient {
} }
} }
/**
* 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}...`);
@@ -193,6 +200,9 @@ export class BaseModelApiClient {
} }
} }
/**
* 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...`);
@@ -229,6 +239,9 @@ export class BaseModelApiClient {
} }
} }
/**
* Replace model preview
*/
replaceModelPreview(filePath) { replaceModelPreview(filePath) {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
@@ -244,6 +257,9 @@ export class BaseModelApiClient {
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...');
@@ -265,6 +281,7 @@ export class BaseModelApiClient {
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);
@@ -288,6 +305,9 @@ export class BaseModelApiClient {
} }
} }
/**
* Save model metadata
*/
async saveModelMetadata(filePath, data) { async saveModelMetadata(filePath, data) {
try { try {
state.loadingManager.showSimpleLoading('Saving metadata...'); state.loadingManager.showSimpleLoading('Saving metadata...');
@@ -312,6 +332,9 @@ export class BaseModelApiClient {
} }
} }
/**
* Refresh models (scan)
*/
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {
try { try {
state.loadingManager.showSimpleLoading( state.loadingManager.showSimpleLoading(
@@ -327,8 +350,6 @@ export class BaseModelApiClient {
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);
@@ -339,6 +360,9 @@ export class BaseModelApiClient {
} }
} }
/**
* Fetch CivitAI metadata for single model
*/
async refreshSingleModelMetadata(filePath) { async refreshSingleModelMetadata(filePath) {
try { try {
state.loadingManager.showSimpleLoading('Refreshing metadata...'); state.loadingManager.showSimpleLoading('Refreshing metadata...');
@@ -375,6 +399,9 @@ export class BaseModelApiClient {
} }
} }
/**
* Fetch CivitAI metadata for all models
*/
async fetchCivitaiMetadata() { async fetchCivitaiMetadata() {
let ws = null; let ws = null;
@@ -450,6 +477,9 @@ export class BaseModelApiClient {
}); });
} }
/**
* 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');
@@ -463,6 +493,7 @@ export class BaseModelApiClient {
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();
@@ -504,6 +535,7 @@ export class BaseModelApiClient {
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`;
@@ -543,6 +575,113 @@ export class BaseModelApiClient {
} }
} }
/**
* 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}`);
@@ -560,6 +699,9 @@ export class BaseModelApiClient {
} }
} }
/**
* 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);
@@ -573,6 +715,9 @@ export class BaseModelApiClient {
} }
} }
/**
* 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);
@@ -586,34 +731,10 @@ export class BaseModelApiClient {
} }
} }
async fetchUnifiedFolderTree() { /**
try { * Download a model
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree); */
if (!response.ok) { async downloadModel(modelId, versionId, modelRoot, relativePath, downloadId) {
throw new Error(`Failed to fetch unified folder tree`);
}
return await response.json();
} catch (error) {
console.error('Error fetching unified folder tree:', error);
throw error;
}
}
async fetchFolderTree(modelRoot) {
try {
const params = new URLSearchParams({ model_root: modelRoot });
const response = await fetch(`${this.apiConfig.endpoints.folderTree}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch folder tree for root: ${modelRoot}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching folder tree:', error);
throw error;
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId) {
try { try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, { const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST', method: 'POST',
@@ -623,7 +744,6 @@ export class BaseModelApiClient {
model_version_id: versionId, model_version_id: versionId,
model_root: modelRoot, model_root: modelRoot,
relative_path: relativePath, relative_path: relativePath,
use_default_paths: useDefaultPaths,
download_id: downloadId download_id: downloadId
}) })
}); });
@@ -639,9 +759,13 @@ export class BaseModelApiClient {
} }
} }
/**
* 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);
} }
@@ -650,10 +774,12 @@ export class BaseModelApiClient {
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');
@@ -664,13 +790,11 @@ export class BaseModelApiClient {
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 => {
@@ -685,12 +809,17 @@ export class BaseModelApiClient {
} }
} }
// 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');
@@ -708,149 +837,23 @@ export class BaseModelApiClient {
} }
} }
} }
}
async moveSingleModel(filePath, targetPath) {
// Only allow move if supported // Export factory functions and utilities
if (!this.apiConfig.config.supportsMove) { export function createModelApiClient(modelType = null) {
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning'); return new ModelApiClient(modelType);
return null; }
}
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) { let _singletonClient = null;
showToast(`${this.apiConfig.config.displayName} is already in the selected folder`, 'info');
return null; export function getModelApiClient() {
} if (!_singletonClient) {
_singletonClient = new ModelApiClient();
const response = await fetch(this.apiConfig.endpoints.moveModel, { }
method: 'POST', _singletonClient.setModelType(state.currentPageType);
headers: { return _singletonClient;
'Content-Type': 'application/json', }
},
body: JSON.stringify({ export async function resetAndReload(updateFolders = false) {
file_path: filePath, return getModelApiClient().loadMoreWithVirtualScroll(true, updateFolders);
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 ${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;
}
async moveBulkModels(filePaths, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast(`Moving ${this.apiConfig.config.displayName}s is not supported`, 'warning');
return [];
}
const movedPaths = filePaths.filter(path => {
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
});
if (movedPaths.length === 0) {
showToast(`All selected ${this.apiConfig.config.displayName}s are already in the target folder`, 'info');
return [];
}
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_paths: movedPaths,
target_path: targetPath
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(`Failed to move ${this.apiConfig.config.displayName}s`);
}
let successFilePaths = [];
if (result.success) {
if (result.failure_count > 0) {
showToast(`Moved ${result.success_count} ${this.apiConfig.config.displayName}s, ${result.failure_count} failed`, 'warning');
console.log('Move operation results:', result.results);
const failedFiles = result.results
.filter(r => !r.success)
.map(r => {
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
return `${fileName}: ${r.message}`;
});
if (failedFiles.length > 0) {
const failureMessage = failedFiles.length <= 3
? failedFiles.join('\n')
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
}
} else {
showToast(`Successfully moved ${result.success_count} ${this.apiConfig.config.displayName}s`, 'success');
}
successFilePaths = result.results
.filter(r => r.success)
.map(r => r.path);
} else {
throw new Error(result.message || `Failed to move ${this.apiConfig.config.displayName}s`);
}
return successFilePaths;
}
async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_paths: filePaths
})
});
if (!response.ok) {
throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
return {
success: true,
deleted_count: result.deleted_count,
failed_count: result.failed_count || 0,
errors: result.errors || []
};
} else {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
}
} catch (error) {
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
throw error;
} finally {
state.loadingManager.hide();
}
}
} }

View File

@@ -1,93 +0,0 @@
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;
}
}
}

View File

@@ -1,8 +0,0 @@
import { BaseModelApiClient } from './baseModelApi.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Embedding-specific API client
*/
export class EmbeddingApiClient extends BaseModelApiClient {
}

View File

@@ -1,94 +0,0 @@
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;
}
}
}

View File

@@ -1,35 +0,0 @@
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 _singletonClients = new Map();
export function getModelApiClient(modelType = null) {
const targetType = modelType || state.currentPageType;
if (!_singletonClients.has(targetType)) {
_singletonClients.set(targetType, createModelApiClient(targetType));
}
return _singletonClients.get(targetType);
}
export function resetAndReload(updateFolders = false) {
const client = getModelApiClient();
return client.loadMoreWithVirtualScroll(true, updateFolders);
}

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.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,7 +54,8 @@ export class CheckpointContextMenu extends BaseContextMenu {
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break; break;
case 'move': case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath, this.currentCard.dataset.model_type); // Move to folder (placeholder)
showToast('Move to folder feature coming soon', 'info');
break; break;
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
import { moveManager } from '../../managers/MoveManager.js'; import { showToast } from '../../utils/uiHelpers.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,7 +54,8 @@ export class EmbeddingContextMenu extends BaseContextMenu {
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath); apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break; break;
case 'move': case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath); // Move to folder (placeholder)
showToast('Move to folder feature coming soon', 'info');
break; break;
case 'exclude': case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath); showExcludeModal(this.currentCard.dataset.filepath);

View File

@@ -1,9 +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/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.js';
import { copyLoraSyntax, sendLoraToWorkflow } from '../../utils/uiHelpers.js'; import { copyToClipboard, 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() {
@@ -37,7 +36,7 @@ export class LoraContextMenu extends BaseContextMenu {
break; break;
case 'copyname': case 'copyname':
// Generate and copy LoRA syntax // Generate and copy LoRA syntax
copyLoraSyntax(this.currentCard); this.copyLoraSyntax();
break; break;
case 'sendappend': case 'sendappend':
// Send LoRA to workflow (append mode) // Send LoRA to workflow (append mode)
@@ -67,6 +66,16 @@ 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 || '{}');

View File

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

View File

@@ -16,9 +16,7 @@ export class HeaderManager {
this.filterManager = null; this.filterManager = null;
// Initialize appropriate managers based on current page // Initialize appropriate managers based on current page
if (this.currentPage !== 'statistics') { this.initializeManagers();
this.initializeManagers();
}
// Set up common header functionality // Set up common header functionality
this.initializeCommonElements(); this.initializeCommonElements();
@@ -39,8 +37,11 @@ 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;
this.filterManager = new FilterManager({ page: this.currentPage }); // Initialize FilterManager for all page types that have filters
window.filterManager = this.filterManager; if (document.getElementById('filterButton')) {
this.filterManager = new FilterManager({ page: this.currentPage });
window.filterManager = this.filterManager;
}
} }
initializeCommonElements() { initializeCommonElements() {

View File

@@ -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/modelApiFactory.js'; import { resetAndReload} from '../api/baseModelApi.js';
import { LoadingManager } from '../managers/LoadingManager.js'; import { LoadingManager } from '../managers/LoadingManager.js';
export class ModelDuplicatesManager { export class ModelDuplicatesManager {

View File

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

View File

@@ -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/modelApiFactory.js'; import { resetAndReload } from '../../api/baseModelApi.js';
/** /**
* AlphabetBar class - Handles the alphabet filtering UI and interactions * AlphabetBar class - Handles the alphabet filtering UI and interactions

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.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';

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.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';

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../../api/baseModelApi.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';

View File

@@ -1,12 +1,10 @@
import { showToast, openCivitai, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, copyToClipboard, 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 { MODEL_TYPES } from '../../api/apiConfig.js'; import { getModelApiClient } from '../../api/baseModelApi.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
@@ -153,7 +151,7 @@ async function toggleFavorite(card) {
} }
function handleSendToWorkflow(card, replaceMode, modelType) { function handleSendToWorkflow(card, replaceMode, modelType) {
if (modelType === MODEL_TYPES.LORA) { if (modelType === 'loras') {
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}>`;
@@ -165,13 +163,16 @@ function handleSendToWorkflow(card, replaceMode, modelType) {
} }
function handleCopyAction(card, modelType) { function handleCopyAction(card, modelType) {
if (modelType === MODEL_TYPES.LORA) { if (modelType === 'loras') {
copyLoraSyntax(card); const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
} else if (modelType === MODEL_TYPES.CHECKPOINT) { const strength = usageTips.strength || 1;
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 === MODEL_TYPES.EMBEDDING) { } else if (modelType === 'embeddings') {
const embeddingName = card.dataset.file_name; const embeddingName = card.dataset.file_name;
copyToClipboard(embeddingName, 'Embedding name copied'); copyToClipboard(embeddingName, 'Embedding name copied');
} }
@@ -241,7 +242,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 === MODEL_TYPES.LORA && { ...(modelType === 'lora' && {
usage_tips: card.dataset.usage_tips, usage_tips: card.dataset.usage_tips,
}) })
}; };
@@ -338,15 +339,6 @@ 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' });
} }
@@ -375,15 +367,10 @@ 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 === MODEL_TYPES.LORA) { if (modelType === 'loras') {
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 || {});
@@ -409,7 +396,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 === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) { if (modelType === 'loras' && state.bulkMode && state.selectedLoras.has(model.file_path)) {
card.classList.add('selected'); card.classList.add('selected');
} }
@@ -457,7 +444,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} style="pointer-events: none;"> `<video ${videoAttrs}>
<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}">`
@@ -485,7 +472,6 @@ 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"

View File

@@ -1,5 +1,3 @@
import { showToast } from '../../utils/uiHelpers.js';
/** /**
* ModelDescription.js * ModelDescription.js
* Handles model description related functionality - General version * Handles model description related functionality - General version
@@ -43,98 +41,3 @@ 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');
}
}

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient } from '../../api/baseModelApi.js';
/** /**
* Set up model name editing functionality * Set up model name editing functionality
@@ -183,7 +183,7 @@ export function setupBaseModelEditing(filePath) {
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1, BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM, BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.QWEN, BASE_MODELS.UNKNOWN BASE_MODELS.UNKNOWN
] ]
}; };

View File

@@ -1,19 +1,16 @@
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, setupModelDescriptionEditing } from './ModelDescription.js'; import { setupTabSwitching } 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/modelApiFactory.js'; import { getModelApiClient } from '../../api/baseModelApi.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';
@@ -183,17 +180,12 @@ 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);
@@ -203,14 +195,12 @@ 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
@@ -223,10 +213,9 @@ export function showModelModal(model, modelType) {
} }
} }
// Load example images asynchronously - merge regular and custom images // Load example images asynchronously
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);
} }
@@ -261,16 +250,14 @@ function renderEmbeddingSpecificContent(embedding, escapedWords) {
} }
/** /**
* Sets up event handlers using event delegation for LoRA modal * Sets up event handlers using event delegation for 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;
@@ -281,9 +268,6 @@ 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;
@@ -296,10 +280,7 @@ 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;
} }
@@ -421,9 +402,7 @@ 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 };

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient } from '../../api/baseModelApi.js';
// Preset tag suggestions // Preset tag suggestions
const PRESET_TAGS = [ const PRESET_TAGS = [

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient } from '../../api/baseModelApi.js';
/** /**
* Parse preset parameters * Parse preset parameters

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient } from '../../api/baseModelApi.js';
/** /**
* Fetch trained words for a model * Fetch trained words for a model

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient } from '../../../api/baseModelApi.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,119 +182,46 @@ 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) {
const mediaWrappers = container.querySelectorAll('.media-wrapper'); // Metadata panel interaction is now handled by the info button
// Keep the existing copy functionality but remove hover-based visibility
const metadataPanel = container.querySelector('.image-metadata-panel');
mediaWrappers.forEach(wrapper => { if (metadataPanel) {
// Get the metadata panel and media element (img or video) // Prevent events from bubbling
const metadataPanel = wrapper.querySelector('.image-metadata-panel'); metadataPanel.addEventListener('click', (e) => {
const mediaControls = wrapper.querySelector('.media-controls'); e.stopPropagation();
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');
}
}); });
wrapper.addEventListener('mouseleave', () => { // Handle copy prompt buttons
if (!isOverMetadataPanel) { const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
if (metadataPanel) metadataPanel.classList.remove('visible'); copyBtns.forEach(copyBtn => {
if (mediaControls) mediaControls.classList.remove('visible'); const promptIndex = copyBtn.dataset.promptIndex;
} const promptElement = container.querySelector(`#prompt-${promptIndex}`);
});
// Add mouse enter/leave events for the metadata panel itself copyBtn.addEventListener('click', async (e) => {
if (metadataPanel) {
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
});
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;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
});
// Handle copy prompt buttons if (!promptElement) return;
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) => { try {
e.stopPropagation(); await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
if (!promptElement) return; console.error('Copy failed:', err);
showToast('Copy failed', 'error');
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 }); });
} });
});
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (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();
}
}, { passive: true });
}
} }
/** /**
@@ -366,9 +293,8 @@ 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; // Don't do anything if button is disabled return;
} }
const shortId = this.dataset.shortId; const shortId = this.dataset.shortId;
@@ -376,14 +302,11 @@ 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';
@@ -395,19 +318,16 @@ 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: {
@@ -422,32 +342,45 @@ export function initMediaControlHandlers(container) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Success: remove the media wrapper from the DOM // Remove the corresponding thumbnail and update main display
mediaWrapper.style.opacity = '0'; const thumbnailItem = container.querySelector(`.thumbnail-item[data-short-id="${shortId}"]`);
mediaWrapper.style.height = '0'; if (thumbnailItem) {
mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s'; const wasActive = thumbnailItem.classList.contains('active');
thumbnailItem.remove();
setTimeout(() => { // If the deleted item was active, select next item
mediaWrapper.remove(); if (wasActive) {
}, 600); const remainingThumbnails = container.querySelectorAll('.thumbnail-item');
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');
@@ -458,7 +391,6 @@ 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');
@@ -469,11 +401,7 @@ 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
} }
/** /**
@@ -545,49 +473,3 @@ 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);
});
}

View File

@@ -23,6 +23,7 @@ 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) {

View File

@@ -9,8 +9,7 @@ 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';
@@ -46,13 +45,10 @@ 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
const carousel = showcaseTab.querySelector('.carousel'); initShowcaseContent(showcaseTab);
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');
@@ -71,13 +67,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 * @param {boolean} startExpanded - Whether to start in expanded state (unused in new design)
* @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 renderImportInterface(true); return renderEmptyShowcase();
} }
// Filter images based on SFW setting // Filter images based on SFW setting
@@ -112,29 +108,69 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
</div>` : ''; </div>` : '';
return ` return `
<div class="scroll-indicator"> ${hiddenNotification}
<i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i> <div class="showcase-container">
<span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span> <div class="thumbnail-sidebar" id="thumbnailSidebar">
</div> <div class="thumbnail-grid">
<div class="carousel ${startExpanded ? '' : 'collapsed'}"> ${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')}
${hiddenNotification} </div>
<div class="carousel-container"> ${renderImportInterface()}
${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')} </div>
<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>
`; `;
} }
/** /**
* Render a single media item (image or video) * Find the matching local file for an image
* @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 media item * @returns {string} HTML for the thumbnail
*/ */
function renderMediaItem(img, index, exampleFiles) { function renderThumbnail(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);
@@ -143,15 +179,57 @@ function renderMediaItem(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');
// Calculate appropriate aspect ratio // Check if media should be blurred
const aspectRatio = (img.height / img.width) * 100; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const containerWidth = 800; // modal content maximum width const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100; return `
const heightPercent = Math.max( <div class="thumbnail-item ${index === 0 ? 'active' : ''}"
minHeightPercent, data-index="${index}"
Math.min(maxHeightPercent, aspectRatio) data-nsfw-level="${nsfwLevel}"
); 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;
@@ -212,69 +290,35 @@ function renderMediaItem(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, heightPercent, shouldBlur, nsfwText, metadataPanel, img, 100, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
return generateImageWrapper( return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel, img, 100, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
/** /**
* Find the matching local file for an image * Render empty showcase with import interface
* @param {Object} img - Image metadata * @returns {string} HTML content for empty showcase
* @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) { function renderEmptyShowcase() {
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 the import interface for example images
* @param {boolean} isEmpty - Whether there are no existing examples
* @returns {string} HTML content for import interface
*/
function renderImportInterface(isEmpty) {
return ` return `
<div class="example-import-area ${isEmpty ? 'empty' : ''}"> <div class="showcase-container empty">
<div class="import-container" id="exampleImportContainer"> <div class="thumbnail-sidebar" id="thumbnailSidebar">
<div class="import-placeholder"> <div class="thumbnail-grid">
<i class="fas fa-cloud-upload-alt"></i> <!-- Empty thumbnails grid -->
<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> </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;"> ${renderImportInterface()}
<div class="import-progress-container" style="display: none;"> </div>
<div class="import-progress"> <div class="main-display-area empty">
<div class="progress-bar"></div> <div class="empty-state">
</div> <i class="fas fa-images"></i>
<span class="progress-text">Importing files...</span> <h3>No example images available</h3>
<p>Import images or videos using the sidebar</p>
</div> </div>
</div> </div>
</div> </div>
@@ -282,310 +326,216 @@ function renderImportInterface(isEmpty) {
} }
/** /**
* Initialize the example import functionality * Render the import interface for example images
* @param {string} modelHash - The SHA256 hash of the model * @returns {string} HTML content for import interface
* @param {Element} container - The container element for the import area
*/ */
export function initExampleImport(modelHash, container) { function renderImportInterface() {
if (!container) return; return `
<div class="import-section">
const importContainer = container.querySelector('#exampleImportContainer'); <button class="select-files-btn" id="selectExampleFilesBtn">
const fileInput = container.querySelector('#exampleFilesInput'); <i class="fas fa-plus"></i>
const selectFilesBtn = container.querySelector('#selectExampleFilesBtn'); <span>Add Images</span>
</button>
// Set up file selection button <div class="import-drop-zone" id="importDropZone">
if (selectFilesBtn) { <div class="drop-zone-content">
selectFilesBtn.addEventListener('click', () => { <i class="fas fa-cloud-upload-alt"></i>
fileInput.click(); <span>Drop here</span>
}); </div>
} </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
// Handle file selection </div>
if (fileInput) { `;
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleImportFiles(Array.from(e.target.files), modelHash, importContainer);
}
});
}
// Set up drag and drop
if (importContainer) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
importContainer.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// 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);
}
}
/**
* Handle the file import process
* @param {File[]} files - Array of files to import
* @param {string} modelHash - The SHA256 hash of the model
* @param {Element} importContainer - The container element for import UI
*/
async function handleImportFiles(files, modelHash, importContainer) {
// Filter for supported file types
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
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 * Initialize all showcase content interactions
* @param {HTMLElement} carousel - The carousel element * @param {HTMLElement} showcase - The showcase element
*/ */
export function initShowcaseContent(carousel) { export function initShowcaseContent(showcase) {
if (!carousel) return; if (!showcase) return;
initLazyLoading(carousel); const container = showcase.querySelector('.showcase-container');
initNsfwBlurHandlers(carousel); if (!container) return;
initMetadataPanelHandlers(carousel);
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
// Bind scroll-indicator click to toggleShowcase initLazyLoading(container);
const scrollIndicator = carousel.previousElementSibling; initNsfwBlurHandlers(container);
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) { initThumbnailNavigation(container);
// Remove previous click listeners to avoid duplicates initMainDisplayHandlers(container);
scrollIndicator.onclick = null; initMediaControlHandlers(container);
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
}
// Add window resize handler // Initialize keyboard navigation
const resizeHandler = () => positionAllMediaControls(carousel); initKeyboardNavigation(container);
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 * Initialize thumbnail navigation
* @param {HTMLElement} button - Back to top button * @param {HTMLElement} container - The showcase container
*/ */
export function scrollToTop(button) { function initThumbnailNavigation(container) {
const modalContent = button.closest('.modal-content'); const thumbnails = container.querySelectorAll('.thumbnail-item');
if (modalContent) { const mainContainer = container.querySelector('#mainMediaContainer');
modalContent.scrollTo({
top: 0, if (!mainContainer) return;
behavior: 'smooth'
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) {
nextBtn.addEventListener('click', () => navigateMedia(container, 1));
}
if (infoBtn) {
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');
} }
} }
/** /**
* Set up showcase scroll functionality * Initialize metadata panel toggle behavior
* @param {string} modalId - ID of the modal element * @param {HTMLElement} container - The showcase container
*/ */
export function setupShowcaseScroll(modalId) { function initMetadataPanelToggle(container) {
// Listen for wheel events const metadataPanel = container.querySelector('.image-metadata-panel');
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section'); if (!metadataPanel) return;
if (!showcase) return;
const carousel = showcase.querySelector('.carousel'); // Handle copy prompt buttons
const scrollIndicator = showcase.querySelector('.scroll-indicator'); const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) { copyBtn.addEventListener('click', async (e) => {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100; e.stopPropagation();
if (isNearBottom) { if (!promptElement) return;
toggleShowcase(scrollIndicator);
event.preventDefault(); try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
} }
} });
}, { 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 }); // Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Try to set up the button immediately in case the modal is already open if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
const modalContent = document.querySelector(`#${modalId} .modal-content`); e.stopPropagation();
if (modalContent) { }
setupBackToTopButton(modalContent); }, { passive: true });
}
} }
/** /**
* Set up back-to-top button * Update main display with new media item
* @param {HTMLElement} modalContent - Modal content element * @param {number} index - Index of the media to display
* @param {HTMLElement} container - The showcase container
*/ */
function setupBackToTopButton(modalContent) { function updateMainDisplay(index, container) {
// Remove any existing scroll listeners to avoid duplicates // This function would need to re-render the main display area
modalContent.onscroll = null; // Implementation depends on how the image data is stored and accessed
console.log('Update main display to index:', index);
// 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'));
} }

View File

@@ -5,11 +5,8 @@ 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';
@@ -30,22 +27,16 @@ 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

View File

@@ -1,6 +1,8 @@
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';
@@ -31,6 +33,15 @@ 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;
@@ -45,6 +56,9 @@ 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();
} }

View File

@@ -1,197 +0,0 @@
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);
// If already initialized, render the banner immediately
if (this.initialized && !this.isBannerDismissed(id) && this.container) {
this.renderBanner(bannerConfig);
this.updateContainerVisibility();
}
}
/**
* 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) {
// Call onRemove callback if provided
const banner = this.banners.get(bannerId);
if (banner && typeof banner.onRemove === 'function') {
banner.onRemove(bannerElement);
}
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => {
bannerElement.remove();
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 => {
const actionAttribute = action.action ? `data-action="${action.action}"` : '';
const href = action.url ? `href="${action.url}"` : '#';
const target = action.url ? 'target="_blank" rel="noopener noreferrer"' : '';
return `<a ${href ? `href="${href}"` : ''} ${target} class="banner-action banner-action-${action.type}" ${actionAttribute}>
<i class="${action.icon}"></i>
<span>${action.text}</span>
</a>`;
}).join('') : '';
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);
// Call onRegister callback if provided
if (typeof banner.onRegister === 'function') {
banner.onRegister(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;

View File

@@ -1,201 +1,147 @@
import { state, getCurrentPageState } from '../state/index.js'; import { state } 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 { moveManager } from './MoveManager.js'; import { getModelApiClient } from '../api/baseModelApi.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; this.isStripVisible = false; // Track strip visibility state
this.stripMaxThumbnails = 50; // Initialize selected loras set in state if not already there
if (!state.selectedLoras) {
state.selectedLoras = new Set();
}
// Model type specific action configurations // Cache for lora metadata to handle non-visible selected loras
this.actionConfig = { if (!state.loraMetadataCache) {
[MODEL_TYPES.LORA]: { state.loraMetadataCache = new Map();
sendToWorkflow: true, }
copyAll: true,
refreshAll: true, this.stripMaxThumbnails = 50; // Maximum thumbnails to show in strip
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() {
this.setupEventListeners(); // Add event listeners if needed
this.setupGlobalKeyboardListeners(); // (Already handled via onclick attributes in HTML, but could be moved here)
}
setupEventListeners() { // Add event listeners for the selected count to toggle thumbnail strip
// 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());
} }
}
setupGlobalKeyboardListeners() { // Add global keyboard event listener for Ctrl+A
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; return; // Exit early - let the browser handle Ctrl+A within the modal
} }
// 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; return; // Exit early - let the browser handle Ctrl+A within the search input
} }
// 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();
setTimeout(() => this.selectAllVisibleModels(), 50); // Small delay to ensure DOM is updated
setTimeout(() => this.selectAllVisibleLoras(), 50);
} else { } else {
this.selectAllVisibleModels(); this.selectAllVisibleLoras();
} }
} 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');
this.updateActionButtonsVisibility(); // Use setTimeout to ensure the DOM updates before adding visible class
// 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); }, 400); // Match this with the transition duration in CSS
// 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: // TODO: fix this, no DOM manipulation should be done here
// Force a lightweight refresh of the cards to ensure proper display
// This is less disruptive than a full resetAndReload()
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.selectedModels.clear(); state.selectedLoras.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) {
countElement.textContent = `${state.selectedModels.size} ${displayName.toLowerCase()}(s) selected `; // Set text content without the icon
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.selectedModels.size > 0 ? 'visible' : 'hidden'; existingCaret.style.visibility = state.selectedLoras.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.selectedModels.size > 0 ? 'visible' : 'hidden'; caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
countElement.appendChild(caretIcon); countElement.appendChild(caretIcon);
} }
} }
@@ -203,18 +149,16 @@ 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.selectedModels.delete(filepath); state.selectedLoras.delete(filepath);
} else { } else {
card.classList.add('selected'); card.classList.add('selected');
state.selectedModels.add(filepath); state.selectedLoras.add(filepath);
// Cache the metadata for this model // Cache the metadata for this lora
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),
@@ -225,49 +169,35 @@ export class BulkManager {
this.updateSelectedCount(); this.updateSelectedCount();
// Update thumbnail strip if it's visible
if (this.isStripVisible) { if (this.isStripVisible) {
this.updateThumbnailStrip(); this.updateThumbnailStrip();
} }
} }
getMetadataCache() { // Helper method to get preview URL from a card
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.selectedModels.has(filepath)) { if (state.selectedLoras.has(filepath)) {
card.classList.add('selected'); card.classList.add('selected');
const metadataCache = this.getMetadataCache(); // Update the cache with latest data
metadataCache.set(filepath, { state.loraMetadataCache.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),
@@ -282,33 +212,30 @@ export class BulkManager {
this.updateSelectedCount(); this.updateSelectedCount();
} }
async copyAllModelsSyntax() { async copyAllLorasSyntax() {
if (state.currentPageType !== MODEL_TYPES.LORA) { if (state.selectedLoras.size === 0) {
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();
for (const filepath of state.selectedModels) { // Process all selected loras using our metadata cache
const metadata = metadataCache.get(filepath); for (const filepath of state.selectedLoras) {
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');
@@ -322,33 +249,31 @@ 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`);
} }
async sendAllModelsToWorkflow() { // Add method to send all selected loras to workflow
if (state.currentPageType !== MODEL_TYPES.LORA) { async sendAllLorasToWorkflow() {
showToast('Send to workflow is only available for LoRAs', 'warning'); if (state.selectedLoras.size === 0) {
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();
for (const filepath of state.selectedModels) { // Process all selected loras using our metadata cache
const metadata = metadataCache.get(filepath); for (const filepath of state.selectedLoras) {
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');
@@ -359,48 +284,82 @@ 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.selectedModels.size === 0) { if (state.selectedLoras.size === 0) {
showToast('No models selected', 'warning'); showToast('No LoRAs 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.selectedModels.size; countElement.textContent = state.selectedLoras.size;
} }
// Show the modal
modalManager.showModal('bulkDeleteModal'); modalManager.showModal('bulkDeleteModal');
} }
// Confirm bulk delete action
async confirmBulkDelete() { async confirmBulkDelete() {
if (state.selectedModels.size === 0) { if (state.selectedLoras.size === 0) {
showToast('No models selected', 'warning'); showToast('No LoRAs 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 {
const apiClient = getModelApiClient(); // Show loading indicator
const filePaths = Array.from(state.selectedModels); state.loadingManager.showSimpleLoading('Deleting models...');
const result = await apiClient.bulkDeleteModels(filePaths); // Gather all file paths for deletion
const filePaths = Array.from(state.selectedLoras);
// Call the backend API
const response = await fetch('/api/loras/bulk-delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_paths: filePaths
})
});
const result = await response.json();
if (result.success) { if (result.success) {
const currentConfig = MODEL_CONFIG[state.currentPageType]; showToast(`Successfully deleted ${result.deleted_count} models`, 'success');
showToast(`Successfully deleted ${result.deleted_count} ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
filePaths.forEach(path => { // If virtual scroller exists, update the UI without page reload
state.virtualScroller.removeItemByFilePath(path); if (state.virtualScroller) {
}); // Remove each deleted item from the virtual scroller
this.clearSelection(); filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
});
// Clear the selection
this.clearSelection();
} else {
// Clear the selection
this.clearSelection();
// Fall back to page reload for non-virtual scroll mode
setTimeout(() => {
window.location.reload();
}, 1500);
}
if (window.modelDuplicatesManager) { if (window.modelDuplicatesManager) {
// Update duplicates badge after refresh
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh(); window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
} }
} else { } else {
@@ -409,11 +368,16 @@ 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 (state.selectedModels.size === 0) return; // If no items are selected, do nothing
if (state.selectedLoras.size === 0) return;
const existing = document.querySelector('.selected-thumbnails-strip'); const existing = document.querySelector('.selected-thumbnails-strip');
if (existing) { if (existing) {
@@ -424,30 +388,38 @@ 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(); this.updateSelectedCount(); // Update caret
// 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) { if (strip && this.isStripVisible) { // Only hide if actually visible
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');
@@ -456,6 +428,7 @@ 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);
@@ -468,28 +441,33 @@ 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 = '';
const selectedModels = Array.from(state.selectedModels); // Get all selected loras
const selectedLoras = Array.from(state.selectedLoras);
if (selectedModels.length > this.stripMaxThumbnails) { // Create counter if we have more thumbnails than we'll show
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 ${selectedModels.length} selected`; counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedLoras.length} selected`;
container.appendChild(counter); container.appendChild(counter);
} }
const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails); // Limit the number of thumbnails to display
const metadataCache = this.getMetadataCache(); const thumbnailsToShow = selectedLoras.slice(0, this.stripMaxThumbnails);
// Add a thumbnail for each selected LoRA (limited to max)
thumbnailsToShow.forEach(filepath => { thumbnailsToShow.forEach(filepath => {
const metadata = metadataCache.get(filepath); const metadata = state.loraMetadataCache.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>
@@ -506,12 +484,14 @@ 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);
@@ -522,36 +502,43 @@ 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');
} }
state.selectedModels.delete(filepath); // Remove from the selection set
state.selectedLoras.delete(filepath);
// Update UI
this.updateSelectedCount(); this.updateSelectedCount();
this.updateThumbnailStrip(); this.updateThumbnailStrip();
if (state.selectedModels.size === 0) { // Hide the strip if no more selections
if (state.selectedLoras.size === 0) {
this.hideThumbnailStrip(); this.hideThumbnailStrip();
} }
} }
selectAllVisibleModels() { // Add method to select all visible loras
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.selectedModels.size; const oldCount = state.selectedLoras.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.selectedModels.add(item.file_path); state.selectedLoras.add(item.file_path);
if (!metadataCache.has(item.file_path)) { // Add to metadata cache if not already there
metadataCache.set(item.file_path, { if (!state.loraMetadataCache.has(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',
@@ -562,37 +549,45 @@ export class BulkManager {
} }
}); });
// Update visual state
this.applySelectionState(); this.applySelectionState();
const newlySelected = state.selectedModels.size - oldCount; // Show success message
const currentConfig = MODEL_CONFIG[state.currentPageType]; const newlySelected = state.selectedLoras.size - oldCount;
showToast(`Selected ${newlySelected} additional ${currentConfig.displayName.toLowerCase()}(s)`, 'success'); showToast(`Selected ${newlySelected} additional LoRAs`, '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.selectedModels.size === 0) { if (state.selectedLoras.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) {
const metadataCache = this.getMetadataCache(); // Update the metadata cache for successfully refreshed items
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) {
// 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) {
metadataCache.set(filepath, { state.loraMetadataCache.set(filepath, {
...metadata, ...metadata,
fileName: card.dataset.file_name, fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips, usageTips: card.dataset.usage_tips,
@@ -604,6 +599,7 @@ export class BulkManager {
} }
} }
// Update thumbnail strip if visible
if (this.isStripVisible) { if (this.isStripVisible) {
this.updateThumbnailStrip(); this.updateThumbnailStrip();
} }
@@ -616,4 +612,5 @@ export class BulkManager {
} }
} }
// Create a singleton instance
export const bulkManager = new BulkManager(); export const bulkManager = new BulkManager();

View File

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

View File

@@ -1,5 +1,4 @@
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
@@ -15,12 +14,6 @@ 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();
@@ -55,14 +48,6 @@ 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
@@ -148,15 +133,6 @@ 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);
@@ -670,106 +646,6 @@ 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

View File

@@ -1,6 +1,7 @@
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/modelApiFactory.js'; import { getModelApiClient } from '../api/baseModelApi.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
export class FilterManager { export class FilterManager {
@@ -66,7 +67,14 @@ 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
const tagsEndpoint = `/api/${this.currentPage}/top-tags?limit=20`; let tagsEndpoint = '/api/loras/top-tags?limit=20';
if (this.currentPage === 'recipes') {
tagsEndpoint = '/api/recipes/top-tags?limit=20';
} else if (this.currentPage === 'checkpoints') {
tagsEndpoint = '/api/checkpoints/top-tags?limit=20';
} else if (this.currentPage === 'embeddings') {
tagsEndpoint = '/api/embeddings/top-tags?limit=20';
}
const 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');
@@ -133,8 +141,19 @@ export class FilterManager {
const baseModelTagsContainer = document.getElementById('baseModelTags'); const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page // Set the appropriate API endpoint based on current page
const apiEndpoint = `/api/${this.currentPage}/base-models`; let apiEndpoint = '';
if (this.currentPage === 'loras') {
apiEndpoint = '/api/loras/base-models';
} else if (this.currentPage === 'recipes') {
apiEndpoint = '/api/recipes/base-models';
} else if (this.currentPage === 'checkpoints') {
apiEndpoint = '/api/checkpoints/base-models';
} else if (this.currentPage === 'embeddings') {
apiEndpoint = '/api/embeddings/base-models';
} else {
return;
}
// Fetch base models // Fetch base models
fetch(apiEndpoint) fetch(apiEndpoint)

View File

@@ -1,5 +1,7 @@
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';
@@ -84,8 +86,8 @@ export class ImportManager {
const uploadError = document.getElementById('uploadError'); const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = ''; if (uploadError) uploadError.textContent = '';
const importUrlError = document.getElementById('importUrlError'); const urlError = document.getElementById('urlError');
if (importUrlError) importUrlError.textContent = ''; if (urlError) urlError.textContent = '';
const recipeName = document.getElementById('recipeName'); const recipeName = document.getElementById('recipeName');
if (recipeName) recipeName.value = ''; if (recipeName) recipeName.value = '';
@@ -165,10 +167,10 @@ export class ImportManager {
// Clear error messages // Clear error messages
const uploadError = document.getElementById('uploadError'); const uploadError = document.getElementById('uploadError');
const importUrlError = document.getElementById('importUrlError'); const urlError = document.getElementById('urlError');
if (uploadError) uploadError.textContent = ''; if (uploadError) uploadError.textContent = '';
if (importUrlError) importUrlError.textContent = ''; if (urlError) urlError.textContent = '';
} }
handleImageUpload(event) { handleImageUpload(event) {
@@ -222,8 +224,8 @@ export class ImportManager {
const uploadError = document.getElementById('uploadError'); const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = ''; if (uploadError) uploadError.textContent = '';
const importUrlError = document.getElementById('importUrlError'); const urlError = document.getElementById('urlError');
if (importUrlError) importUrlError.textContent = ''; if (urlError) urlError.textContent = '';
} }
backToDetails() { backToDetails() {

View File

@@ -1,178 +1,152 @@
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/modelApiFactory.js'; import { getModelApiClient } from '../api/baseModelApi.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
class MoveManager { class MoveManager {
constructor() { constructor() {
this.currentFilePath = null; this.currentFilePath = null;
this.bulkFilePaths = null; this.bulkFilePaths = null;
this.folderTreeManager = new FolderTreeManager(); this.modal = document.getElementById('moveModal');
this.initialized = false; this.loraRootSelect = document.getElementById('moveLoraRoot');
this.folderBrowser = document.getElementById('moveFolderBrowser');
this.newFolderInput = document.getElementById('moveNewFolder');
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
this.modalTitle = document.getElementById('moveModalTitle');
// Bind methods this.initializeEventListeners();
this.updateTargetPath = this.updateTargetPath.bind(this);
} }
initializeEventListeners() { initializeEventListeners() {
if (this.initialized) return; // 初始化LoRA根目录选择器
this.loraRootSelect.addEventListener('change', () => this.updatePathPreview());
const modelRootSelect = document.getElementById('moveModelRoot'); // 文件夹选择事件
this.folderBrowser.addEventListener('click', (e) => {
const folderItem = e.target.closest('.folder-item');
if (!folderItem) return;
// Initialize model root directory selector // 如果点击已选中的文件夹,则取消选择
modelRootSelect.addEventListener('change', async () => { if (folderItem.classList.contains('selected')) {
await this.initializeFolderTree(); folderItem.classList.remove('selected');
this.updateTargetPath(); } else {
// 取消其他选中状态
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
item.classList.remove('selected');
});
// 设置当前选中状态
folderItem.classList.add('selected');
}
this.updatePathPreview();
}); });
this.initialized = true; // 新文件夹输入事件
this.newFolderInput.addEventListener('input', () => this.updatePathPreview());
} }
async showMoveModal(filePath, modelType = null) { async showMoveModal(filePath) {
// 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.selectedModels); const selectedPaths = Array.from(state.selectedLoras);
if (selectedPaths.length === 0) { if (selectedPaths.length === 0) {
showToast('No models selected', 'warning'); showToast('No LoRAs selected', 'warning');
return; return;
} }
this.bulkFilePaths = selectedPaths; this.bulkFilePaths = selectedPaths;
document.getElementById('moveModalTitle').textContent = `Move ${selectedPaths.length} ${modelConfig.displayName}s`; this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`;
} else { } else {
// Single file mode // Single file mode
this.currentFilePath = filePath; this.currentFilePath = filePath;
document.getElementById('moveModalTitle').textContent = `Move ${modelConfig.displayName}`; this.modalTitle.textContent = "Move Model";
} }
// Update UI labels based on model type // 清除之前的选择
document.getElementById('moveRootLabel').textContent = `Select ${modelConfig.displayName} Root:`; this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
document.getElementById('moveTargetPathDisplay').querySelector('.path-text').textContent = `Select a ${modelConfig.displayName.toLowerCase()} root directory`; item.classList.remove('selected');
});
// Clear folder path input this.newFolderInput.value = '';
const folderPathInput = document.getElementById('moveFolderPath');
if (folderPathInput) {
folderPathInput.value = '';
}
try { try {
// Fetch model roots // Fetch LoRA roots
const modelRootSelect = document.getElementById('moveModelRoot'); const rootsResponse = await fetch('/api/loras/roots');
let rootsData; if (!rootsResponse.ok) {
if (modelType) { throw new Error('Failed to fetch LoRA roots');
rootsData = await apiClient.fetchModelRoots(modelType);
} else {
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 ${modelConfig.displayName.toLowerCase()} roots found`); throw new Error('No LoRA roots found');
} }
// Populate model root selector // 填充LoRA根目录选择器
modelRootSelect.innerHTML = rootsData.roots.map(root => this.loraRootSelect.innerHTML = rootsData.roots.map(root =>
`<option value="${root}">${root}</option>` `<option value="${root}">${root}</option>`
).join(''); ).join('');
// Set default root if available // Set default lora root if available
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; const defaultRoot = getStorageItem('settings', {}).default_loras_root;
const defaultRoot = getStorageItem('settings', {})[settingsKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
modelRootSelect.value = defaultRoot; this.loraRootSelect.value = defaultRoot;
} }
// Initialize event listeners // Fetch folders dynamically
this.initializeEventListeners(); const foldersResponse = await fetch('/api/loras/folders');
if (!foldersResponse.ok) {
throw new Error('Failed to fetch folders');
}
// Setup folder tree manager const foldersData = await foldersResponse.json();
this.folderTreeManager.init({
onPathChange: (path) => {
this.updateTargetPath();
},
elementsPrefix: 'move'
});
// Initialize folder tree // Update folder browser with dynamic content
await this.initializeFolderTree(); this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
).join('');
this.updateTargetPath(); this.updatePathPreview();
modalManager.showModal('moveModal', null, () => { modalManager.showModal('moveModal');
// Cleanup on modal close
if (this.folderTreeManager) {
this.folderTreeManager.destroy();
}
});
} catch (error) { } catch (error) {
console.error(`Error fetching ${modelConfig.displayName.toLowerCase()} roots or folders:`, error); console.error('Error fetching LoRA roots or folders:', error);
showToast(error.message, 'error'); showToast(error.message, 'error');
} }
} }
async initializeFolderTree() { updatePathPreview() {
try { const selectedRoot = this.loraRootSelect.value;
const apiClient = getModelApiClient(); const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
// Fetch unified folder tree const newFolder = this.newFolderInput.value.trim();
const treeData = await apiClient.fetchUnifiedFolderTree();
if (treeData.success) { let targetPath = selectedRoot;
// Load tree data into folder tree manager if (selectedFolder) {
await this.folderTreeManager.loadTree(treeData.tree); targetPath = `${targetPath}/${selectedFolder}`;
} else {
console.error('Failed to fetch folder tree:', treeData.error);
showToast('Failed to load folder tree', 'error');
}
} catch (error) {
console.error('Error initializing folder tree:', error);
showToast('Error loading folder tree', 'error');
} }
} if (newFolder) {
targetPath = `${targetPath}/${newFolder}`;
updateTargetPath() {
const pathDisplay = document.getElementById('moveTargetPathDisplay');
const modelRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient();
const config = apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
if (modelRoot) {
const selectedPath = this.folderTreeManager ? this.folderTreeManager.getSelectedPath() : '';
if (selectedPath) {
fullPath += '/' + selectedPath;
}
} }
pathDisplay.innerHTML = `<span class="path-text">${fullPath}</span>`; this.pathDisplay.querySelector('.path-text').textContent = targetPath;
} }
async moveModel() { async moveModel() {
const selectedRoot = document.getElementById('moveModelRoot').value; const selectedRoot = this.loraRootSelect.value;
const apiClient = getModelApiClient(); const selectedFolder = this.folderBrowser.querySelector('.folder-item.selected')?.dataset.folder || '';
const config = apiClient.apiConfig.config; const newFolder = this.newFolderInput.value.trim();
if (!selectedRoot) {
showToast(`Please select a ${config.displayName.toLowerCase()} root directory`, 'error');
return;
}
// Get selected folder path from folder tree manager
const targetFolder = this.folderTreeManager.getSelectedPath();
let targetPath = selectedRoot; let targetPath = selectedRoot;
if (targetFolder) { if (selectedFolder) {
targetPath = `${targetPath}/${targetFolder}`; targetPath = `${targetPath}/${selectedFolder}`;
} }
if (newFolder) {
targetPath = `${targetPath}/${newFolder}`;
}
const apiClient = getModelApiClient();
try { try {
if (this.bulkFilePaths) { if (this.bulkFilePaths) {
@@ -217,8 +191,11 @@ class MoveManager {
// Refresh folder tags after successful move // Refresh folder tags after successful move
try { try {
const foldersData = await apiClient.fetchModelFolders(); const foldersResponse = await fetch('/api/loras/folders');
updateFolderTags(foldersData.folders); if (foldersResponse.ok) {
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);
} }
@@ -227,7 +204,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) {
bulkManager.toggleBulkMode(); toggleBulkMode();
} }
} catch (error) { } catch (error) {

View File

@@ -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/modelApiFactory.js"; import { getModelApiClient } from "../api/baseModelApi.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,7 +318,6 @@ 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') {
@@ -326,7 +325,6 @@ 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
}; };
} }

View File

@@ -1,9 +1,9 @@
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/modelApiFactory.js'; import { resetAndReload } from '../api/baseModelApi.js';
import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js'; import { setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES } from '../utils/constants.js'; import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS } from '../utils/constants.js';
export class SettingsManager { export class SettingsManager {
constructor() { constructor() {
@@ -16,9 +16,6 @@ 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();
} }
@@ -26,13 +23,6 @@ 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 };
@@ -48,11 +38,6 @@ 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';
@@ -73,88 +58,15 @@ export class SettingsManager {
// We can delete the old setting, but keeping it for backwards compatibility // We can delete the old setting, but keeping it for backwards compatibility
} }
// Migrate legacy download_path_template to new structure // Set default for download path template if undefined
if (state.global.settings.download_path_template && !state.global.settings.download_path_templates) { if (state.global.settings.download_path_template === undefined) {
const legacyTemplate = state.global.settings.download_path_template; state.global.settings.download_path_template = DOWNLOAD_PATH_TEMPLATES.BASE_MODEL_TAG.value;
state.global.settings.download_path_templates = {
lora: legacyTemplate,
checkpoint: legacyTemplate,
embedding: legacyTemplate
};
delete state.global.settings.download_path_template;
setStorageItem('settings', state.global.settings);
} }
// Set default for download path templates if undefined
if (state.global.settings.download_path_templates === undefined) {
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
}
// Ensure all model types have templates
Object.keys(DEFAULT_PATH_TEMPLATES).forEach(modelType => {
if (typeof state.global.settings.download_path_templates[modelType] === 'undefined') {
state.global.settings.download_path_templates[modelType] = DEFAULT_PATH_TEMPLATES[modelType];
}
});
// Set default for base model path mappings if undefined // Set default for base model path mappings if undefined
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_templates'
];
// Build payload for syncing
const payload = {};
fieldsToSync.forEach(key => {
if (localSettings[key] !== undefined) {
if (key === 'base_model_path_mappings' || key === 'download_path_templates') {
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() {
@@ -184,30 +96,6 @@ export class SettingsManager {
button.addEventListener('click', () => this.toggleInputVisibility(button)); button.addEventListener('click', () => this.toggleInputVisibility(button));
}); });
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (customInput) {
customInput.addEventListener('input', (e) => {
const template = e.target.value;
settingsManager.validateTemplate(modelType, template);
settingsManager.updateTemplatePreview(modelType, template);
});
customInput.addEventListener('blur', (e) => {
const template = e.target.value;
if (settingsManager.validateTemplate(modelType, template)) {
settingsManager.updateTemplate(modelType, template);
}
});
customInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.target.blur();
}
});
}
});
this.initialized = true; this.initialized = true;
} }
@@ -248,19 +136,11 @@ export class SettingsManager {
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false; optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
} }
// Set auto download example images setting // Set download path template setting
const autoDownloadExampleImagesCheckbox = document.getElementById('autoDownloadExampleImages'); const downloadPathTemplateSelect = document.getElementById('downloadPathTemplate');
if (autoDownloadExampleImagesCheckbox) { if (downloadPathTemplateSelect) {
autoDownloadExampleImagesCheckbox.checked = state.global.settings.autoDownloadExampleImages || false; downloadPathTemplateSelect.value = state.global.settings.download_path_template || '';
} this.updatePathTemplatePreview();
// Load download path templates
this.loadDownloadPathTemplates();
// Set include trigger words setting
const includeTriggerWordsCheckbox = document.getElementById('includeTriggerWords');
if (includeTriggerWordsCheckbox) {
includeTriggerWordsCheckbox.checked = state.global.settings.includeTriggerWords || false;
} }
// Load base model path mappings // Load base model path mappings
@@ -272,8 +152,7 @@ export class SettingsManager {
// Load default checkpoint root // Load default checkpoint root
await this.loadCheckpointRoots(); await this.loadCheckpointRoots();
// Load default embedding root // Backend settings are loaded from the template directly
await this.loadEmbeddingRoots();
} }
async loadLoraRoots() { async loadLoraRoots() {
@@ -306,7 +185,7 @@ export class SettingsManager {
}); });
// Set selected value from settings // Set selected value from settings
const defaultRoot = state.global.settings.default_lora_root || ''; const defaultRoot = state.global.settings.default_loras_root || '';
defaultLoraRootSelect.value = defaultRoot; defaultLoraRootSelect.value = defaultRoot;
} catch (error) { } catch (error) {
@@ -354,45 +233,6 @@ 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;
@@ -568,184 +408,19 @@ export class SettingsManager {
} }
} }
loadDownloadPathTemplates() { updatePathTemplatePreview() {
const templates = state.global.settings.download_path_templates || DEFAULT_PATH_TEMPLATES; const templateSelect = document.getElementById('downloadPathTemplate');
const previewElement = document.getElementById('pathTemplatePreview');
if (!templateSelect || !previewElement) return;
Object.keys(templates).forEach(modelType => { const template = templateSelect.value;
this.loadTemplateForModelType(modelType, templates[modelType]); const templateInfo = Object.values(DOWNLOAD_PATH_TEMPLATES).find(t => t.value === template);
});
}
loadTemplateForModelType(modelType, template) { if (templateInfo) {
const presetSelect = document.getElementById(`${modelType}TemplatePreset`); previewElement.textContent = templateInfo.example;
const customRow = document.getElementById(`${modelType}CustomRow`); previewElement.style.display = 'block';
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (!presetSelect) return;
// Find matching preset
const matchingPreset = this.findMatchingPreset(template);
if (matchingPreset !== null) {
presetSelect.value = matchingPreset;
if (customRow) customRow.style.display = 'none';
} else { } else {
// Custom template previewElement.style.display = 'none';
presetSelect.value = 'custom';
if (customRow) customRow.style.display = 'block';
if (customInput) {
customInput.value = template;
this.validateTemplate(modelType, template);
}
}
this.updateTemplatePreview(modelType, template);
}
findMatchingPreset(template) {
const presetValues = Object.values(DOWNLOAD_PATH_TEMPLATES)
.map(t => t.value)
.filter(v => v !== 'custom');
return presetValues.includes(template) ? template : null;
}
updateTemplatePreset(modelType, value) {
const customRow = document.getElementById(`${modelType}CustomRow`);
const customInput = document.getElementById(`${modelType}CustomTemplate`);
if (value === 'custom') {
if (customRow) customRow.style.display = 'block';
if (customInput) customInput.focus();
return;
} else {
if (customRow) customRow.style.display = 'none';
}
// Update template
this.updateTemplate(modelType, value);
}
updateTemplate(modelType, template) {
// Validate template if it's custom
if (document.getElementById(`${modelType}TemplatePreset`).value === 'custom') {
if (!this.validateTemplate(modelType, template)) {
return; // Don't save invalid templates
}
}
// Update state
if (!state.global.settings.download_path_templates) {
state.global.settings.download_path_templates = { ...DEFAULT_PATH_TEMPLATES };
}
state.global.settings.download_path_templates[modelType] = template;
// Update preview
this.updateTemplatePreview(modelType, template);
// Save settings
this.saveDownloadPathTemplates();
}
validateTemplate(modelType, template) {
const validationElement = document.getElementById(`${modelType}Validation`);
if (!validationElement) return true;
// Reset validation state
validationElement.innerHTML = '';
validationElement.className = 'template-validation';
if (!template) {
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid (flat structure)';
validationElement.classList.add('valid');
return true;
}
// Check for invalid characters
const invalidChars = /[<>:"|?*]/;
if (invalidChars.test(template)) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Invalid characters detected';
validationElement.classList.add('invalid');
return false;
}
// Check for double slashes
if (template.includes('//')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Double slashes not allowed';
validationElement.classList.add('invalid');
return false;
}
// Check if it starts or ends with slash
if (template.startsWith('/') || template.endsWith('/')) {
validationElement.innerHTML = '<i class="fas fa-times"></i> Cannot start or end with slash';
validationElement.classList.add('invalid');
return false;
}
// Extract placeholders
const placeholderRegex = /\{([^}]+)\}/g;
const matches = template.match(placeholderRegex) || [];
// Check for invalid placeholders
const invalidPlaceholders = matches.filter(match =>
!PATH_TEMPLATE_PLACEHOLDERS.includes(match)
);
if (invalidPlaceholders.length > 0) {
validationElement.innerHTML = `<i class="fas fa-times"></i> Invalid placeholder: ${invalidPlaceholders[0]}`;
validationElement.classList.add('invalid');
return false;
}
// Template is valid
validationElement.innerHTML = '<i class="fas fa-check"></i> Valid template';
validationElement.classList.add('valid');
return true;
}
updateTemplatePreview(modelType, template) {
const previewElement = document.getElementById(`${modelType}Preview`);
if (!previewElement) return;
if (!template) {
previewElement.textContent = 'model-name.safetensors';
} else {
// Generate example preview
const exampleTemplate = template
.replace('{base_model}', 'Flux.1 D')
.replace('{author}', 'authorname')
.replace('{first_tag}', 'style');
previewElement.textContent = `${exampleTemplate}/model-name.safetensors`;
}
previewElement.style.display = 'block';
}
async saveDownloadPathTemplates() {
try {
// Save to localStorage
setStorageItem('settings', state.global.settings);
// Save to backend
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
download_path_templates: JSON.stringify(state.global.settings.download_path_templates)
})
});
if (!response.ok) {
throw new Error('Failed to save download path templates');
}
showToast('Download path templates updated', 'success');
} catch (error) {
console.error('Error saving download path templates:', error);
showToast('Failed to save download path templates: ' + error.message, 'error');
} }
} }
@@ -773,12 +448,8 @@ 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;
@@ -789,7 +460,7 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // For backend settings, make API call
if (['show_only_sfw'].includes(settingKey)) { if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover', 'optimize_example_images', 'use_centralized_examples'].includes(settingKey)) {
const payload = {}; const payload = {};
payload[settingKey] = value; payload[settingKey] = value;
@@ -804,23 +475,14 @@ 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();
// Trigger auto download setup/teardown when setting changes if (settingKey === 'show_only_sfw') {
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();
} }
@@ -843,11 +505,9 @@ export class SettingsManager {
// Update frontend state // Update frontend state
if (settingKey === 'default_lora_root') { if (settingKey === 'default_lora_root') {
state.global.settings.default_lora_root = value; state.global.settings.default_loras_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;
@@ -855,6 +515,9 @@ export class SettingsManager {
state.global.settings.compactMode = (value !== 'default'); state.global.settings.compactMode = (value !== 'default');
} else if (settingKey === 'card_info_display') { } else if (settingKey === 'card_info_display') {
state.global.settings.cardInfoDisplay = value; state.global.settings.cardInfoDisplay = value;
} else if (settingKey === 'download_path_template') {
state.global.settings.download_path_template = value;
this.updatePathTemplatePreview();
} 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;
@@ -865,13 +528,9 @@ 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 === 'default_embedding_root' || settingKey === 'download_path_templates') { if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'download_path_template') {
const payload = {}; const payload = {};
if (settingKey === 'download_path_templates') { payload[settingKey] = value;
payload[settingKey] = JSON.stringify(state.global.settings.download_path_templates);
} else {
payload[settingKey] = value;
}
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
method: 'POST', method: 'POST',
@@ -924,7 +583,10 @@ export class SettingsManager {
// Update state // Update state
state.global.settings[settingKey] = value; state.global.settings[settingKey] = value;
setStorageItem('settings', state.global.settings); // Save to localStorage if appropriate
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 = {};
@@ -1003,13 +665,83 @@ 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 => {

View File

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

View File

@@ -1,6 +1,4 @@
import { showToast } from '../../utils/uiHelpers.js'; import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
export class DownloadManager { export class DownloadManager {
constructor(importManager) { constructor(importManager) {
@@ -202,16 +200,29 @@ export class DownloadManager {
try { try {
// Download the LoRA with download ID // Download the LoRA with download ID
const response = await getModelApiClient(MODEL_TYPES.LORA).downloadModel( const response = await fetch('/api/download-model', {
lora.modelId, method: 'POST',
lora.id, headers: { 'Content-Type': 'application/json' },
loraRoot, body: JSON.stringify({
targetPath.replace(loraRoot + '/', ''), model_id: lora.modelId,
batchDownloadId model_version_id: lora.id,
); model_root: loraRoot,
relative_path: targetPath.replace(loraRoot + '/', ''),
download_id: batchDownloadId
})
});
if (!response.success) { if (!response.ok) {
console.error(`Failed to download LoRA ${lora.name}: ${response.error}`); const errorText = await response.text();
console.error(`Failed to download LoRA ${lora.name}: ${errorText}`);
// Check if this is an early access error (status 401 is the key indicator)
if (response.status === 401) {
accessFailures++;
this.importManager.loadingManager.setStatus(
`Failed to download ${lora.name}: Access restricted`
);
}
failedDownloads++; failedDownloads++;
// Continue with next download // Continue with next download

View File

@@ -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_lora_root; const defaultRoot = getStorageItem('settings', {}).default_loras_root;
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
loraRoot.value = defaultRoot; loraRoot.value = defaultRoot;
} }

View File

@@ -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('importUrlError'); const errorElement = document.getElementById('urlError');
const input = urlInput.value.trim(); const input = urlInput.value.trim();
// Validate input // Validate input

View File

@@ -37,7 +37,6 @@ export const state = {
filename: true, filename: true,
modelname: true, modelname: true,
tags: false, tags: false,
creator: false,
recursive: false recursive: false
}, },
filters: { filters: {
@@ -84,17 +83,12 @@ 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,
}, },
@@ -112,16 +106,12 @@ 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,
} }
@@ -164,43 +154,12 @@ 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() { get bulkMode() { return this.pages.loras.bulkMode; },
const currentType = this.currentPageType; set bulkMode(value) { this.pages.loras.bulkMode = value; },
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; },

View File

@@ -150,12 +150,6 @@ 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'
} }
]; ];
@@ -201,9 +195,7 @@ 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 + const unusedModels = this.data.collection.unused_loras + this.data.collection.unused_checkpoints;
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;
@@ -241,17 +233,12 @@ class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'], labels: ['LoRAs', 'Checkpoints'],
datasets: [{ datasets: [{
data: [ data: [this.data.collection.lora_count, this.data.collection.checkpoint_count],
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')
@@ -279,13 +266,8 @@ 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([ const allModels = new Set([...Object.keys(loraData), ...Object.keys(checkpointData)]);
...Object.keys(loraData),
...Object.keys(checkpointData),
...Object.keys(embeddingData)
]);
const data = { const data = {
labels: Array.from(allModels), labels: Array.from(allModels),
@@ -299,11 +281,6 @@ 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)'
} }
] ]
}; };
@@ -348,13 +325,6 @@ 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
} }
] ]
}; };
@@ -395,13 +365,11 @@ 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 = {
@@ -409,14 +377,9 @@ 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 =>
switch(model.type) { model.type === 'LoRA' ? 'oklch(68% 0.28 256)' : 'oklch(68% 0.28 200)'
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)';
}
})
}] }]
}; };
@@ -441,17 +404,12 @@ class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints', 'Embeddings'], labels: ['LoRAs', 'Checkpoints'],
datasets: [{ datasets: [{
data: [ data: [this.data.collection.lora_size, this.data.collection.checkpoint_size],
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)'
] ]
}] }]
}; };
@@ -485,12 +443,10 @@ 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 = {
@@ -502,14 +458,9 @@ class StatisticsManager {
name: item.name, name: item.name,
type: item.type type: item.type
})), })),
backgroundColor: allData.map(item => { backgroundColor: allData.map(item =>
switch(item.type) { item.type === 'LoRA' ? 'oklch(68% 0.28 256 / 0.6)' : 'oklch(68% 0.28 200 / 0.6)'
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)';
}
})
}] }]
}; };
@@ -551,7 +502,6 @@ class StatisticsManager {
renderTopModelsLists() { renderTopModelsLists() {
this.renderTopLorasList(); this.renderTopLorasList();
this.renderTopCheckpointsList(); this.renderTopCheckpointsList();
this.renderTopEmbeddingsList();
this.renderLargestModelsList(); this.renderLargestModelsList();
} }
@@ -605,44 +555,17 @@ 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) {

View File

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

View File

@@ -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/modelApiFactory.js'; import { getModelApiClient } from '../api/baseModelApi.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

View File

@@ -1,5 +1,5 @@
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/baseModelApi.js';
const apiClient = getModelApiClient(); const apiClient = getModelApiClient();

View File

@@ -141,8 +141,7 @@ 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
@@ -214,44 +213,3 @@ export function getMapFromStorage(key) {
return new Map(); return new Map();
} }
} }
/**
* Get stored version info from localStorage
* @returns {string|null} The stored version string or null if not found
*/
export function getStoredVersionInfo() {
return getStorageItem('version_info', null);
}
/**
* Store version info to localStorage
* @param {string} versionInfo - The version info string to store
*/
export function setStoredVersionInfo(versionInfo) {
setStorageItem('version_info', versionInfo);
}
/**
* Check if version info matches between stored and current
* @param {string} currentVersionInfo - The current version info from server
* @returns {boolean} True if versions match or no stored version exists
*/
export function isVersionMatch(currentVersionInfo) {
const storedVersion = getStoredVersionInfo();
// If we have no stored version yet, consider it a match
if (storedVersion === null) {
setStoredVersionInfo(currentVersionInfo);
return true;
}
return storedVersion === currentVersionInfo;
}
/**
* Reset the dismissed status of a specific banner
* @param {string} bannerId - The ID of the banner to un-dismiss
*/
export function resetDismissedBanner(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
const updatedBanners = dismissedBanners.filter(id => id !== bannerId);
setStorageItem('dismissed_banners', updatedBanners);
}

View File

@@ -1,4 +1,4 @@
import { state, getCurrentPageState } from '../state/index.js'; import { 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,76 +285,6 @@ 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

View File

@@ -82,11 +82,6 @@
</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' %}

View File

@@ -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/list?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/checkpoints?page=1&page_size=1{% endblock %}
{% block additional_components %} {% block additional_components %}

View File

@@ -50,11 +50,14 @@
<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
@@ -116,22 +119,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 data-action="send-to-workflow" title="Send all selected LoRAs to workflow"> <button onclick="bulkManager.sendAllLorasToWorkflow()" 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 data-action="copy-all" title="Copy all selected LoRAs syntax"> <button onclick="bulkManager.copyAllLorasSyntax()" 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 data-action="refresh-all" title="Refresh CivitAI metadata for selected models"> <button onclick="bulkManager.refreshAllMetadata()" 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 data-action="move-all" title="Move selected models to folder"> <button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
<i class="fas fa-folder-open"></i> Move All <i class="fas fa-folder-open"></i> Move All
</button> </button>
<button data-action="delete-all" title="Delete selected models" class="danger-btn"> <button onclick="bulkManager.showBulkDeleteModal()" title="Delete selected LoRAs" class="danger-btn">
<i class="fas fa-trash"></i> Delete All <i class="fas fa-trash"></i> Delete All
</button> </button>
<button data-action="clear" title="Clear selection"> <button onclick="bulkManager.clearSelection()" title="Clear selection">
<i class="fas fa-times"></i> Clear <i class="fas fa-times"></i> Clear
</button> </button>
</div> </div>

View File

@@ -86,18 +86,15 @@
<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>

View File

@@ -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="importUrlError"></div> <div class="error-message" id="urlError"></div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -91,6 +91,98 @@
</div> </div>
</div> </div>
<!-- Add Folder Settings Section -->
<div class="settings-section">
<h3>Folder Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="defaultLoraRoot">Default LoRA Root</label>
</div>
<div class="setting-control select-control">
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')">
<option value="">No Default</option>
<!-- Options will be loaded dynamically -->
</select>
</div>
</div>
<div class="input-help">
Set the default LoRA root directory for downloads, imports and moves
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="defaultCheckpointRoot">Default Checkpoint Root</label>
</div>
<div class="setting-control select-control">
<select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')">
<option value="">No Default</option>
<!-- Options will be loaded dynamically -->
</select>
</div>
</div>
<div class="input-help">
Set the default checkpoint root directory for downloads, imports and moves
</div>
</div>
</div>
<!-- Default Path Customization Section -->
<div class="settings-section">
<h3>Default Path Customization</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadPathTemplate">Download Path Template</label>
</div>
<div class="setting-control select-control">
<select id="downloadPathTemplate" onchange="settingsManager.saveSelectSetting('downloadPathTemplate', 'download_path_template')">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
</select>
</div>
</div>
<div class="input-help">
Configure path structure for default download locations
<ul class="list-description">
<li><strong>Flat:</strong> All models in root folder</li>
<li><strong>Base Model:</strong> Organized by model type (e.g., Flux.1 D, SDXL)</li>
<li><strong>First Tag:</strong> Organized by primary tag (e.g., style, character)</li>
<li><strong>Base Model + Tag:</strong> Two-level organization for better structure</li>
</ul>
</div>
<div id="pathTemplatePreview" class="template-preview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Base Model Path Mappings</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div>
</div>
</div>
</div>
<!-- Add Layout Settings Section --> <!-- Add Layout Settings Section -->
<div class="settings-section"> <div class="settings-section">
<h3>Layout Settings</h3> <h3>Layout Settings</h3>
@@ -142,179 +234,6 @@
</div> </div>
</div> </div>
<!-- Add Folder Settings Section -->
<div class="settings-section">
<h3>Folder Settings</h3>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="defaultLoraRoot">Default LoRA Root</label>
</div>
<div class="setting-control select-control">
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')">
<option value="">No Default</option>
<!-- Options will be loaded dynamically -->
</select>
</div>
</div>
<div class="input-help">
Set the default LoRA root directory for downloads, imports and moves
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="defaultCheckpointRoot">Default Checkpoint Root</label>
</div>
<div class="setting-control select-control">
<select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')">
<option value="">No Default</option>
<!-- Options will be loaded dynamically -->
</select>
</div>
</div>
<div class="input-help">
Set the default checkpoint root directory for downloads, imports and moves
</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>
<!-- Default Path Customization Section -->
<div class="settings-section">
<h3>Download Path Templates</h3>
<div class="setting-item">
<div class="input-help">
Configure folder structures for different model types when downloading from Civitai.
<div class="placeholder-info">
<strong>Available placeholders:</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
</div>
</div>
</div>
<!-- LoRA Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraTemplatePreset">LoRA</label>
</div>
<div class="setting-control select-control">
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<!-- Checkpoint Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointTemplatePreset">Checkpoint</label>
</div>
<div class="setting-control select-control">
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<!-- Embedding Template Configuration -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">Embedding</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
<option value="">Flat Structure</option>
<option value="{base_model}">By Base Model</option>
<option value="{author}">By Author</option>
<option value="{first_tag}">By First Tag</option>
<option value="{base_model}/{first_tag}">Base Model + First Tag</option>
<option value="{base_model}/{author}">Base Model + Author</option>
<option value="{author}/{first_tag}">Author + First Tag</option>
<option value="custom">Custom Template</option>
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="Enter custom template (e.g., {base_model}/{author}/{first_tag})" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>Base Model Path Mappings</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>Add Mapping</span>
</button>
</div>
</div>
<div class="input-help">
Customize folder names for specific base models (e.g., "Flux.1 D" → "flux")
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
<!-- Mapping rows will be added dynamically -->
</div>
</div>
</div>
<!-- Add Example Images Settings Section --> <!-- Add Example Images Settings Section -->
<div class="settings-section"> <div class="settings-section">
<h3>Example Images</h3> <h3>Example Images</h3>
@@ -336,24 +255,6 @@
</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">
@@ -372,28 +273,6 @@
</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>

View File

@@ -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/list?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/embeddings?page=1&page_size=1{% endblock %}
{% block additional_components %} {% block additional_components %}

View File

@@ -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/list?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/loras?page=1&page_size=1{% endblock %}
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}

Some files were not shown because too many files have changed in this diff Show More