mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18cdaabf5e | ||
|
|
787e37b7c6 | ||
|
|
4e5c8b2dd0 | ||
|
|
d8ddacde38 | ||
|
|
bb1e42f0d3 | ||
|
|
923669c495 | ||
|
|
7a4139544c | ||
|
|
4d6ea0236b | ||
|
|
e872a06f22 | ||
|
|
647bda2160 | ||
|
|
c1e93d23f3 | ||
|
|
c96550cc68 | ||
|
|
b1015ecdc5 | ||
|
|
f1b928a037 | ||
|
|
16c312c90b | ||
|
|
110ffd0118 | ||
|
|
35ad872419 | ||
|
|
9b943cf2b8 | ||
|
|
9d1b357e64 | ||
|
|
9fc2fb4d17 | ||
|
|
641fa8a3d9 | ||
|
|
add9269706 | ||
|
|
1a01c4a344 | ||
|
|
b4e7feed06 | ||
|
|
4b96c650eb | ||
|
|
107aef3785 | ||
|
|
b49807824f | ||
|
|
e5ef2ef8b5 | ||
|
|
88779ed56c | ||
|
|
8b59fb6adc | ||
|
|
7945647b0b | ||
|
|
2d39b84806 | ||
|
|
e151a19fcf | ||
|
|
99d2ba26b9 | ||
|
|
396924f4cc | ||
|
|
7545312229 | ||
|
|
26f9779fbf | ||
|
|
0bd62eef3a | ||
|
|
e06d15f508 | ||
|
|
aa1ee96bc9 | ||
|
|
355c73512d |
@@ -22,6 +22,14 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
|||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
### v0.8.17
|
||||||
|
* **Duplicate Model Detection** - Added "Find Duplicates" functionality for LoRAs and checkpoints using model file hash detection, enabling convenient viewing and batch deletion of duplicate models
|
||||||
|
* **Enhanced URL Recipe Imports** - Optimized import recipe via URL functionality using CivitAI API calls instead of web scraping, now supporting all rated images (including NSFW) for recipe imports
|
||||||
|
* **Improved TriggerWord Control** - Enhanced TriggerWord Toggle node with new default_active switch to set the initial state (active/inactive) when trigger words are added
|
||||||
|
* **Centralized Example Management** - Added "Migrate Existing Example Images" feature to consolidate downloaded example images from model folders into central storage with customizable naming patterns
|
||||||
|
* **Intelligent Word Suggestions** - Implemented smart trigger word suggestions by reading class tokens and tag frequency from safetensors files, displaying recommendations when editing trigger words
|
||||||
|
* **Model Version Management** - Added "Re-link to CivitAI" context menu option for connecting models to different CivitAI versions when needed
|
||||||
|
|
||||||
### v0.8.16
|
### v0.8.16
|
||||||
* **Dramatic Startup Speed Improvement** - Added cache serialization mechanism for significantly faster loading times, especially beneficial for large model collections
|
* **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
|
* **Enhanced Refresh Options** - Extended functionality with "Full Rebuild (complete)" option alongside "Quick Refresh (incremental)" to fix potential memory cache issues without requiring application restart
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from .routes.recipe_routes import RecipeRoutes
|
|||||||
from .routes.checkpoints_routes import CheckpointsRoutes
|
from .routes.checkpoints_routes import CheckpointsRoutes
|
||||||
from .routes.update_routes import UpdateRoutes
|
from .routes.update_routes import UpdateRoutes
|
||||||
from .routes.misc_routes import MiscRoutes
|
from .routes.misc_routes import MiscRoutes
|
||||||
|
from .routes.example_images_routes import ExampleImagesRoutes
|
||||||
from .services.service_registry import ServiceRegistry
|
from .services.service_registry import ServiceRegistry
|
||||||
from .services.settings_manager import settings
|
from .services.settings_manager import settings
|
||||||
import logging
|
import logging
|
||||||
@@ -112,6 +113,7 @@ class LoraManager:
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app) # Register miscellaneous routes
|
MiscRoutes.setup_routes(app) # Register miscellaneous routes
|
||||||
|
ExampleImagesRoutes.setup_routes(app) # Register example images routes
|
||||||
|
|
||||||
# Schedule service initialization
|
# Schedule service initialization
|
||||||
app.on_startup.append(lambda app: cls._initialize_services())
|
app.on_startup.append(lambda app: cls._initialize_services())
|
||||||
|
|||||||
@@ -31,14 +31,33 @@ class SaveImage:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"images": ("IMAGE",),
|
"images": ("IMAGE",),
|
||||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
"filename_prefix": ("STRING", {
|
||||||
"file_format": (["png", "jpeg", "webp"],),
|
"default": "ComfyUI",
|
||||||
|
"tooltip": "Base filename for saved images. Supports format patterns like %seed%, %width%, %height%, %model%, etc."
|
||||||
|
}),
|
||||||
|
"file_format": (["png", "jpeg", "webp"], {
|
||||||
|
"tooltip": "Image format to save as. PNG preserves quality, JPEG is smaller, WebP balances size and quality."
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
"lossless_webp": ("BOOLEAN", {"default": False}),
|
"lossless_webp": ("BOOLEAN", {
|
||||||
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
|
"default": False,
|
||||||
"embed_workflow": ("BOOLEAN", {"default": False}),
|
"tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss."
|
||||||
"add_counter_to_filename": ("BOOLEAN", {"default": True}),
|
}),
|
||||||
|
"quality": ("INT", {
|
||||||
|
"default": 100,
|
||||||
|
"min": 1,
|
||||||
|
"max": 100,
|
||||||
|
"tooltip": "Compression quality for JPEG and lossy WebP formats (1-100). Higher values mean better quality but larger files."
|
||||||
|
}),
|
||||||
|
"embed_workflow": ("BOOLEAN", {
|
||||||
|
"default": False,
|
||||||
|
"tooltip": "Embeds the complete workflow data into the image metadata. Only works with PNG and WebP formats."
|
||||||
|
}),
|
||||||
|
"add_counter_to_filename": ("BOOLEAN", {
|
||||||
|
"default": True,
|
||||||
|
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images."
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"id": "UNIQUE_ID",
|
"id": "UNIQUE_ID",
|
||||||
|
|||||||
@@ -16,11 +16,18 @@ class TriggerWordToggle:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"group_mode": ("BOOLEAN", {"default": True}),
|
"group_mode": ("BOOLEAN", {
|
||||||
|
"default": True,
|
||||||
|
"tooltip": "When enabled, treats each group of trigger words as a single toggleable unit."
|
||||||
|
}),
|
||||||
|
"default_active": ("BOOLEAN", {
|
||||||
|
"default": True,
|
||||||
|
"tooltip": "Sets the default initial state (active or inactive) when trigger words are added."
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
"hidden": {
|
"hidden": {
|
||||||
"id": "UNIQUE_ID", # 会被 ComfyUI 自动替换为唯一ID
|
"id": "UNIQUE_ID",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ class TriggerWordToggle:
|
|||||||
else:
|
else:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def process_trigger_words(self, id, group_mode, **kwargs):
|
def process_trigger_words(self, id, group_mode, default_active, **kwargs):
|
||||||
# Handle both old and new formats for trigger_words
|
# Handle both old and new formats for trigger_words
|
||||||
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
|
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
|
||||||
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
|
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from .parsers import (
|
|||||||
RecipeFormatParser,
|
RecipeFormatParser,
|
||||||
ComfyMetadataParser,
|
ComfyMetadataParser,
|
||||||
MetaFormatParser,
|
MetaFormatParser,
|
||||||
AutomaticMetadataParser
|
AutomaticMetadataParser,
|
||||||
|
CivitaiApiMetadataParser
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -18,5 +19,6 @@ __all__ = [
|
|||||||
'RecipeFormatParser',
|
'RecipeFormatParser',
|
||||||
'ComfyMetadataParser',
|
'ComfyMetadataParser',
|
||||||
'MetaFormatParser',
|
'MetaFormatParser',
|
||||||
'AutomaticMetadataParser'
|
'AutomaticMetadataParser',
|
||||||
|
'CivitaiApiMetadataParser'
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from .parsers import (
|
|||||||
RecipeFormatParser,
|
RecipeFormatParser,
|
||||||
ComfyMetadataParser,
|
ComfyMetadataParser,
|
||||||
MetaFormatParser,
|
MetaFormatParser,
|
||||||
AutomaticMetadataParser
|
AutomaticMetadataParser,
|
||||||
|
CivitaiApiMetadataParser
|
||||||
)
|
)
|
||||||
from .base import RecipeMetadataParser
|
from .base import RecipeMetadataParser
|
||||||
|
|
||||||
@@ -15,29 +16,49 @@ class RecipeParserFactory:
|
|||||||
"""Factory for creating recipe metadata parsers"""
|
"""Factory for creating recipe metadata parsers"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_parser(user_comment: str) -> RecipeMetadataParser:
|
def create_parser(metadata) -> RecipeMetadataParser:
|
||||||
"""
|
"""
|
||||||
Create appropriate parser based on the user comment content
|
Create appropriate parser based on the metadata content
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_comment: The EXIF UserComment string from the image
|
metadata: The metadata from the image (dict or str)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Appropriate RecipeMetadataParser implementation
|
Appropriate RecipeMetadataParser implementation
|
||||||
"""
|
"""
|
||||||
# Try ComfyMetadataParser first since it requires valid JSON
|
# First, try CivitaiApiMetadataParser for dict input
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
try:
|
||||||
|
if CivitaiApiMetadataParser().is_metadata_matching(metadata):
|
||||||
|
return CivitaiApiMetadataParser()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"CivitaiApiMetadataParser check failed: {e}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Convert dict to string for other parsers that expect string input
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
metadata_str = json.dumps(metadata)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to convert dict to JSON string: {e}")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
metadata_str = metadata
|
||||||
|
|
||||||
|
# Try ComfyMetadataParser which requires valid JSON
|
||||||
try:
|
try:
|
||||||
if ComfyMetadataParser().is_metadata_matching(user_comment):
|
if ComfyMetadataParser().is_metadata_matching(metadata_str):
|
||||||
return ComfyMetadataParser()
|
return ComfyMetadataParser()
|
||||||
except Exception:
|
except Exception:
|
||||||
# If JSON parsing fails, move on to other parsers
|
# If JSON parsing fails, move on to other parsers
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if RecipeFormatParser().is_metadata_matching(user_comment):
|
# Check other parsers that expect string input
|
||||||
|
if RecipeFormatParser().is_metadata_matching(metadata_str):
|
||||||
return RecipeFormatParser()
|
return RecipeFormatParser()
|
||||||
elif AutomaticMetadataParser().is_metadata_matching(user_comment):
|
elif AutomaticMetadataParser().is_metadata_matching(metadata_str):
|
||||||
return AutomaticMetadataParser()
|
return AutomaticMetadataParser()
|
||||||
elif MetaFormatParser().is_metadata_matching(user_comment):
|
elif MetaFormatParser().is_metadata_matching(metadata_str):
|
||||||
return MetaFormatParser()
|
return MetaFormatParser()
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ from .recipe_format import RecipeFormatParser
|
|||||||
from .comfy import ComfyMetadataParser
|
from .comfy import ComfyMetadataParser
|
||||||
from .meta_format import MetaFormatParser
|
from .meta_format import MetaFormatParser
|
||||||
from .automatic import AutomaticMetadataParser
|
from .automatic import AutomaticMetadataParser
|
||||||
|
from .civitai_image import CivitaiApiMetadataParser
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'RecipeFormatParser',
|
'RecipeFormatParser',
|
||||||
'ComfyMetadataParser',
|
'ComfyMetadataParser',
|
||||||
'MetaFormatParser',
|
'MetaFormatParser',
|
||||||
'AutomaticMetadataParser',
|
'AutomaticMetadataParser',
|
||||||
|
'CivitaiApiMetadataParser',
|
||||||
]
|
]
|
||||||
|
|||||||
248
py/recipes/parsers/civitai_image.py
Normal file
248
py/recipes/parsers/civitai_image.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""Parser for Civitai image metadata format."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Union
|
||||||
|
from ..base import RecipeMetadataParser
|
||||||
|
from ..constants import GEN_PARAM_KEYS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||||
|
"""Parser for Civitai image metadata format"""
|
||||||
|
|
||||||
|
def is_metadata_matching(self, metadata) -> bool:
|
||||||
|
"""Check if the metadata matches the Civitai image metadata format
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: The metadata from the image (dict)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if this parser can handle the metadata
|
||||||
|
"""
|
||||||
|
if not metadata or not isinstance(metadata, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for key markers specific to Civitai image metadata
|
||||||
|
return any([
|
||||||
|
"resources" in metadata,
|
||||||
|
"civitaiResources" in metadata,
|
||||||
|
"additionalResources" in metadata
|
||||||
|
])
|
||||||
|
|
||||||
|
async def parse_metadata(self, metadata, recipe_scanner=None, civitai_client=None) -> Dict[str, Any]:
|
||||||
|
"""Parse metadata from Civitai image format
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata: The metadata from the image (dict)
|
||||||
|
recipe_scanner: Optional recipe scanner service
|
||||||
|
civitai_client: Optional Civitai API client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing parsed recipe data
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Initialize result structure
|
||||||
|
result = {
|
||||||
|
'base_model': None,
|
||||||
|
'loras': [],
|
||||||
|
'gen_params': {},
|
||||||
|
'from_civitai_image': True
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract prompt and negative prompt
|
||||||
|
if "prompt" in metadata:
|
||||||
|
result["gen_params"]["prompt"] = metadata["prompt"]
|
||||||
|
|
||||||
|
if "negativePrompt" in metadata:
|
||||||
|
result["gen_params"]["negative_prompt"] = metadata["negativePrompt"]
|
||||||
|
|
||||||
|
# Extract other generation parameters
|
||||||
|
param_mapping = {
|
||||||
|
"steps": "steps",
|
||||||
|
"sampler": "sampler",
|
||||||
|
"cfgScale": "cfg_scale",
|
||||||
|
"seed": "seed",
|
||||||
|
"Size": "size",
|
||||||
|
"clipSkip": "clip_skip",
|
||||||
|
}
|
||||||
|
|
||||||
|
for civitai_key, our_key in param_mapping.items():
|
||||||
|
if civitai_key in metadata and our_key in GEN_PARAM_KEYS:
|
||||||
|
result["gen_params"][our_key] = metadata[civitai_key]
|
||||||
|
|
||||||
|
# Extract base model information - directly if available
|
||||||
|
if "baseModel" in metadata:
|
||||||
|
result["base_model"] = metadata["baseModel"]
|
||||||
|
elif "Model hash" in metadata and civitai_client:
|
||||||
|
model_hash = metadata["Model hash"]
|
||||||
|
model_info = await civitai_client.get_model_by_hash(model_hash)
|
||||||
|
if model_info:
|
||||||
|
result["base_model"] = model_info.get("baseModel", "")
|
||||||
|
elif "Model" in metadata and isinstance(metadata.get("resources"), list):
|
||||||
|
# Try to find base model in resources
|
||||||
|
for resource in metadata.get("resources", []):
|
||||||
|
if resource.get("type") == "model" and resource.get("name") == metadata.get("Model"):
|
||||||
|
# This is likely the checkpoint model
|
||||||
|
if civitai_client and resource.get("hash"):
|
||||||
|
model_info = await civitai_client.get_model_by_hash(resource.get("hash"))
|
||||||
|
if model_info:
|
||||||
|
result["base_model"] = model_info.get("baseModel", "")
|
||||||
|
|
||||||
|
base_model_counts = {}
|
||||||
|
|
||||||
|
# Process standard resources array
|
||||||
|
if "resources" in metadata and isinstance(metadata["resources"], list):
|
||||||
|
for resource in metadata["resources"]:
|
||||||
|
# Modified to process resources without a type field as potential LoRAs
|
||||||
|
if resource.get("type", "lora") == "lora":
|
||||||
|
lora_entry = {
|
||||||
|
'name': resource.get("name", "Unknown LoRA"),
|
||||||
|
'type': "lora",
|
||||||
|
'weight': float(resource.get("weight", 1.0)),
|
||||||
|
'hash': resource.get("hash", ""),
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': resource.get("name", "Unknown"),
|
||||||
|
'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:
|
||||||
|
lora_hash = lora_entry['hash']
|
||||||
|
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:
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}")
|
||||||
|
|
||||||
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
|
# Process civitaiResources array
|
||||||
|
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
||||||
|
for resource in metadata["civitaiResources"]:
|
||||||
|
# Modified to process resources without a type field as potential LoRAs
|
||||||
|
if resource.get("type") in ["lora", "lycoris"] or "type" not in resource:
|
||||||
|
# Initialize lora entry with the same structure as in automatic.py
|
||||||
|
lora_entry = {
|
||||||
|
'id': str(resource.get("modelVersionId")),
|
||||||
|
'modelId': str(resource.get("modelId")) if resource.get("modelId") else None,
|
||||||
|
'name': resource.get("modelName", "Unknown LoRA"),
|
||||||
|
'version': resource.get("modelVersionName", ""),
|
||||||
|
'type': resource.get("type", "lora"),
|
||||||
|
'weight': round(float(resource.get("weight", 1.0)), 2),
|
||||||
|
'existsLocally': False,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try to get info from Civitai if modelVersionId is available
|
||||||
|
if resource.get('modelVersionId') and civitai_client:
|
||||||
|
try:
|
||||||
|
version_id = str(resource.get('modelVersionId'))
|
||||||
|
# Use get_model_version_info instead of get_model_version
|
||||||
|
civitai_info, error = await civitai_client.get_model_version_info(version_id)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
logger.warning(f"Error getting model version info: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for model version {resource.get('modelVersionId')}: {e}")
|
||||||
|
|
||||||
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
|
# Process additionalResources array
|
||||||
|
if "additionalResources" in metadata and isinstance(metadata["additionalResources"], list):
|
||||||
|
for resource in metadata["additionalResources"]:
|
||||||
|
# Modified to process resources without a type field as potential LoRAs
|
||||||
|
if resource.get("type") in ["lora", "lycoris"] or "type" not in resource:
|
||||||
|
lora_type = resource.get("type", "lora")
|
||||||
|
name = resource.get("name", "")
|
||||||
|
|
||||||
|
# Extract ID from URN format if available
|
||||||
|
model_id = None
|
||||||
|
if name and "civitai:" in name:
|
||||||
|
parts = name.split("@")
|
||||||
|
if len(parts) > 1:
|
||||||
|
model_id = parts[1]
|
||||||
|
|
||||||
|
lora_entry = {
|
||||||
|
'name': name,
|
||||||
|
'type': lora_type,
|
||||||
|
'weight': float(resource.get("strength", 1.0)),
|
||||||
|
'hash': "",
|
||||||
|
'existsLocally': False,
|
||||||
|
'localPath': None,
|
||||||
|
'file_name': name,
|
||||||
|
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||||
|
'baseModel': '',
|
||||||
|
'size': 0,
|
||||||
|
'downloadUrl': '',
|
||||||
|
'isDeleted': False
|
||||||
|
}
|
||||||
|
|
||||||
|
# If we have a model ID and civitai client, try to get more info
|
||||||
|
if model_id and civitai_client:
|
||||||
|
try:
|
||||||
|
# Use get_model_version_info with the model ID
|
||||||
|
civitai_info, error = await civitai_client.get_model_version_info(model_id)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
logger.warning(f"Error getting model version info: {error}")
|
||||||
|
else:
|
||||||
|
populated_entry = await self.populate_lora_from_civitai(
|
||||||
|
lora_entry,
|
||||||
|
civitai_info,
|
||||||
|
recipe_scanner,
|
||||||
|
base_model_counts
|
||||||
|
)
|
||||||
|
|
||||||
|
if populated_entry is None:
|
||||||
|
continue # Skip invalid LoRA types
|
||||||
|
|
||||||
|
lora_entry = populated_entry
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Civitai info for model ID {model_id}: {e}")
|
||||||
|
|
||||||
|
result["loras"].append(lora_entry)
|
||||||
|
|
||||||
|
# If base model wasn't found earlier, use the most common one from LoRAs
|
||||||
|
if not result["base_model"] and base_model_counts:
|
||||||
|
result["base_model"] = max(base_model_counts.items(), key=lambda x: x[1])[0]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing Civitai image metadata: {e}", exc_info=True)
|
||||||
|
return {"error": str(e), "loras": []}
|
||||||
@@ -45,6 +45,7 @@ class ApiRoutes:
|
|||||||
app.router.add_post('/api/delete_model', routes.delete_model)
|
app.router.add_post('/api/delete_model', routes.delete_model)
|
||||||
app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint
|
app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint
|
||||||
app.router.add_post('/api/fetch-civitai', routes.fetch_civitai)
|
app.router.add_post('/api/fetch-civitai', routes.fetch_civitai)
|
||||||
|
app.router.add_post('/api/relink-civitai', routes.relink_civitai) # Add new relink endpoint
|
||||||
app.router.add_post('/api/replace_preview', routes.replace_preview)
|
app.router.add_post('/api/replace_preview', routes.replace_preview)
|
||||||
app.router.add_get('/api/loras', routes.get_loras)
|
app.router.add_get('/api/loras', routes.get_loras)
|
||||||
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
|
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
|
||||||
@@ -80,6 +81,13 @@ class ApiRoutes:
|
|||||||
# Add update check routes
|
# Add update check routes
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
|
|
||||||
|
# Add new endpoints for finding duplicates
|
||||||
|
app.router.add_get('/api/loras/find-duplicates', routes.find_duplicate_loras)
|
||||||
|
app.router.add_get('/api/loras/find-filename-conflicts', routes.find_filename_conflicts)
|
||||||
|
|
||||||
|
# Add new endpoint for bulk deleting loras
|
||||||
|
app.router.add_post('/api/loras/bulk-delete', routes.bulk_delete_loras)
|
||||||
|
|
||||||
async def delete_model(self, request: web.Request) -> web.Response:
|
async def delete_model(self, request: web.Request) -> web.Response:
|
||||||
"""Handle model deletion request"""
|
"""Handle model deletion request"""
|
||||||
if self.scanner is None:
|
if self.scanner is None:
|
||||||
@@ -1169,3 +1177,118 @@ class ApiRoutes:
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
async def find_duplicate_loras(self, request: web.Request) -> web.Response:
|
||||||
|
"""Find loras with duplicate SHA256 hashes"""
|
||||||
|
try:
|
||||||
|
if self.scanner is None:
|
||||||
|
self.scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
|
||||||
|
# Get duplicate hashes from hash index
|
||||||
|
duplicates = self.scanner._hash_index.get_duplicate_hashes()
|
||||||
|
|
||||||
|
# Format the response
|
||||||
|
result = []
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for sha256, paths in duplicates.items():
|
||||||
|
group = {
|
||||||
|
"hash": sha256,
|
||||||
|
"models": []
|
||||||
|
}
|
||||||
|
# Find matching models for each duplicate path
|
||||||
|
for path in paths:
|
||||||
|
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
|
||||||
|
if model:
|
||||||
|
group["models"].append(self._format_lora_response(model))
|
||||||
|
|
||||||
|
# Add the primary model too
|
||||||
|
primary_path = self.scanner._hash_index.get_path(sha256)
|
||||||
|
if primary_path and primary_path not in paths:
|
||||||
|
primary_model = next((m for m in cache.raw_data if m['file_path'] == primary_path), None)
|
||||||
|
if primary_model:
|
||||||
|
group["models"].insert(0, self._format_lora_response(primary_model))
|
||||||
|
|
||||||
|
if group["models"]: # Only include if we found models
|
||||||
|
result.append(group)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"duplicates": result,
|
||||||
|
"count": len(result)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding duplicate loras: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
|
"""Find loras with conflicting filenames"""
|
||||||
|
try:
|
||||||
|
if self.scanner is None:
|
||||||
|
self.scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
|
||||||
|
# Get duplicate filenames from hash index
|
||||||
|
duplicates = self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
# Format the response
|
||||||
|
result = []
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
group = {
|
||||||
|
"filename": filename,
|
||||||
|
"models": []
|
||||||
|
}
|
||||||
|
# Find matching models for each path
|
||||||
|
for path in paths:
|
||||||
|
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
|
||||||
|
if model:
|
||||||
|
group["models"].append(self._format_lora_response(model))
|
||||||
|
|
||||||
|
# Find the model from the main index too
|
||||||
|
hash_val = self.scanner._hash_index.get_hash_by_filename(filename)
|
||||||
|
if hash_val:
|
||||||
|
main_path = self.scanner._hash_index.get_path(hash_val)
|
||||||
|
if main_path and main_path not in paths:
|
||||||
|
main_model = next((m for m in cache.raw_data if m['file_path'] == main_path), None)
|
||||||
|
if main_model:
|
||||||
|
group["models"].insert(0, self._format_lora_response(main_model))
|
||||||
|
|
||||||
|
if group["models"]: # Only include if we found models
|
||||||
|
result.append(group)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"conflicts": result,
|
||||||
|
"count": len(result)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding filename conflicts: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def bulk_delete_loras(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle bulk deletion of lora models"""
|
||||||
|
try:
|
||||||
|
if self.scanner is None:
|
||||||
|
self.scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
|
||||||
|
return await ModelRouteUtils.handle_bulk_delete_models(request, self.scanner)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in bulk delete loras: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def relink_civitai(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle CivitAI metadata re-linking request by model version ID for LoRAs"""
|
||||||
|
if self.scanner is None:
|
||||||
|
self.scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class CheckpointsRoutes:
|
|||||||
app.router.add_post('/api/checkpoints/delete', self.delete_model)
|
app.router.add_post('/api/checkpoints/delete', self.delete_model)
|
||||||
app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint
|
app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint
|
||||||
app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai)
|
app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai)
|
||||||
|
app.router.add_post('/api/checkpoints/relink-civitai', self.relink_civitai) # Add new relink endpoint
|
||||||
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
|
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
|
||||||
app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
|
app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
|
||||||
app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route
|
app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route
|
||||||
@@ -58,6 +59,13 @@ class CheckpointsRoutes:
|
|||||||
# Add new WebSocket endpoint for checkpoint progress
|
# Add new WebSocket endpoint for checkpoint progress
|
||||||
app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection)
|
app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection)
|
||||||
|
|
||||||
|
# Add new routes for finding duplicates and filename conflicts
|
||||||
|
app.router.add_get('/api/checkpoints/find-duplicates', self.find_duplicate_checkpoints)
|
||||||
|
app.router.add_get('/api/checkpoints/find-filename-conflicts', self.find_filename_conflicts)
|
||||||
|
|
||||||
|
# Add new endpoint for bulk deleting checkpoints
|
||||||
|
app.router.add_post('/api/checkpoints/bulk-delete', self.bulk_delete_checkpoints)
|
||||||
|
|
||||||
async def get_checkpoints(self, request):
|
async def get_checkpoints(self, request):
|
||||||
"""Get paginated checkpoint data"""
|
"""Get paginated checkpoint data"""
|
||||||
try:
|
try:
|
||||||
@@ -695,3 +703,116 @@ class CheckpointsRoutes:
|
|||||||
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 find_duplicate_checkpoints(self, request: web.Request) -> web.Response:
|
||||||
|
"""Find checkpoints with duplicate SHA256 hashes"""
|
||||||
|
try:
|
||||||
|
if self.scanner is None:
|
||||||
|
self.scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
|
||||||
|
# Get duplicate hashes from hash index
|
||||||
|
duplicates = self.scanner._hash_index.get_duplicate_hashes()
|
||||||
|
|
||||||
|
# Format the response
|
||||||
|
result = []
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for sha256, paths in duplicates.items():
|
||||||
|
group = {
|
||||||
|
"hash": sha256,
|
||||||
|
"models": []
|
||||||
|
}
|
||||||
|
# Find matching models for each path
|
||||||
|
for path in paths:
|
||||||
|
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
|
||||||
|
if model:
|
||||||
|
group["models"].append(self._format_checkpoint_response(model))
|
||||||
|
|
||||||
|
# Add the primary model too
|
||||||
|
primary_path = self.scanner._hash_index.get_path(sha256)
|
||||||
|
if primary_path and primary_path not in paths:
|
||||||
|
primary_model = next((m for m in cache.raw_data if m['file_path'] == primary_path), None)
|
||||||
|
if primary_model:
|
||||||
|
group["models"].insert(0, self._format_checkpoint_response(primary_model))
|
||||||
|
|
||||||
|
if group["models"]:
|
||||||
|
result.append(group)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"duplicates": result,
|
||||||
|
"count": len(result)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding duplicate checkpoints: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def find_filename_conflicts(self, request: web.Request) -> web.Response:
|
||||||
|
"""Find checkpoints with conflicting filenames"""
|
||||||
|
try:
|
||||||
|
if self.scanner is None:
|
||||||
|
self.scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
|
||||||
|
# Get duplicate filenames from hash index
|
||||||
|
duplicates = self.scanner._hash_index.get_duplicate_filenames()
|
||||||
|
|
||||||
|
# Format the response
|
||||||
|
result = []
|
||||||
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
|
for filename, paths in duplicates.items():
|
||||||
|
group = {
|
||||||
|
"filename": filename,
|
||||||
|
"models": []
|
||||||
|
}
|
||||||
|
# Find matching models for each path
|
||||||
|
for path in paths:
|
||||||
|
model = next((m for m in cache.raw_data if m['file_path'] == path), None)
|
||||||
|
if model:
|
||||||
|
group["models"].append(self._format_checkpoint_response(model))
|
||||||
|
|
||||||
|
# Find the model from the main index too
|
||||||
|
hash_val = self.scanner._hash_index.get_hash_by_filename(filename)
|
||||||
|
if hash_val:
|
||||||
|
main_path = self.scanner._hash_index.get_path(hash_val)
|
||||||
|
if main_path and main_path not in paths:
|
||||||
|
main_model = next((m for m in cache.raw_data if m['file_path'] == main_path), None)
|
||||||
|
if main_model:
|
||||||
|
group["models"].insert(0, self._format_checkpoint_response(main_model))
|
||||||
|
|
||||||
|
if group["models"]:
|
||||||
|
result.append(group)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"conflicts": result,
|
||||||
|
"count": len(result)
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error finding filename conflicts: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def bulk_delete_checkpoints(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle bulk deletion of checkpoint models"""
|
||||||
|
try:
|
||||||
|
if self.scanner is None:
|
||||||
|
self.scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
|
|
||||||
|
return await ModelRouteUtils.handle_bulk_delete_models(request, self.scanner)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in bulk delete checkpoints: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
async def relink_civitai(self, request: web.Request) -> web.Response:
|
||||||
|
"""Handle CivitAI metadata re-linking request by model version ID for checkpoints"""
|
||||||
|
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)
|
||||||
|
|||||||
1428
py/routes/example_images_routes.py
Normal file
1428
py/routes/example_images_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,13 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import aiohttp
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from server import PromptServer # type: ignore
|
from server import PromptServer # type: ignore
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from ..services.settings_manager import settings
|
from ..services.settings_manager import settings
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..utils.lora_metadata import extract_trained_words
|
||||||
|
from ..config import config
|
||||||
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
|
||||||
from ..services.civitai_client import CivitaiClient
|
import re
|
||||||
from ..utils.routes_common import ModelRouteUtils
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -49,17 +42,14 @@ class MiscRoutes:
|
|||||||
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
|
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
|
||||||
app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats)
|
app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats)
|
||||||
|
|
||||||
# Example images download routes
|
|
||||||
app.router.add_post('/api/download-example-images', MiscRoutes.download_example_images)
|
|
||||||
app.router.add_get('/api/example-images-status', MiscRoutes.get_example_images_status)
|
|
||||||
app.router.add_post('/api/pause-example-images', MiscRoutes.pause_example_images)
|
|
||||||
app.router.add_post('/api/resume-example-images', MiscRoutes.resume_example_images)
|
|
||||||
|
|
||||||
# Lora code update endpoint
|
# Lora code update endpoint
|
||||||
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
|
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
|
||||||
|
|
||||||
# Add new route for opening example images folder
|
# Add new route for getting trained words
|
||||||
app.router.add_post('/api/open-example-images-folder', MiscRoutes.open_example_images_folder)
|
app.router.add_get('/api/trained-words', MiscRoutes.get_trained_words)
|
||||||
|
|
||||||
|
# Add new route for getting model example files
|
||||||
|
app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def clear_cache(request):
|
async def clear_cache(request):
|
||||||
@@ -182,10 +172,14 @@ class MiscRoutes:
|
|||||||
usage_stats = UsageStats()
|
usage_stats = UsageStats()
|
||||||
stats = await usage_stats.get_stats()
|
stats = await usage_stats.get_stats()
|
||||||
|
|
||||||
return web.json_response({
|
# Add version information to help clients handle format changes
|
||||||
|
stats_response = {
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': stats
|
'data': stats,
|
||||||
})
|
'format_version': 2 # Indicate this is the new format with history
|
||||||
|
}
|
||||||
|
|
||||||
|
return web.json_response(stats_response)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get usage stats: {e}", exc_info=True)
|
logger.error(f"Failed to get usage stats: {e}", exc_info=True)
|
||||||
@@ -194,623 +188,6 @@ class MiscRoutes:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def download_example_images(request):
|
|
||||||
"""
|
|
||||||
Download example images for models from Civitai
|
|
||||||
|
|
||||||
Expects a JSON body with:
|
|
||||||
{
|
|
||||||
"output_dir": "path/to/output", # Base directory to save example images
|
|
||||||
"optimize": true, # Whether to optimize images (default: true)
|
|
||||||
"model_types": ["lora", "checkpoint"], # Model types to process (default: both)
|
|
||||||
"delay": 1.0 # Delay between downloads to avoid rate limiting (default: 1.0)
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
global download_task, is_downloading, download_progress
|
|
||||||
|
|
||||||
if is_downloading:
|
|
||||||
# Create a copy for JSON serialization
|
|
||||||
response_progress = download_progress.copy()
|
|
||||||
response_progress['processed_models'] = list(download_progress['processed_models'])
|
|
||||||
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Download already in progress',
|
|
||||||
'status': response_progress
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Parse the request body
|
|
||||||
data = await request.json()
|
|
||||||
output_dir = data.get('output_dir')
|
|
||||||
optimize = data.get('optimize', True)
|
|
||||||
model_types = data.get('model_types', ['lora', 'checkpoint'])
|
|
||||||
delay = float(data.get('delay', 0.1)) # Default to 0.1 seconds
|
|
||||||
|
|
||||||
if not output_dir:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'Missing output_dir parameter'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Create the output directory
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Initialize progress tracking
|
|
||||||
download_progress['total'] = 0
|
|
||||||
download_progress['completed'] = 0
|
|
||||||
download_progress['current_model'] = ''
|
|
||||||
download_progress['status'] = 'running'
|
|
||||||
download_progress['errors'] = []
|
|
||||||
download_progress['last_error'] = None
|
|
||||||
download_progress['start_time'] = time.time()
|
|
||||||
download_progress['end_time'] = None
|
|
||||||
|
|
||||||
# Get the processed models list from a file if it exists
|
|
||||||
progress_file = os.path.join(output_dir, '.download_progress.json')
|
|
||||||
if os.path.exists(progress_file):
|
|
||||||
try:
|
|
||||||
with open(progress_file, 'r', encoding='utf-8') as f:
|
|
||||||
saved_progress = json.load(f)
|
|
||||||
download_progress['processed_models'] = set(saved_progress.get('processed_models', []))
|
|
||||||
logger.info(f"Loaded previous progress, {len(download_progress['processed_models'])} models already processed")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to load progress file: {e}")
|
|
||||||
download_progress['processed_models'] = set()
|
|
||||||
else:
|
|
||||||
download_progress['processed_models'] = set()
|
|
||||||
|
|
||||||
# Start the download task
|
|
||||||
is_downloading = True
|
|
||||||
download_task = asyncio.create_task(
|
|
||||||
MiscRoutes._download_all_example_images(
|
|
||||||
output_dir,
|
|
||||||
optimize,
|
|
||||||
model_types,
|
|
||||||
delay
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a copy for JSON serialization
|
|
||||||
response_progress = download_progress.copy()
|
|
||||||
response_progress['processed_models'] = list(download_progress['processed_models'])
|
|
||||||
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Download started',
|
|
||||||
'status': response_progress
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to start example images download: {e}", exc_info=True)
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': str(e)
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def get_example_images_status(request):
|
|
||||||
"""Get the current status of example images download"""
|
|
||||||
global download_progress
|
|
||||||
|
|
||||||
# Create a copy of the progress dict with the set converted to a list for JSON serialization
|
|
||||||
response_progress = download_progress.copy()
|
|
||||||
response_progress['processed_models'] = list(download_progress['processed_models'])
|
|
||||||
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'is_downloading': is_downloading,
|
|
||||||
'status': response_progress
|
|
||||||
})
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def pause_example_images(request):
|
|
||||||
"""Pause the example images download"""
|
|
||||||
global download_progress
|
|
||||||
|
|
||||||
if not is_downloading:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'No download in progress'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
download_progress['status'] = 'paused'
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Download paused'
|
|
||||||
})
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def resume_example_images(request):
|
|
||||||
"""Resume the example images download"""
|
|
||||||
global download_progress
|
|
||||||
|
|
||||||
if not is_downloading:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'No download in progress'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
if download_progress['status'] == 'paused':
|
|
||||||
download_progress['status'] = 'running'
|
|
||||||
|
|
||||||
return web.json_response({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Download resumed'
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': f"Download is in '{download_progress['status']}' state, cannot resume"
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _refresh_model_metadata(model_hash, model_name, scanner_type, scanner):
|
|
||||||
"""Refresh model metadata from CivitAI
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_hash: SHA256 hash of the model
|
|
||||||
model_name: Name of the model (for logging)
|
|
||||||
scanner_type: Type of scanner ('lora' or 'checkpoint')
|
|
||||||
scanner: Scanner instance for this model type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if metadata was successfully refreshed, False otherwise
|
|
||||||
"""
|
|
||||||
global download_progress
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Find the model in the scanner cache
|
|
||||||
cache = await scanner.get_cached_data()
|
|
||||||
model_data = None
|
|
||||||
|
|
||||||
for item in cache.raw_data:
|
|
||||||
if item.get('sha256') == model_hash:
|
|
||||||
model_data = item
|
|
||||||
break
|
|
||||||
|
|
||||||
if not model_data:
|
|
||||||
logger.warning(f"Model {model_name} with hash {model_hash} not found in cache")
|
|
||||||
return False
|
|
||||||
|
|
||||||
file_path = model_data.get('file_path')
|
|
||||||
if not file_path:
|
|
||||||
logger.warning(f"Model {model_name} has no file path")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Track that we're refreshing this model
|
|
||||||
download_progress['refreshed_models'].add(model_hash)
|
|
||||||
|
|
||||||
# Use ModelRouteUtils to refresh the metadata
|
|
||||||
async def update_cache_func(old_path, new_path, metadata):
|
|
||||||
return await scanner.update_single_model_cache(old_path, new_path, metadata)
|
|
||||||
|
|
||||||
success = await ModelRouteUtils.fetch_and_update_model(
|
|
||||||
model_hash,
|
|
||||||
file_path,
|
|
||||||
model_data,
|
|
||||||
update_cache_func
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(f"Successfully refreshed metadata for {model_name}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.warning(f"Failed to refresh metadata for {model_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error refreshing metadata for {model_name}: {str(e)}"
|
|
||||||
logger.error(error_msg, exc_info=True)
|
|
||||||
download_progress['errors'].append(error_msg)
|
|
||||||
download_progress['last_error'] = error_msg
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_civitai_optimized_url(image_url):
|
|
||||||
"""Convert a Civitai image URL to its optimized WebP version
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_url: Original Civitai image URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: URL to optimized WebP version
|
|
||||||
"""
|
|
||||||
# Match the base part of Civitai URLs
|
|
||||||
base_pattern = r'(https://image\.civitai\.com/[^/]+/[^/]+)'
|
|
||||||
match = re.match(base_pattern, image_url)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
base_url = match.group(1)
|
|
||||||
# Create the optimized WebP URL
|
|
||||||
return f"{base_url}/optimized=true/image.webp"
|
|
||||||
|
|
||||||
# Return original URL if it doesn't match the expected format
|
|
||||||
return image_url
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _process_model_images(model_hash, model_name, model_images, model_dir, optimize, independent_session, delay):
|
|
||||||
"""Process and download images for a single model
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_hash: SHA256 hash of the model
|
|
||||||
model_name: Name of the model
|
|
||||||
model_images: List of image objects from CivitAI
|
|
||||||
model_dir: Directory to save images to
|
|
||||||
optimize: Whether to optimize images
|
|
||||||
independent_session: aiohttp session for downloads
|
|
||||||
delay: Delay between downloads
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if all images were processed successfully, False otherwise
|
|
||||||
"""
|
|
||||||
global download_progress
|
|
||||||
|
|
||||||
model_success = True
|
|
||||||
|
|
||||||
for i, image in enumerate(model_images, 1):
|
|
||||||
image_url = image.get('url')
|
|
||||||
if not image_url:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get image filename from URL
|
|
||||||
image_filename = os.path.basename(image_url.split('?')[0])
|
|
||||||
image_ext = os.path.splitext(image_filename)[1].lower()
|
|
||||||
|
|
||||||
# Handle both images and videos
|
|
||||||
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
|
|
||||||
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
|
|
||||||
|
|
||||||
if not (is_image or is_video):
|
|
||||||
logger.debug(f"Skipping unsupported file type: {image_filename}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
save_filename = f"image_{i}{image_ext}"
|
|
||||||
|
|
||||||
# If optimizing images and this is a Civitai image, use their pre-optimized WebP version
|
|
||||||
if is_image and optimize and 'civitai.com' in image_url:
|
|
||||||
# Transform URL to use Civitai's optimized WebP version
|
|
||||||
image_url = MiscRoutes._get_civitai_optimized_url(image_url)
|
|
||||||
# Update filename to use .webp extension
|
|
||||||
save_filename = f"image_{i}.webp"
|
|
||||||
|
|
||||||
# Check if already downloaded
|
|
||||||
save_path = os.path.join(model_dir, save_filename)
|
|
||||||
if os.path.exists(save_path):
|
|
||||||
logger.debug(f"File already exists: {save_path}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Download the file
|
|
||||||
try:
|
|
||||||
logger.debug(f"Downloading {save_filename} for {model_name}")
|
|
||||||
|
|
||||||
# Direct download using the independent session
|
|
||||||
async with independent_session.get(image_url, timeout=60) as response:
|
|
||||||
if response.status == 200:
|
|
||||||
with open(save_path, 'wb') as f:
|
|
||||||
async for chunk in response.content.iter_chunked(8192):
|
|
||||||
if chunk:
|
|
||||||
f.write(chunk)
|
|
||||||
elif response.status == 404:
|
|
||||||
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
|
|
||||||
logger.warning(error_msg)
|
|
||||||
download_progress['errors'].append(error_msg)
|
|
||||||
download_progress['last_error'] = error_msg
|
|
||||||
model_success = False # Mark model as failed due to 404
|
|
||||||
# Return early to trigger metadata refresh attempt
|
|
||||||
return False, True # (success, is_stale_metadata)
|
|
||||||
else:
|
|
||||||
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
|
|
||||||
logger.warning(error_msg)
|
|
||||||
download_progress['errors'].append(error_msg)
|
|
||||||
download_progress['last_error'] = error_msg
|
|
||||||
model_success = False # Mark model as failed
|
|
||||||
|
|
||||||
# Add a delay between downloads for remote files only
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error downloading file {image_url}: {str(e)}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
download_progress['errors'].append(error_msg)
|
|
||||||
download_progress['last_error'] = error_msg
|
|
||||||
model_success = False # Mark model as failed
|
|
||||||
|
|
||||||
return model_success, False # (success, is_stale_metadata)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _process_local_example_images(model_file_path, model_file_name, model_name, model_dir, optimize):
|
|
||||||
"""Process local example images for a model
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_file_path: Path to the model file
|
|
||||||
model_file_name: Filename of the model
|
|
||||||
model_name: Name of the model
|
|
||||||
model_dir: Directory to save processed images to
|
|
||||||
optimize: Whether to optimize images
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if local images were processed successfully, False otherwise
|
|
||||||
"""
|
|
||||||
global download_progress
|
|
||||||
|
|
||||||
try:
|
|
||||||
model_dir_path = os.path.dirname(model_file_path)
|
|
||||||
local_images = []
|
|
||||||
|
|
||||||
# Look for files with pattern: filename.example.*.ext
|
|
||||||
if model_file_name:
|
|
||||||
example_prefix = f"{model_file_name}.example."
|
|
||||||
|
|
||||||
if os.path.exists(model_dir_path):
|
|
||||||
for file in os.listdir(model_dir_path):
|
|
||||||
file_lower = file.lower()
|
|
||||||
if file_lower.startswith(example_prefix.lower()):
|
|
||||||
file_ext = os.path.splitext(file_lower)[1]
|
|
||||||
is_supported = (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
|
|
||||||
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'])
|
|
||||||
|
|
||||||
if is_supported:
|
|
||||||
local_images.append(os.path.join(model_dir_path, file))
|
|
||||||
|
|
||||||
# Process local images if found
|
|
||||||
if local_images:
|
|
||||||
logger.info(f"Found {len(local_images)} local example images for {model_name}")
|
|
||||||
|
|
||||||
for i, local_image_path in enumerate(local_images, 1):
|
|
||||||
local_ext = os.path.splitext(local_image_path)[1].lower()
|
|
||||||
save_filename = f"image_{i}{local_ext}"
|
|
||||||
save_path = os.path.join(model_dir, save_filename)
|
|
||||||
|
|
||||||
# Skip if already exists in output directory
|
|
||||||
if os.path.exists(save_path):
|
|
||||||
logger.debug(f"File already exists in output: {save_path}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# For local files, we just copy them without optimization
|
|
||||||
# since we don't want to modify user files
|
|
||||||
with open(local_image_path, 'rb') as src_file:
|
|
||||||
with open(save_path, 'wb') as dst_file:
|
|
||||||
dst_file.write(src_file.read())
|
|
||||||
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error processing local examples for {model_name}: {str(e)}"
|
|
||||||
logger.error(error_msg)
|
|
||||||
download_progress['errors'].append(error_msg)
|
|
||||||
download_progress['last_error'] = error_msg
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _download_all_example_images(output_dir, optimize, model_types, delay):
|
|
||||||
"""Download example images for all models
|
|
||||||
|
|
||||||
Args:
|
|
||||||
output_dir: Base directory to save example images
|
|
||||||
optimize: Whether to optimize images
|
|
||||||
model_types: List of model types to process
|
|
||||||
delay: Delay between downloads to avoid rate limiting
|
|
||||||
"""
|
|
||||||
global is_downloading, download_progress
|
|
||||||
|
|
||||||
# Create an independent session for downloading example images
|
|
||||||
# This avoids interference with the CivitAI client's session
|
|
||||||
connector = aiohttp.TCPConnector(
|
|
||||||
ssl=True,
|
|
||||||
limit=3,
|
|
||||||
force_close=False,
|
|
||||||
enable_cleanup_closed=True
|
|
||||||
)
|
|
||||||
timeout = aiohttp.ClientTimeout(total=None, connect=60, sock_read=60)
|
|
||||||
|
|
||||||
# Create a dedicated session just for this download task
|
|
||||||
independent_session = aiohttp.ClientSession(
|
|
||||||
connector=connector,
|
|
||||||
trust_env=True,
|
|
||||||
timeout=timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get the scanners
|
|
||||||
scanners = []
|
|
||||||
if 'lora' in model_types:
|
|
||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
|
||||||
scanners.append(('lora', lora_scanner))
|
|
||||||
|
|
||||||
if 'checkpoint' in model_types:
|
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
|
||||||
scanners.append(('checkpoint', checkpoint_scanner))
|
|
||||||
|
|
||||||
# Get all models from all scanners
|
|
||||||
all_models = []
|
|
||||||
for scanner_type, scanner in scanners:
|
|
||||||
cache = await scanner.get_cached_data()
|
|
||||||
if cache and cache.raw_data:
|
|
||||||
for model in cache.raw_data:
|
|
||||||
# Only process models with images and a valid sha256
|
|
||||||
if model.get('civitai') and model.get('civitai', {}).get('images') and model.get('sha256'):
|
|
||||||
all_models.append((scanner_type, model, scanner))
|
|
||||||
|
|
||||||
# Update total count
|
|
||||||
download_progress['total'] = len(all_models)
|
|
||||||
logger.info(f"Found {download_progress['total']} models with example images")
|
|
||||||
|
|
||||||
# Process each model
|
|
||||||
for scanner_type, model, scanner in all_models:
|
|
||||||
# Check if download is paused
|
|
||||||
while download_progress['status'] == 'paused':
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
# Check if download should continue
|
|
||||||
if download_progress['status'] != 'running':
|
|
||||||
logger.info(f"Download stopped: {download_progress['status']}")
|
|
||||||
break
|
|
||||||
|
|
||||||
model_hash = model.get('sha256', '').lower()
|
|
||||||
model_name = model.get('model_name', 'Unknown')
|
|
||||||
model_file_path = model.get('file_path', '')
|
|
||||||
model_file_name = model.get('file_name', '')
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Update current model info
|
|
||||||
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
|
|
||||||
|
|
||||||
# Skip if already processed
|
|
||||||
if model_hash in download_progress['processed_models']:
|
|
||||||
logger.debug(f"Skipping already processed model: {model_name}")
|
|
||||||
download_progress['completed'] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create model directory
|
|
||||||
model_dir = os.path.join(output_dir, model_hash)
|
|
||||||
os.makedirs(model_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Process images for this model
|
|
||||||
images = model.get('civitai', {}).get('images', [])
|
|
||||||
|
|
||||||
if not images:
|
|
||||||
logger.debug(f"No images found for model: {model_name}")
|
|
||||||
download_progress['processed_models'].add(model_hash)
|
|
||||||
download_progress['completed'] += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# First check if we have local example images for this model
|
|
||||||
local_images_processed = False
|
|
||||||
if model_file_path:
|
|
||||||
local_images_processed = await MiscRoutes._process_local_example_images(
|
|
||||||
model_file_path,
|
|
||||||
model_file_name,
|
|
||||||
model_name,
|
|
||||||
model_dir,
|
|
||||||
optimize
|
|
||||||
)
|
|
||||||
|
|
||||||
if local_images_processed:
|
|
||||||
# Mark as successfully processed if all local images were processed
|
|
||||||
download_progress['processed_models'].add(model_hash)
|
|
||||||
logger.info(f"Successfully processed local examples for {model_name}")
|
|
||||||
|
|
||||||
# If we didn't process local images, download from remote
|
|
||||||
if not local_images_processed:
|
|
||||||
# Try to download images
|
|
||||||
model_success, is_stale_metadata = await MiscRoutes._process_model_images(
|
|
||||||
model_hash,
|
|
||||||
model_name,
|
|
||||||
images,
|
|
||||||
model_dir,
|
|
||||||
optimize,
|
|
||||||
independent_session,
|
|
||||||
delay
|
|
||||||
)
|
|
||||||
|
|
||||||
# If metadata is stale (404 error), try to refresh it and download again
|
|
||||||
if is_stale_metadata and model_hash not in download_progress['refreshed_models']:
|
|
||||||
logger.info(f"Metadata seems stale for {model_name}, attempting to refresh...")
|
|
||||||
|
|
||||||
# Refresh metadata from CivitAI
|
|
||||||
refresh_success = await MiscRoutes._refresh_model_metadata(
|
|
||||||
model_hash,
|
|
||||||
model_name,
|
|
||||||
scanner_type,
|
|
||||||
scanner
|
|
||||||
)
|
|
||||||
|
|
||||||
if refresh_success:
|
|
||||||
# Get updated model data
|
|
||||||
updated_cache = await scanner.get_cached_data()
|
|
||||||
updated_model = None
|
|
||||||
|
|
||||||
for item in updated_cache.raw_data:
|
|
||||||
if item.get('sha256') == model_hash:
|
|
||||||
updated_model = item
|
|
||||||
break
|
|
||||||
|
|
||||||
if updated_model and updated_model.get('civitai', {}).get('images'):
|
|
||||||
# Try downloading with updated metadata
|
|
||||||
logger.info(f"Retrying download with refreshed metadata for {model_name}")
|
|
||||||
updated_images = updated_model.get('civitai', {}).get('images', [])
|
|
||||||
|
|
||||||
# Retry download with new images
|
|
||||||
model_success, _ = await MiscRoutes._process_model_images(
|
|
||||||
model_hash,
|
|
||||||
model_name,
|
|
||||||
updated_images,
|
|
||||||
model_dir,
|
|
||||||
optimize,
|
|
||||||
independent_session,
|
|
||||||
delay
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only mark model as processed if all images downloaded successfully
|
|
||||||
if model_success:
|
|
||||||
download_progress['processed_models'].add(model_hash)
|
|
||||||
else:
|
|
||||||
logger.warning(f"Model {model_name} had download errors, will not mark as completed")
|
|
||||||
|
|
||||||
# Save progress to file periodically
|
|
||||||
if download_progress['completed'] % 10 == 0 or download_progress['completed'] == download_progress['total'] - 1:
|
|
||||||
progress_file = os.path.join(output_dir, '.download_progress.json')
|
|
||||||
with open(progress_file, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump({
|
|
||||||
'processed_models': list(download_progress['processed_models']),
|
|
||||||
'refreshed_models': list(download_progress['refreshed_models']),
|
|
||||||
'completed': download_progress['completed'],
|
|
||||||
'total': download_progress['total'],
|
|
||||||
'last_update': time.time()
|
|
||||||
}, f, indent=2)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error processing model {model.get('model_name')}: {str(e)}"
|
|
||||||
logger.error(error_msg, exc_info=True)
|
|
||||||
download_progress['errors'].append(error_msg)
|
|
||||||
download_progress['last_error'] = error_msg
|
|
||||||
|
|
||||||
# Update progress
|
|
||||||
download_progress['completed'] += 1
|
|
||||||
|
|
||||||
# Mark as completed
|
|
||||||
download_progress['status'] = 'completed'
|
|
||||||
download_progress['end_time'] = time.time()
|
|
||||||
logger.info(f"Example images download completed: {download_progress['completed']}/{download_progress['total']} models processed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = f"Error during example images download: {str(e)}"
|
|
||||||
logger.error(error_msg, exc_info=True)
|
|
||||||
download_progress['errors'].append(error_msg)
|
|
||||||
download_progress['last_error'] = error_msg
|
|
||||||
download_progress['status'] = 'error'
|
|
||||||
download_progress['end_time'] = time.time()
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Close the independent session
|
|
||||||
try:
|
|
||||||
await independent_session.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error closing download session: {e}")
|
|
||||||
|
|
||||||
# Save final progress to file
|
|
||||||
try:
|
|
||||||
progress_file = os.path.join(output_dir, '.download_progress.json')
|
|
||||||
with open(progress_file, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump({
|
|
||||||
'processed_models': list(download_progress['processed_models']),
|
|
||||||
'refreshed_models': list(download_progress['refreshed_models']),
|
|
||||||
'completed': download_progress['completed'],
|
|
||||||
'total': download_progress['total'],
|
|
||||||
'last_update': time.time(),
|
|
||||||
'status': download_progress['status']
|
|
||||||
}, f, indent=2)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save progress file: {e}")
|
|
||||||
|
|
||||||
# Set download status to not downloading
|
|
||||||
is_downloading = False
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_lora_code(request):
|
async def update_lora_code(request):
|
||||||
"""
|
"""
|
||||||
@@ -893,60 +270,135 @@ class MiscRoutes:
|
|||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def open_example_images_folder(request):
|
async def get_trained_words(request):
|
||||||
"""
|
"""
|
||||||
Open the example images folder for a specific model
|
Get trained words from a safetensors file, sorted by frequency
|
||||||
|
|
||||||
Expects a JSON body with:
|
Expects a query parameter:
|
||||||
{
|
file_path: Path to the safetensors file
|
||||||
"model_hash": "sha256_hash" # SHA256 hash of the model
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Parse the request body
|
# Get file path from query parameters
|
||||||
data = await request.json()
|
file_path = request.query.get('file_path')
|
||||||
model_hash = data.get('model_hash')
|
|
||||||
|
|
||||||
if not model_hash:
|
if not file_path:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'Missing model_hash parameter'
|
'error': 'Missing file_path parameter'
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Get the example images path from settings
|
# Check if file exists and is a safetensors file
|
||||||
example_images_path = settings.get('example_images_path')
|
if not os.path.exists(file_path):
|
||||||
if not example_images_path:
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': 'No example images path configured. Please set it in the settings panel first.'
|
'error': f"File not found: {file_path}"
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Construct the folder path for this model
|
|
||||||
model_folder = os.path.join(example_images_path, model_hash)
|
|
||||||
|
|
||||||
# Check if the folder exists
|
|
||||||
if not os.path.exists(model_folder):
|
|
||||||
return web.json_response({
|
|
||||||
'success': False,
|
|
||||||
'error': 'No example images found for this model. Download example images first.'
|
|
||||||
}, status=404)
|
}, status=404)
|
||||||
|
|
||||||
# Open the folder in the file explorer
|
if not file_path.lower().endswith('.safetensors'):
|
||||||
if os.name == 'nt': # Windows
|
return web.json_response({
|
||||||
os.startfile(model_folder)
|
'success': False,
|
||||||
elif os.name == 'posix': # macOS and Linux
|
'error': 'File is not a safetensors file'
|
||||||
if sys.platform == 'darwin': # macOS
|
}, status=400)
|
||||||
subprocess.Popen(['open', model_folder])
|
|
||||||
else: # Linux
|
|
||||||
subprocess.Popen(['xdg-open', model_folder])
|
|
||||||
|
|
||||||
|
# Extract trained words and class_tokens
|
||||||
|
trained_words, class_tokens = await extract_trained_words(file_path)
|
||||||
|
|
||||||
|
# Return result with both trained words and class tokens
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': f'Opened example images folder for model {model_hash}'
|
'trained_words': trained_words,
|
||||||
|
'class_tokens': class_tokens
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to open example images folder: {e}", exc_info=True)
|
logger.error(f"Failed to get trained words: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_model_example_files(request):
|
||||||
|
"""
|
||||||
|
Get list of example image files for a specific model based on file path
|
||||||
|
|
||||||
|
Expects:
|
||||||
|
- file_path in query parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- List of image files with their paths as static URLs
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get the model file path from query parameters
|
||||||
|
file_path = request.query.get('file_path')
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Missing file_path parameter'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Extract directory and base filename
|
||||||
|
model_dir = os.path.dirname(file_path)
|
||||||
|
model_filename = os.path.basename(file_path)
|
||||||
|
model_name = os.path.splitext(model_filename)[0]
|
||||||
|
|
||||||
|
# Check if the directory exists
|
||||||
|
if not os.path.exists(model_dir):
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Model directory not found',
|
||||||
|
'files': []
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Look for files matching the pattern modelname.example.<index>.<ext>
|
||||||
|
files = []
|
||||||
|
pattern = f"{model_name}.example."
|
||||||
|
|
||||||
|
for file in os.listdir(model_dir):
|
||||||
|
file_lower = file.lower()
|
||||||
|
if file_lower.startswith(pattern.lower()):
|
||||||
|
file_full_path = os.path.join(model_dir, file)
|
||||||
|
if os.path.isfile(file_full_path):
|
||||||
|
# Check if the file is a supported media file
|
||||||
|
file_ext = os.path.splitext(file)[1].lower()
|
||||||
|
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
|
||||||
|
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
|
||||||
|
|
||||||
|
# Extract the index from the filename
|
||||||
|
try:
|
||||||
|
# Extract the part after '.example.' and before file extension
|
||||||
|
index_part = file[len(pattern):].split('.')[0]
|
||||||
|
# Try to parse it as an integer
|
||||||
|
index = int(index_part)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
# If we can't parse the index, use infinity to sort at the end
|
||||||
|
index = float('inf')
|
||||||
|
|
||||||
|
# Convert file path to static URL
|
||||||
|
static_url = config.get_preview_static_url(file_full_path)
|
||||||
|
|
||||||
|
files.append({
|
||||||
|
'name': file,
|
||||||
|
'path': static_url,
|
||||||
|
'extension': file_ext,
|
||||||
|
'is_video': file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'],
|
||||||
|
'index': index
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort files by their index for consistent ordering
|
||||||
|
files.sort(key=lambda x: x['index'])
|
||||||
|
# Remove the index field as it's only used for sorting
|
||||||
|
for file in files:
|
||||||
|
file.pop('index', None)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'files': files
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get model example files: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ class RecipeRoutes:
|
|||||||
content_type = request.headers.get('Content-Type', '')
|
content_type = request.headers.get('Content-Type', '')
|
||||||
|
|
||||||
is_url_mode = False
|
is_url_mode = False
|
||||||
|
metadata = None # Initialize metadata variable
|
||||||
|
|
||||||
if 'multipart/form-data' in content_type:
|
if 'multipart/form-data' in content_type:
|
||||||
# Handle image upload
|
# Handle image upload
|
||||||
@@ -287,17 +288,63 @@ class RecipeRoutes:
|
|||||||
"loras": []
|
"loras": []
|
||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
# Download image from URL
|
# Check if this is a Civitai image URL
|
||||||
temp_path = download_civitai_image(url)
|
import re
|
||||||
|
civitai_image_match = re.match(r'https://civitai\.com/images/(\d+)', url)
|
||||||
|
|
||||||
if not temp_path:
|
if civitai_image_match:
|
||||||
return web.json_response({
|
# Extract image ID and fetch image info using get_image_info
|
||||||
"error": "Failed to download image from URL",
|
image_id = civitai_image_match.group(1)
|
||||||
"loras": []
|
image_info = await self.civitai_client.get_image_info(image_id)
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Extract metadata from the image using ExifUtils
|
if not image_info:
|
||||||
metadata = ExifUtils.extract_image_metadata(temp_path)
|
return web.json_response({
|
||||||
|
"error": "Failed to fetch image information from Civitai",
|
||||||
|
"loras": []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Get image URL from response
|
||||||
|
image_url = image_info.get('url')
|
||||||
|
if not image_url:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "No image URL found in Civitai response",
|
||||||
|
"loras": []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Download image directly from URL
|
||||||
|
session = await self.civitai_client.session
|
||||||
|
# Create a temporary file to save the downloaded image
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as temp_file:
|
||||||
|
temp_path = temp_file.name
|
||||||
|
|
||||||
|
async with session.get(image_url) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
return web.json_response({
|
||||||
|
"error": f"Failed to download image from URL: HTTP {response.status}",
|
||||||
|
"loras": []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
with open(temp_path, 'wb') as f:
|
||||||
|
f.write(await response.read())
|
||||||
|
|
||||||
|
# Use meta field from image_info as metadata
|
||||||
|
if 'meta' in image_info:
|
||||||
|
metadata = image_info['meta']
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Not a Civitai image URL, use the original download method
|
||||||
|
temp_path = download_civitai_image(url)
|
||||||
|
|
||||||
|
if not temp_path:
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Failed to download image from URL",
|
||||||
|
"loras": []
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# If metadata wasn't obtained from Civitai API, extract it from the image
|
||||||
|
if metadata is None:
|
||||||
|
# Extract metadata from the image using ExifUtils
|
||||||
|
metadata = ExifUtils.extract_image_metadata(temp_path)
|
||||||
|
|
||||||
# If no metadata found, return a more specific error
|
# If no metadata found, return a more specific error
|
||||||
if not metadata:
|
if not metadata:
|
||||||
|
|||||||
@@ -346,3 +346,34 @@ class CivitaiClient:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting hash from Civitai: {e}")
|
logger.error(f"Error getting hash from Civitai: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def get_image_info(self, image_id: str) -> Optional[Dict]:
|
||||||
|
"""Fetch image information from Civitai API
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_id: The Civitai image ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[Dict]: The image data or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
session = await self._ensure_fresh_session()
|
||||||
|
headers = self._get_request_headers()
|
||||||
|
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
|
||||||
|
|
||||||
|
logger.debug(f"Fetching image info for ID: {image_id}")
|
||||||
|
async with session.get(url, headers=headers) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
if data and "items" in data and len(data["items"]) > 0:
|
||||||
|
logger.debug(f"Successfully fetched image info for ID: {image_id}")
|
||||||
|
return data["items"][0]
|
||||||
|
logger.warning(f"No image found with ID: {image_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.error(f"Failed to fetch image info for ID: {image_id} (status {response.status})")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error fetching image info: {e}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
from typing import Dict, Optional
|
|
||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LoraHashIndex:
|
|
||||||
"""Index for mapping LoRA file hashes to their file paths"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._hash_to_path: Dict[str, str] = {}
|
|
||||||
|
|
||||||
def add_entry(self, sha256: str, file_path: str) -> None:
|
|
||||||
"""Add or update a hash -> path mapping"""
|
|
||||||
if not sha256 or not file_path:
|
|
||||||
return
|
|
||||||
# Always store lowercase hashes for consistency
|
|
||||||
self._hash_to_path[sha256.lower()] = file_path
|
|
||||||
|
|
||||||
def remove_entry(self, sha256: str) -> None:
|
|
||||||
"""Remove a hash entry"""
|
|
||||||
if sha256:
|
|
||||||
self._hash_to_path.pop(sha256.lower(), None)
|
|
||||||
|
|
||||||
def remove_by_path(self, file_path: str) -> None:
|
|
||||||
"""Remove entry by file path"""
|
|
||||||
for sha256, path in list(self._hash_to_path.items()):
|
|
||||||
if path == file_path:
|
|
||||||
del self._hash_to_path[sha256]
|
|
||||||
break
|
|
||||||
|
|
||||||
def get_path(self, sha256: str) -> Optional[str]:
|
|
||||||
"""Get file path for a given hash"""
|
|
||||||
if not sha256:
|
|
||||||
return None
|
|
||||||
return self._hash_to_path.get(sha256.lower())
|
|
||||||
|
|
||||||
def get_hash(self, file_path: str) -> Optional[str]:
|
|
||||||
"""Get hash for a given file path"""
|
|
||||||
for sha256, path in self._hash_to_path.items():
|
|
||||||
if path == file_path:
|
|
||||||
return sha256
|
|
||||||
return None
|
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
|
||||||
"""Check if hash exists in index"""
|
|
||||||
if not sha256:
|
|
||||||
return False
|
|
||||||
return sha256.lower() in self._hash_to_path
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Clear all entries"""
|
|
||||||
self._hash_to_path.clear()
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
from typing import Dict, Optional, Set
|
from typing import Dict, Optional, Set, List
|
||||||
import os
|
import os
|
||||||
|
|
||||||
class ModelHashIndex:
|
class ModelHashIndex:
|
||||||
"""Index for looking up models by hash or path"""
|
"""Index for looking up models by hash or filename"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._hash_to_path: Dict[str, str] = {}
|
self._hash_to_path: Dict[str, str] = {}
|
||||||
self._filename_to_hash: Dict[str, str] = {} # Changed from path_to_hash to filename_to_hash
|
self._filename_to_hash: Dict[str, str] = {}
|
||||||
|
# New data structures for tracking duplicates
|
||||||
|
self._duplicate_hashes: Dict[str, List[str]] = {} # sha256 -> list of paths
|
||||||
|
self._duplicate_filenames: Dict[str, List[str]] = {} # filename -> list of paths
|
||||||
|
|
||||||
def add_entry(self, sha256: str, file_path: str) -> None:
|
def add_entry(self, sha256: str, file_path: str) -> None:
|
||||||
"""Add or update hash index entry"""
|
"""Add or update hash index entry"""
|
||||||
@@ -19,6 +22,26 @@ class ModelHashIndex:
|
|||||||
# Extract filename without extension
|
# Extract filename without extension
|
||||||
filename = self._get_filename_from_path(file_path)
|
filename = self._get_filename_from_path(file_path)
|
||||||
|
|
||||||
|
# Track duplicates by hash
|
||||||
|
if sha256 in self._hash_to_path:
|
||||||
|
old_path = self._hash_to_path[sha256]
|
||||||
|
if old_path != file_path: # Only record if it's actually a different path
|
||||||
|
if sha256 not in self._duplicate_hashes:
|
||||||
|
self._duplicate_hashes[sha256] = [old_path]
|
||||||
|
if file_path not in self._duplicate_hashes.get(sha256, []):
|
||||||
|
self._duplicate_hashes.setdefault(sha256, []).append(file_path)
|
||||||
|
|
||||||
|
# Track duplicates by filename
|
||||||
|
if filename in self._filename_to_hash:
|
||||||
|
old_hash = self._filename_to_hash[filename]
|
||||||
|
if old_hash != sha256: # Different models with the same name
|
||||||
|
old_path = self._hash_to_path.get(old_hash)
|
||||||
|
if old_path:
|
||||||
|
if filename not in self._duplicate_filenames:
|
||||||
|
self._duplicate_filenames[filename] = [old_path]
|
||||||
|
if file_path not in self._duplicate_filenames.get(filename, []):
|
||||||
|
self._duplicate_filenames.setdefault(filename, []).append(file_path)
|
||||||
|
|
||||||
# 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]
|
||||||
@@ -43,21 +66,123 @@ class ModelHashIndex:
|
|||||||
def remove_by_path(self, file_path: str) -> None:
|
def remove_by_path(self, file_path: str) -> None:
|
||||||
"""Remove entry by file path"""
|
"""Remove entry by file path"""
|
||||||
filename = self._get_filename_from_path(file_path)
|
filename = self._get_filename_from_path(file_path)
|
||||||
if filename in self._filename_to_hash:
|
hash_val = None
|
||||||
hash_val = self._filename_to_hash[filename]
|
|
||||||
if hash_val in self._hash_to_path:
|
# Find the hash for this file path
|
||||||
|
for h, p in self._hash_to_path.items():
|
||||||
|
if p == file_path:
|
||||||
|
hash_val = h
|
||||||
|
break
|
||||||
|
|
||||||
|
# If we didn't find a hash, nothing to do
|
||||||
|
if not hash_val:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update duplicates tracking for hash
|
||||||
|
if hash_val in self._duplicate_hashes:
|
||||||
|
# Remove the current path from duplicates
|
||||||
|
self._duplicate_hashes[hash_val] = [p for p in self._duplicate_hashes[hash_val] if p != file_path]
|
||||||
|
|
||||||
|
# Update or remove hash mapping based on remaining duplicates
|
||||||
|
if len(self._duplicate_hashes[hash_val]) > 0:
|
||||||
|
# Replace with one of the remaining paths
|
||||||
|
new_path = self._duplicate_hashes[hash_val][0]
|
||||||
|
new_filename = self._get_filename_from_path(new_path)
|
||||||
|
|
||||||
|
# Update hash-to-path mapping
|
||||||
|
self._hash_to_path[hash_val] = new_path
|
||||||
|
|
||||||
|
# IMPORTANT: Update filename-to-hash mapping for consistency
|
||||||
|
# Remove old filename mapping if it points to this hash
|
||||||
|
if filename in self._filename_to_hash and self._filename_to_hash[filename] == hash_val:
|
||||||
|
del self._filename_to_hash[filename]
|
||||||
|
|
||||||
|
# Add new filename mapping
|
||||||
|
self._filename_to_hash[new_filename] = hash_val
|
||||||
|
|
||||||
|
# If only one duplicate left, remove from duplicates tracking
|
||||||
|
if len(self._duplicate_hashes[hash_val]) == 1:
|
||||||
|
del self._duplicate_hashes[hash_val]
|
||||||
|
else:
|
||||||
|
# No duplicates left, remove hash entry completely
|
||||||
|
del self._duplicate_hashes[hash_val]
|
||||||
del self._hash_to_path[hash_val]
|
del self._hash_to_path[hash_val]
|
||||||
del self._filename_to_hash[filename]
|
|
||||||
|
# Remove corresponding filename entry if it points to this hash
|
||||||
|
if filename in self._filename_to_hash and self._filename_to_hash[filename] == hash_val:
|
||||||
|
del self._filename_to_hash[filename]
|
||||||
|
else:
|
||||||
|
# No duplicates, simply remove the hash entry
|
||||||
|
del self._hash_to_path[hash_val]
|
||||||
|
|
||||||
|
# Remove corresponding filename entry if it points to this hash
|
||||||
|
if filename in self._filename_to_hash and self._filename_to_hash[filename] == hash_val:
|
||||||
|
del self._filename_to_hash[filename]
|
||||||
|
|
||||||
|
# Update duplicates tracking for filename
|
||||||
|
if filename in self._duplicate_filenames:
|
||||||
|
# Remove the current path from duplicates
|
||||||
|
self._duplicate_filenames[filename] = [p for p in self._duplicate_filenames[filename] if p != file_path]
|
||||||
|
|
||||||
|
# Update or remove filename mapping based on remaining duplicates
|
||||||
|
if len(self._duplicate_filenames[filename]) > 0:
|
||||||
|
# Get the hash for the first remaining duplicate path
|
||||||
|
first_dup_path = self._duplicate_filenames[filename][0]
|
||||||
|
first_dup_hash = None
|
||||||
|
for h, p in self._hash_to_path.items():
|
||||||
|
if p == first_dup_path:
|
||||||
|
first_dup_hash = h
|
||||||
|
break
|
||||||
|
|
||||||
|
# Update the filename to hash mapping if we found a hash
|
||||||
|
if first_dup_hash:
|
||||||
|
self._filename_to_hash[filename] = first_dup_hash
|
||||||
|
|
||||||
|
# If only one duplicate left, remove from duplicates tracking
|
||||||
|
if len(self._duplicate_filenames[filename]) == 1:
|
||||||
|
del self._duplicate_filenames[filename]
|
||||||
|
else:
|
||||||
|
# No duplicates left, remove filename entry completely
|
||||||
|
del self._duplicate_filenames[filename]
|
||||||
|
if filename in self._filename_to_hash:
|
||||||
|
del self._filename_to_hash[filename]
|
||||||
|
|
||||||
def remove_by_hash(self, sha256: str) -> None:
|
def remove_by_hash(self, sha256: str) -> None:
|
||||||
"""Remove entry by hash"""
|
"""Remove entry by hash"""
|
||||||
sha256 = sha256.lower()
|
sha256 = sha256.lower()
|
||||||
if sha256 in self._hash_to_path:
|
if sha256 not in self._hash_to_path:
|
||||||
path = self._hash_to_path[sha256]
|
return
|
||||||
filename = self._get_filename_from_path(path)
|
|
||||||
if filename in self._filename_to_hash:
|
# Get the path and filename
|
||||||
del self._filename_to_hash[filename]
|
path = self._hash_to_path[sha256]
|
||||||
del self._hash_to_path[sha256]
|
filename = self._get_filename_from_path(path)
|
||||||
|
|
||||||
|
# Get all paths for this hash (including duplicates)
|
||||||
|
paths_to_remove = [path]
|
||||||
|
if sha256 in self._duplicate_hashes:
|
||||||
|
paths_to_remove.extend(self._duplicate_hashes[sha256])
|
||||||
|
del self._duplicate_hashes[sha256]
|
||||||
|
|
||||||
|
# Remove hash-to-path mapping
|
||||||
|
del self._hash_to_path[sha256]
|
||||||
|
|
||||||
|
# Update filename-to-hash and duplicate filenames for all paths
|
||||||
|
for path_to_remove in paths_to_remove:
|
||||||
|
fname = self._get_filename_from_path(path_to_remove)
|
||||||
|
|
||||||
|
# If this filename maps to the hash we're removing, remove it
|
||||||
|
if fname in self._filename_to_hash and self._filename_to_hash[fname] == sha256:
|
||||||
|
del self._filename_to_hash[fname]
|
||||||
|
|
||||||
|
# Update duplicate filenames tracking
|
||||||
|
if fname in self._duplicate_filenames:
|
||||||
|
self._duplicate_filenames[fname] = [p for p in self._duplicate_filenames[fname] if p != path_to_remove]
|
||||||
|
|
||||||
|
if not self._duplicate_filenames[fname]:
|
||||||
|
del self._duplicate_filenames[fname]
|
||||||
|
elif len(self._duplicate_filenames[fname]) == 1:
|
||||||
|
# If only one entry remains, it's no longer a duplicate
|
||||||
|
del self._duplicate_filenames[fname]
|
||||||
|
|
||||||
def has_hash(self, sha256: str) -> bool:
|
def has_hash(self, sha256: str) -> bool:
|
||||||
"""Check if hash exists in index"""
|
"""Check if hash exists in index"""
|
||||||
@@ -82,6 +207,8 @@ class ModelHashIndex:
|
|||||||
"""Clear all entries"""
|
"""Clear all entries"""
|
||||||
self._hash_to_path.clear()
|
self._hash_to_path.clear()
|
||||||
self._filename_to_hash.clear()
|
self._filename_to_hash.clear()
|
||||||
|
self._duplicate_hashes.clear()
|
||||||
|
self._duplicate_filenames.clear()
|
||||||
|
|
||||||
def get_all_hashes(self) -> Set[str]:
|
def get_all_hashes(self) -> Set[str]:
|
||||||
"""Get all hashes in the index"""
|
"""Get all hashes in the index"""
|
||||||
@@ -91,6 +218,14 @@ class ModelHashIndex:
|
|||||||
"""Get all filenames in the index"""
|
"""Get all filenames in the index"""
|
||||||
return set(self._filename_to_hash.keys())
|
return set(self._filename_to_hash.keys())
|
||||||
|
|
||||||
|
def get_duplicate_hashes(self) -> Dict[str, List[str]]:
|
||||||
|
"""Get dictionary of duplicate hashes and their paths"""
|
||||||
|
return self._duplicate_hashes
|
||||||
|
|
||||||
|
def get_duplicate_filenames(self) -> Dict[str, List[str]]:
|
||||||
|
"""Get dictionary of duplicate filenames and their paths"""
|
||||||
|
return self._duplicate_filenames
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
"""Get number of entries"""
|
"""Get number of entries"""
|
||||||
return len(self._hash_to_path)
|
return len(self._hash_to_path)
|
||||||
@@ -19,7 +19,11 @@ from .websocket_manager import ws_manager
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Define cache version to handle future format changes
|
# Define cache version to handle future format changes
|
||||||
CACHE_VERSION = 1
|
# Version history:
|
||||||
|
# 1 - Initial version
|
||||||
|
# 2 - Added duplicate_filenames and duplicate_hashes tracking
|
||||||
|
# 3 - Added _excluded_models list to cache
|
||||||
|
CACHE_VERSION = 3
|
||||||
|
|
||||||
class ModelScanner:
|
class ModelScanner:
|
||||||
"""Base service for scanning and managing model files"""
|
"""Base service for scanning and managing model files"""
|
||||||
@@ -107,10 +111,13 @@ class ModelScanner:
|
|||||||
"raw_data": self._cache.raw_data,
|
"raw_data": self._cache.raw_data,
|
||||||
"hash_index": {
|
"hash_index": {
|
||||||
"hash_to_path": self._hash_index._hash_to_path,
|
"hash_to_path": self._hash_index._hash_to_path,
|
||||||
"filename_to_hash": self._hash_index._filename_to_hash # Fix: changed from path_to_hash to filename_to_hash
|
"filename_to_hash": self._hash_index._filename_to_hash, # Fix: changed from path_to_hash to filename_to_hash
|
||||||
|
"duplicate_hashes": self._hash_index._duplicate_hashes,
|
||||||
|
"duplicate_filenames": self._hash_index._duplicate_filenames
|
||||||
},
|
},
|
||||||
"tags_count": self._tags_count,
|
"tags_count": self._tags_count,
|
||||||
"dirs_last_modified": self._get_dirs_last_modified()
|
"dirs_last_modified": self._get_dirs_last_modified(),
|
||||||
|
"excluded_models": self._excluded_models # Add excluded_models to cache data
|
||||||
}
|
}
|
||||||
|
|
||||||
# Preprocess data to handle large integers
|
# Preprocess data to handle large integers
|
||||||
@@ -128,6 +135,7 @@ class ModelScanner:
|
|||||||
os.rename(temp_path, cache_path)
|
os.rename(temp_path, cache_path)
|
||||||
|
|
||||||
logger.info(f"Saved {self.model_type} cache with {len(self._cache.raw_data)} models to {cache_path}")
|
logger.info(f"Saved {self.model_type} cache with {len(self._cache.raw_data)} models to {cache_path}")
|
||||||
|
logger.debug(f"Hash index stats - hash_to_path: {len(self._hash_index._hash_to_path)}, filename_to_hash: {len(self._hash_index._filename_to_hash)}, duplicate_hashes: {len(self._hash_index._duplicate_hashes)}, duplicate_filenames: {len(self._hash_index._duplicate_filenames)}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error saving {self.model_type} cache to disk: {e}")
|
logger.error(f"Error saving {self.model_type} cache to disk: {e}")
|
||||||
@@ -158,18 +166,21 @@ class ModelScanner:
|
|||||||
def _is_cache_valid(self, cache_data: Dict) -> bool:
|
def _is_cache_valid(self, cache_data: Dict) -> bool:
|
||||||
"""Validate if the loaded cache is still valid"""
|
"""Validate if the loaded cache is still valid"""
|
||||||
if not cache_data or cache_data.get("version") != CACHE_VERSION:
|
if not cache_data or cache_data.get("version") != CACHE_VERSION:
|
||||||
|
logger.info(f"Cache invalid - version mismatch. Got: {cache_data.get('version')}, Expected: {CACHE_VERSION}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if cache_data.get("model_type") != self.model_type:
|
if cache_data.get("model_type") != self.model_type:
|
||||||
|
logger.info(f"Cache invalid - model type mismatch. Got: {cache_data.get('model_type')}, Expected: {self.model_type}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if directories have changed
|
# Check if directories have changed
|
||||||
stored_dirs = cache_data.get("dirs_last_modified", {})
|
# stored_dirs = cache_data.get("dirs_last_modified", {})
|
||||||
current_dirs = self._get_dirs_last_modified()
|
# current_dirs = self._get_dirs_last_modified()
|
||||||
|
|
||||||
# If directory structure has changed, cache is invalid
|
# If directory structure has changed, cache is invalid
|
||||||
if set(stored_dirs.keys()) != set(current_dirs.keys()):
|
# if set(stored_dirs.keys()) != set(current_dirs.keys()):
|
||||||
return False
|
# logger.info(f"Cache invalid - directory structure changed. Stored: {set(stored_dirs.keys())}, Current: {set(current_dirs.keys())}")
|
||||||
|
# return False
|
||||||
|
|
||||||
# Remove the modification time check to make cache validation less strict
|
# Remove the modification time check to make cache validation less strict
|
||||||
# This allows the cache to be valid even when files have changed
|
# This allows the cache to be valid even when files have changed
|
||||||
@@ -205,10 +216,15 @@ class ModelScanner:
|
|||||||
hash_index_data = cache_data.get("hash_index", {})
|
hash_index_data = cache_data.get("hash_index", {})
|
||||||
self._hash_index._hash_to_path = hash_index_data.get("hash_to_path", {})
|
self._hash_index._hash_to_path = hash_index_data.get("hash_to_path", {})
|
||||||
self._hash_index._filename_to_hash = hash_index_data.get("filename_to_hash", {}) # Fix: changed from path_to_hash to filename_to_hash
|
self._hash_index._filename_to_hash = hash_index_data.get("filename_to_hash", {}) # Fix: changed from path_to_hash to filename_to_hash
|
||||||
|
self._hash_index._duplicate_hashes = hash_index_data.get("duplicate_hashes", {})
|
||||||
|
self._hash_index._duplicate_filenames = hash_index_data.get("duplicate_filenames", {})
|
||||||
|
|
||||||
# Load tags count
|
# Load tags count
|
||||||
self._tags_count = cache_data.get("tags_count", {})
|
self._tags_count = cache_data.get("tags_count", {})
|
||||||
|
|
||||||
|
# Load excluded models
|
||||||
|
self._excluded_models = cache_data.get("excluded_models", [])
|
||||||
|
|
||||||
# Resort the cache
|
# Resort the cache
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
@@ -1212,3 +1228,166 @@ class ModelScanner:
|
|||||||
# Save updated cache to disk
|
# Save updated cache to disk
|
||||||
await self._save_cache_to_disk()
|
await self._save_cache_to_disk()
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
async def bulk_delete_models(self, file_paths: List[str]) -> Dict:
|
||||||
|
"""Delete multiple models and update cache in a batch operation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_paths: List of file paths to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing results of the operation
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not file_paths:
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': 'No file paths provided for deletion',
|
||||||
|
'results': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the file monitor
|
||||||
|
file_monitor = getattr(self, 'file_monitor', None)
|
||||||
|
|
||||||
|
# Keep track of success and failures
|
||||||
|
results = []
|
||||||
|
total_deleted = 0
|
||||||
|
cache_updated = False
|
||||||
|
|
||||||
|
# Get cache data
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
# Track deleted models to update cache once
|
||||||
|
deleted_models = []
|
||||||
|
|
||||||
|
for file_path in file_paths:
|
||||||
|
try:
|
||||||
|
target_dir = os.path.dirname(file_path)
|
||||||
|
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
|
||||||
|
# Delete all associated files for the model
|
||||||
|
from ..utils.routes_common import ModelRouteUtils
|
||||||
|
deleted_files = await ModelRouteUtils.delete_model_files(
|
||||||
|
target_dir,
|
||||||
|
file_name,
|
||||||
|
file_monitor
|
||||||
|
)
|
||||||
|
|
||||||
|
if deleted_files:
|
||||||
|
deleted_models.append(file_path)
|
||||||
|
results.append({
|
||||||
|
'file_path': file_path,
|
||||||
|
'success': True,
|
||||||
|
'deleted_files': deleted_files
|
||||||
|
})
|
||||||
|
total_deleted += 1
|
||||||
|
else:
|
||||||
|
results.append({
|
||||||
|
'file_path': file_path,
|
||||||
|
'success': False,
|
||||||
|
'error': 'No files deleted'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting file {file_path}: {e}")
|
||||||
|
results.append({
|
||||||
|
'file_path': file_path,
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Batch update cache if any models were deleted
|
||||||
|
if deleted_models:
|
||||||
|
# Update the cache in a batch operation
|
||||||
|
cache_updated = await self._batch_update_cache_for_deleted_models(deleted_models)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'success': True,
|
||||||
|
'total_deleted': total_deleted,
|
||||||
|
'total_attempted': len(file_paths),
|
||||||
|
'cache_updated': cache_updated,
|
||||||
|
'results': results
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in bulk delete: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'success': False,
|
||||||
|
'error': str(e),
|
||||||
|
'results': []
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _batch_update_cache_for_deleted_models(self, file_paths: List[str]) -> bool:
|
||||||
|
"""Update cache after multiple models have been deleted
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_paths: List of file paths that were deleted
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if cache was updated and saved successfully
|
||||||
|
"""
|
||||||
|
if not file_paths or self._cache is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all models that need to be removed from cache
|
||||||
|
models_to_remove = [item for item in self._cache.raw_data if item['file_path'] in file_paths]
|
||||||
|
|
||||||
|
if not models_to_remove:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Update tag counts
|
||||||
|
for model in models_to_remove:
|
||||||
|
for tag in model.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
|
||||||
|
# Update hash index
|
||||||
|
for model in models_to_remove:
|
||||||
|
file_path = model['file_path']
|
||||||
|
if hasattr(self, '_hash_index') and self._hash_index:
|
||||||
|
# Get the hash and filename before removal for duplicate checking
|
||||||
|
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
hash_val = model.get('sha256', '').lower()
|
||||||
|
|
||||||
|
# Remove from hash index
|
||||||
|
self._hash_index.remove_by_path(file_path)
|
||||||
|
|
||||||
|
# Check and clean up duplicates
|
||||||
|
self._cleanup_duplicates_after_removal(hash_val, file_name)
|
||||||
|
|
||||||
|
# Update cache data
|
||||||
|
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in file_paths]
|
||||||
|
|
||||||
|
# Resort cache
|
||||||
|
await self._cache.resort()
|
||||||
|
|
||||||
|
# Save updated cache to disk
|
||||||
|
await self._save_cache_to_disk()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating cache after bulk delete: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _cleanup_duplicates_after_removal(self, hash_val: str, file_name: str) -> None:
|
||||||
|
"""Clean up duplicate entries in hash index after removing a model
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hash_val: SHA256 hash of the removed model
|
||||||
|
file_name: File name of the removed model without extension
|
||||||
|
"""
|
||||||
|
if not hash_val or not file_name or not hasattr(self, '_hash_index'):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clean up hash duplicates if only 0 or 1 entries remain
|
||||||
|
if hash_val in self._hash_index._duplicate_hashes:
|
||||||
|
if len(self._hash_index._duplicate_hashes[hash_val]) <= 1:
|
||||||
|
del self._hash_index._duplicate_hashes[hash_val]
|
||||||
|
|
||||||
|
# Clean up filename duplicates if only 0 or 1 entries remain
|
||||||
|
if file_name in self._hash_index._duplicate_filenames:
|
||||||
|
if len(self._hash_index._duplicate_filenames[file_name]) <= 1:
|
||||||
|
del self._hash_index._duplicate_filenames[file_name]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from safetensors import safe_open
|
from safetensors import safe_open
|
||||||
from typing import Dict
|
from typing import Dict, List, Tuple
|
||||||
from .model_utils import determine_base_model
|
from .model_utils import determine_base_model
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -81,3 +82,52 @@ async def extract_checkpoint_metadata(file_path: str) -> dict:
|
|||||||
logger.error(f"Error extracting checkpoint metadata for {file_path}: {e}")
|
logger.error(f"Error extracting checkpoint metadata for {file_path}: {e}")
|
||||||
# Return default values
|
# Return default values
|
||||||
return {'base_model': 'Unknown', 'model_type': 'checkpoint'}
|
return {'base_model': 'Unknown', 'model_type': 'checkpoint'}
|
||||||
|
|
||||||
|
async def extract_trained_words(file_path: str) -> Tuple[List[Tuple[str, int]], str]:
|
||||||
|
"""Extract trained words from a safetensors file and sort by frequency
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the safetensors file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of:
|
||||||
|
- List of (word, frequency) tuples sorted by frequency (highest first)
|
||||||
|
- class_tokens value (or None if not found)
|
||||||
|
"""
|
||||||
|
class_tokens = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with safe_open(file_path, framework="pt", device="cpu") as f:
|
||||||
|
metadata = f.metadata()
|
||||||
|
|
||||||
|
# Extract class_tokens from ss_datasets if present
|
||||||
|
if metadata and "ss_datasets" in metadata:
|
||||||
|
try:
|
||||||
|
datasets_data = json.loads(metadata["ss_datasets"])
|
||||||
|
# Look for class_tokens in the first subset
|
||||||
|
if datasets_data and isinstance(datasets_data, list) and datasets_data[0].get("subsets"):
|
||||||
|
subsets = datasets_data[0].get("subsets", [])
|
||||||
|
if subsets and isinstance(subsets, list) and len(subsets) > 0:
|
||||||
|
class_tokens = subsets[0].get("class_tokens")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing ss_datasets for class_tokens: {str(e)}")
|
||||||
|
|
||||||
|
# Extract tag frequency as before
|
||||||
|
if metadata and "ss_tag_frequency" in metadata:
|
||||||
|
# Parse the JSON string into a dictionary
|
||||||
|
tag_data = json.loads(metadata["ss_tag_frequency"])
|
||||||
|
|
||||||
|
# The structure may have an outer key (like "image_dir" or "img")
|
||||||
|
# We need to get the inner dictionary with the actual word frequencies
|
||||||
|
if tag_data:
|
||||||
|
# Get the first key (usually "image_dir" or "img")
|
||||||
|
first_key = list(tag_data.keys())[0]
|
||||||
|
words_dict = tag_data[first_key]
|
||||||
|
|
||||||
|
# Sort words by frequency (highest first)
|
||||||
|
sorted_words = sorted(words_dict.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
return sorted_words, class_tokens
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error extracting trained words from {file_path}: {str(e)}")
|
||||||
|
|
||||||
|
return [], class_tokens
|
||||||
@@ -62,7 +62,7 @@ class ModelRouteUtils:
|
|||||||
# Update preview if needed
|
# Update preview if needed
|
||||||
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
|
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
|
||||||
first_preview = next((img for img in civitai_metadata.get('images', [])), None)
|
first_preview = next((img for img in civitai_metadata.get('images', [])), None)
|
||||||
if first_preview:
|
if (first_preview):
|
||||||
# Determine if content is video or image
|
# Determine if content is video or image
|
||||||
is_video = first_preview['type'] == 'video'
|
is_video = first_preview['type'] == 'video'
|
||||||
|
|
||||||
@@ -304,6 +304,8 @@ class ModelRouteUtils:
|
|||||||
if hasattr(scanner, '_hash_index') and scanner._hash_index:
|
if hasattr(scanner, '_hash_index') and scanner._hash_index:
|
||||||
scanner._hash_index.remove_by_path(file_path)
|
scanner._hash_index.remove_by_path(file_path)
|
||||||
|
|
||||||
|
await scanner._save_cache_to_disk()
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'deleted_files': deleted_files
|
'deleted_files': deleted_files
|
||||||
@@ -485,6 +487,8 @@ class ModelRouteUtils:
|
|||||||
# Add to excluded models list
|
# Add to excluded models list
|
||||||
scanner._excluded_models.append(file_path)
|
scanner._excluded_models.append(file_path)
|
||||||
|
|
||||||
|
await scanner._save_cache_to_disk()
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': f"Model {os.path.basename(file_path)} excluded"
|
'message': f"Model {os.path.basename(file_path)} excluded"
|
||||||
@@ -571,3 +575,106 @@ class ModelRouteUtils:
|
|||||||
|
|
||||||
logger.error(f"Error downloading {model_type}: {error_message}")
|
logger.error(f"Error downloading {model_type}: {error_message}")
|
||||||
return web.Response(status=500, text=error_message)
|
return web.Response(status=500, text=error_message)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_bulk_delete_models(request: web.Request, scanner) -> web.Response:
|
||||||
|
"""Handle bulk deletion of models
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The aiohttp request
|
||||||
|
scanner: The model scanner instance with cache management methods
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
web.Response: The HTTP response
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_paths = data.get('file_paths', [])
|
||||||
|
|
||||||
|
if not file_paths:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'No file paths provided for deletion'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Use the scanner's bulk delete method to handle all cache and file operations
|
||||||
|
result = await scanner.bulk_delete_models(file_paths)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': result.get('success', False),
|
||||||
|
'total_deleted': result.get('total_deleted', 0),
|
||||||
|
'total_attempted': result.get('total_attempted', len(file_paths)),
|
||||||
|
'results': result.get('results', [])
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in bulk delete: {e}", exc_info=True)
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def handle_relink_civitai(request: web.Request, scanner) -> web.Response:
|
||||||
|
"""Handle CivitAI metadata re-linking request by model version ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The aiohttp request
|
||||||
|
scanner: The model scanner instance with cache management methods
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
web.Response: The HTTP response
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_path = data.get('file_path')
|
||||||
|
model_version_id = data.get('model_version_id')
|
||||||
|
|
||||||
|
if not file_path or not model_version_id:
|
||||||
|
return web.json_response({"success": False, "error": "Both file_path and model_version_id are required"}, status=400)
|
||||||
|
|
||||||
|
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||||
|
|
||||||
|
# Check if model metadata exists
|
||||||
|
local_metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||||
|
|
||||||
|
# Create a client for fetching from Civitai
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
try:
|
||||||
|
# Fetch metadata by model version ID
|
||||||
|
civitai_metadata, error = await client.get_model_version_info(model_version_id)
|
||||||
|
if not civitai_metadata:
|
||||||
|
error_msg = error or "Model version not found on CivitAI"
|
||||||
|
return web.json_response({"success": False, "error": error_msg}, status=404)
|
||||||
|
|
||||||
|
# Find the primary model file to get the correct SHA256 hash
|
||||||
|
primary_model_file = None
|
||||||
|
for file in civitai_metadata.get('files', []):
|
||||||
|
if file.get('primary', False) and file.get('type') == 'Model':
|
||||||
|
primary_model_file = file
|
||||||
|
break
|
||||||
|
|
||||||
|
if not primary_model_file or not primary_model_file.get('hashes', {}).get('SHA256'):
|
||||||
|
return web.json_response({"success": False, "error": "No SHA256 hash found in model metadata"}, status=404)
|
||||||
|
|
||||||
|
# Update the SHA256 hash in local metadata (convert to lowercase)
|
||||||
|
local_metadata['sha256'] = primary_model_file['hashes']['SHA256'].lower()
|
||||||
|
|
||||||
|
# Update metadata with CivitAI information
|
||||||
|
await ModelRouteUtils.update_model_metadata(metadata_path, local_metadata, civitai_metadata, client)
|
||||||
|
|
||||||
|
# Update the cache
|
||||||
|
await scanner.update_single_model_cache(file_path, file_path, local_metadata)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Model successfully re-linked to Civitai version {model_version_id}",
|
||||||
|
"hash": local_metadata['sha256']
|
||||||
|
})
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error re-linking to CivitAI: {e}", exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import datetime
|
||||||
|
import shutil
|
||||||
from typing import Dict, Set
|
from typing import Dict, Set
|
||||||
|
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -26,6 +28,7 @@ class UsageStats:
|
|||||||
|
|
||||||
# Default stats file name
|
# Default stats file name
|
||||||
STATS_FILENAME = "lora_manager_stats.json"
|
STATS_FILENAME = "lora_manager_stats.json"
|
||||||
|
BACKUP_SUFFIX = ".backup"
|
||||||
|
|
||||||
def __new__(cls):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
@@ -39,8 +42,8 @@ class UsageStats:
|
|||||||
|
|
||||||
# Initialize stats storage
|
# Initialize stats storage
|
||||||
self.stats = {
|
self.stats = {
|
||||||
"checkpoints": {}, # sha256 -> count
|
"checkpoints": {}, # sha256 -> { total: count, history: { date: count } }
|
||||||
"loras": {}, # sha256 -> count
|
"loras": {}, # sha256 -> { total: count, history: { date: count } }
|
||||||
"total_executions": 0,
|
"total_executions": 0,
|
||||||
"last_save_time": 0
|
"last_save_time": 0
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,68 @@ class UsageStats:
|
|||||||
# Use the first lora root
|
# Use the first lora root
|
||||||
return os.path.join(config.loras_roots[0], self.STATS_FILENAME)
|
return os.path.join(config.loras_roots[0], self.STATS_FILENAME)
|
||||||
|
|
||||||
|
def _backup_old_stats(self):
|
||||||
|
"""Backup the old stats file before conversion"""
|
||||||
|
if os.path.exists(self._stats_file_path):
|
||||||
|
backup_path = f"{self._stats_file_path}{self.BACKUP_SUFFIX}"
|
||||||
|
try:
|
||||||
|
shutil.copy2(self._stats_file_path, backup_path)
|
||||||
|
logger.info(f"Backed up old stats file to {backup_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to backup stats file: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _convert_old_format(self, old_stats):
|
||||||
|
"""Convert old stats format to new format with history"""
|
||||||
|
new_stats = {
|
||||||
|
"checkpoints": {},
|
||||||
|
"loras": {},
|
||||||
|
"total_executions": old_stats.get("total_executions", 0),
|
||||||
|
"last_save_time": old_stats.get("last_save_time", time.time())
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get today's date in YYYY-MM-DD format
|
||||||
|
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Convert checkpoint stats
|
||||||
|
if "checkpoints" in old_stats and isinstance(old_stats["checkpoints"], dict):
|
||||||
|
for hash_id, count in old_stats["checkpoints"].items():
|
||||||
|
new_stats["checkpoints"][hash_id] = {
|
||||||
|
"total": count,
|
||||||
|
"history": {
|
||||||
|
today: count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Convert lora stats
|
||||||
|
if "loras" in old_stats and isinstance(old_stats["loras"], dict):
|
||||||
|
for hash_id, count in old_stats["loras"].items():
|
||||||
|
new_stats["loras"][hash_id] = {
|
||||||
|
"total": count,
|
||||||
|
"history": {
|
||||||
|
today: count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Successfully converted stats from old format to new format with history")
|
||||||
|
return new_stats
|
||||||
|
|
||||||
|
def _is_old_format(self, stats):
|
||||||
|
"""Check if the stats are in the old format (direct count values)"""
|
||||||
|
# Check if any lora or checkpoint entry is a direct number instead of an object
|
||||||
|
if "loras" in stats and isinstance(stats["loras"], dict):
|
||||||
|
for hash_id, data in stats["loras"].items():
|
||||||
|
if isinstance(data, (int, float)):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if "checkpoints" in stats and isinstance(stats["checkpoints"], dict):
|
||||||
|
for hash_id, data in stats["checkpoints"].items():
|
||||||
|
if isinstance(data, (int, float)):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
def _load_stats(self):
|
def _load_stats(self):
|
||||||
"""Load existing statistics from file"""
|
"""Load existing statistics from file"""
|
||||||
try:
|
try:
|
||||||
@@ -77,17 +142,26 @@ class UsageStats:
|
|||||||
with open(self._stats_file_path, 'r', encoding='utf-8') as f:
|
with open(self._stats_file_path, 'r', encoding='utf-8') as f:
|
||||||
loaded_stats = json.load(f)
|
loaded_stats = json.load(f)
|
||||||
|
|
||||||
# Update our stats with loaded data
|
# Check if old format and needs conversion
|
||||||
if isinstance(loaded_stats, dict):
|
if self._is_old_format(loaded_stats):
|
||||||
# Update individual sections to maintain structure
|
logger.info("Detected old stats format, performing conversion")
|
||||||
if "checkpoints" in loaded_stats and isinstance(loaded_stats["checkpoints"], dict):
|
self._backup_old_stats()
|
||||||
self.stats["checkpoints"] = loaded_stats["checkpoints"]
|
self.stats = self._convert_old_format(loaded_stats)
|
||||||
|
else:
|
||||||
|
# Update our stats with loaded data (already in new format)
|
||||||
|
if isinstance(loaded_stats, dict):
|
||||||
|
# Update individual sections to maintain structure
|
||||||
|
if "checkpoints" in loaded_stats and isinstance(loaded_stats["checkpoints"], dict):
|
||||||
|
self.stats["checkpoints"] = loaded_stats["checkpoints"]
|
||||||
|
|
||||||
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
|
if "loras" in loaded_stats and isinstance(loaded_stats["loras"], dict):
|
||||||
self.stats["loras"] = loaded_stats["loras"]
|
self.stats["loras"] = loaded_stats["loras"]
|
||||||
|
|
||||||
if "total_executions" in loaded_stats:
|
if "total_executions" in loaded_stats:
|
||||||
self.stats["total_executions"] = loaded_stats["total_executions"]
|
self.stats["total_executions"] = loaded_stats["total_executions"]
|
||||||
|
|
||||||
|
if "last_save_time" in loaded_stats:
|
||||||
|
self.stats["last_save_time"] = loaded_stats["last_save_time"]
|
||||||
|
|
||||||
logger.info(f"Loaded usage statistics from {self._stats_file_path}")
|
logger.info(f"Loaded usage statistics from {self._stats_file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -174,15 +248,18 @@ class UsageStats:
|
|||||||
# Increment total executions count
|
# Increment total executions count
|
||||||
self.stats["total_executions"] += 1
|
self.stats["total_executions"] += 1
|
||||||
|
|
||||||
|
# Get today's date in YYYY-MM-DD format
|
||||||
|
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Process checkpoints
|
# Process checkpoints
|
||||||
if MODELS in metadata and isinstance(metadata[MODELS], dict):
|
if MODELS in metadata and isinstance(metadata[MODELS], dict):
|
||||||
await self._process_checkpoints(metadata[MODELS])
|
await self._process_checkpoints(metadata[MODELS], today)
|
||||||
|
|
||||||
# Process loras
|
# Process loras
|
||||||
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
if LORAS in metadata and isinstance(metadata[LORAS], dict):
|
||||||
await self._process_loras(metadata[LORAS])
|
await self._process_loras(metadata[LORAS], today)
|
||||||
|
|
||||||
async def _process_checkpoints(self, models_data):
|
async def _process_checkpoints(self, models_data, today_date):
|
||||||
"""Process checkpoint models from metadata"""
|
"""Process checkpoint models from metadata"""
|
||||||
try:
|
try:
|
||||||
# Get checkpoint scanner service
|
# Get checkpoint scanner service
|
||||||
@@ -208,12 +285,24 @@ class UsageStats:
|
|||||||
# Get hash for this checkpoint
|
# Get hash for this checkpoint
|
||||||
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
|
||||||
if model_hash:
|
if model_hash:
|
||||||
# Update stats for this checkpoint
|
# Update stats for this checkpoint with date tracking
|
||||||
self.stats["checkpoints"][model_hash] = self.stats["checkpoints"].get(model_hash, 0) + 1
|
if model_hash not in self.stats["checkpoints"]:
|
||||||
|
self.stats["checkpoints"][model_hash] = {
|
||||||
|
"total": 0,
|
||||||
|
"history": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Increment total count
|
||||||
|
self.stats["checkpoints"][model_hash]["total"] += 1
|
||||||
|
|
||||||
|
# Increment today's count
|
||||||
|
if today_date not in self.stats["checkpoints"][model_hash]["history"]:
|
||||||
|
self.stats["checkpoints"][model_hash]["history"][today_date] = 0
|
||||||
|
self.stats["checkpoints"][model_hash]["history"][today_date] += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
|
logger.error(f"Error processing checkpoint usage: {e}", exc_info=True)
|
||||||
|
|
||||||
async def _process_loras(self, loras_data):
|
async def _process_loras(self, loras_data, today_date):
|
||||||
"""Process LoRA models from metadata"""
|
"""Process LoRA models from metadata"""
|
||||||
try:
|
try:
|
||||||
# Get LoRA scanner service
|
# Get LoRA scanner service
|
||||||
@@ -239,8 +328,20 @@ class UsageStats:
|
|||||||
# Get hash for this LoRA
|
# Get hash for this LoRA
|
||||||
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
|
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
|
||||||
if lora_hash:
|
if lora_hash:
|
||||||
# Update stats for this LoRA
|
# Update stats for this LoRA with date tracking
|
||||||
self.stats["loras"][lora_hash] = self.stats["loras"].get(lora_hash, 0) + 1
|
if lora_hash not in self.stats["loras"]:
|
||||||
|
self.stats["loras"][lora_hash] = {
|
||||||
|
"total": 0,
|
||||||
|
"history": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Increment total count
|
||||||
|
self.stats["loras"][lora_hash]["total"] += 1
|
||||||
|
|
||||||
|
# Increment today's count
|
||||||
|
if today_date not in self.stats["loras"][lora_hash]["history"]:
|
||||||
|
self.stats["loras"][lora_hash]["history"][today_date] = 0
|
||||||
|
self.stats["loras"][lora_hash]["history"][today_date] += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
logger.error(f"Error processing LoRA usage: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -251,9 +352,11 @@ class UsageStats:
|
|||||||
async def get_model_usage_count(self, model_type, sha256):
|
async def get_model_usage_count(self, model_type, sha256):
|
||||||
"""Get usage count for a specific model by hash"""
|
"""Get usage count for a specific model by hash"""
|
||||||
if model_type == "checkpoint":
|
if model_type == "checkpoint":
|
||||||
return self.stats["checkpoints"].get(sha256, 0)
|
if sha256 in self.stats["checkpoints"]:
|
||||||
|
return self.stats["checkpoints"][sha256]["total"]
|
||||||
elif model_type == "lora":
|
elif model_type == "lora":
|
||||||
return self.stats["loras"].get(sha256, 0)
|
if sha256 in self.stats["loras"]:
|
||||||
|
return self.stats["loras"][sha256]["total"]
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
async def process_execution(self, prompt_id):
|
async def process_execution(self, prompt_id):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||||
version = "0.8.16"
|
version = "0.8.17"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
@@ -25,4 +25,4 @@ Repository = "https://github.com/willmiao/ComfyUI-Lora-Manager"
|
|||||||
[tool.comfy]
|
[tool.comfy]
|
||||||
PublisherId = "willmiao"
|
PublisherId = "willmiao"
|
||||||
DisplayName = "ComfyUI-Lora-Manager"
|
DisplayName = "ComfyUI-Lora-Manager"
|
||||||
Icon = ""
|
Icon = "https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/android-chrome-512x512.png?raw=true"
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
from py.routes.checkpoints_routes import CheckpointsRoutes
|
from py.routes.checkpoints_routes import CheckpointsRoutes
|
||||||
from py.routes.update_routes import UpdateRoutes
|
from py.routes.update_routes import UpdateRoutes
|
||||||
from py.routes.misc_routes import MiscRoutes
|
from py.routes.misc_routes import MiscRoutes
|
||||||
|
from py.routes.example_images_routes import ExampleImagesRoutes
|
||||||
|
|
||||||
lora_routes = LoraRoutes()
|
lora_routes = LoraRoutes()
|
||||||
checkpoints_routes = CheckpointsRoutes()
|
checkpoints_routes = CheckpointsRoutes()
|
||||||
@@ -306,6 +307,7 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app)
|
MiscRoutes.setup_routes(app)
|
||||||
|
ExampleImagesRoutes.setup_routes(app)
|
||||||
|
|
||||||
# Schedule service initialization
|
# Schedule service initialization
|
||||||
app.on_startup.append(lambda app: cls._initialize_services())
|
app.on_startup.append(lambda app: cls._initialize_services())
|
||||||
|
|||||||
@@ -32,13 +32,21 @@ html, body {
|
|||||||
--card-bg: #ffffff;
|
--card-bg: #ffffff;
|
||||||
--border-color: #e0e0e0;
|
--border-color: #e0e0e0;
|
||||||
|
|
||||||
/* Color System */
|
/* Color Components */
|
||||||
--lora-accent: oklch(68% 0.28 256);
|
--lora-accent-l: 68%;
|
||||||
|
--lora-accent-c: 0.28;
|
||||||
|
--lora-accent-h: 256;
|
||||||
|
--lora-warning-l: 75%;
|
||||||
|
--lora-warning-c: 0.25;
|
||||||
|
--lora-warning-h: 80;
|
||||||
|
|
||||||
|
/* Composed Colors */
|
||||||
|
--lora-accent: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
--lora-surface: oklch(100% 0 0 / 0.98);
|
--lora-surface: oklch(100% 0 0 / 0.98);
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(95% 0.02 256);
|
--lora-text: oklch(95% 0.02 256);
|
||||||
--lora-error: oklch(75% 0.32 29);
|
--lora-error: oklch(75% 0.32 29);
|
||||||
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
|
--lora-warning: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Modified to be used with oklch() */
|
||||||
|
|
||||||
/* Spacing Scale */
|
/* Spacing Scale */
|
||||||
--space-1: calc(8px * 1);
|
--space-1: calc(8px * 1);
|
||||||
|
|||||||
@@ -60,6 +60,18 @@
|
|||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Danger button style - updated to use proper theme variables */
|
||||||
|
.bulk-operations-actions button.danger-btn {
|
||||||
|
background: oklch(70% 0.2 29); /* Light red background that works in both themes */
|
||||||
|
color: oklch(98% 0.01 0); /* Almost white text for good contrast */
|
||||||
|
border-color: var(--lora-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-operations-actions button.danger-btn:hover {
|
||||||
|
background: var(--lora-error);
|
||||||
|
color: oklch(100% 0 0); /* Pure white text on hover for maximum contrast */
|
||||||
|
}
|
||||||
|
|
||||||
/* Style for selected cards */
|
/* Style for selected cards */
|
||||||
.lora-card.selected {
|
.lora-card.selected {
|
||||||
box-shadow: 0 0 0 2px var(--lora-accent);
|
box-shadow: 0 0 0 2px var(--lora-accent);
|
||||||
@@ -262,83 +274,6 @@
|
|||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* NSFW Level Selector */
|
|
||||||
.nsfw-level-selector {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
background: var(--card-bg);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
padding: 16px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
|
||||||
z-index: var(--z-modal);
|
|
||||||
width: 300px;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nsfw-level-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nsfw-level-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-nsfw-selector {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-color);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-nsfw-selector:hover {
|
|
||||||
background: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-level {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--bg-color);
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nsfw-level-options {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nsfw-level-btn {
|
|
||||||
flex: 1 0 calc(33% - 8px);
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: var(--border-radius-xs);
|
|
||||||
background: var(--bg-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nsfw-level-btn:hover {
|
|
||||||
background: var(--lora-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nsfw-level-btn.active {
|
|
||||||
background: var(--lora-accent);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--lora-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile optimizations */
|
/* Mobile optimizations */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.selected-thumbnails-strip {
|
.selected-thumbnails-strip {
|
||||||
|
|||||||
@@ -2,25 +2,28 @@
|
|||||||
|
|
||||||
/* Duplicates banner */
|
/* Duplicates banner */
|
||||||
.duplicates-banner {
|
.duplicates-banner {
|
||||||
position: relative; /* Changed from sticky to relative */
|
position: sticky; /* Keep the sticky position */
|
||||||
|
top: var(--space-1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: var(--card-bg);
|
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1); /* Use accent color with low opacity */
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-top: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); /* Add top border with accent color */
|
||||||
|
border-bottom: 1px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.4); /* Make bottom border stronger */
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
padding: 12px 0; /* Removed horizontal padding */
|
padding: 12px 0;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
margin-bottom: 20px; /* Add margin to create space below the banner */
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner .banner-content {
|
.duplicates-banner .banner-content {
|
||||||
max-width: 1400px; /* Match the container max-width */
|
position: relative;
|
||||||
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0 16px; /* Move horizontal padding to the content */
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive container for larger screens - match container in layout.css */
|
/* Responsive container for larger screens - match container in layout.css */
|
||||||
@@ -38,7 +41,7 @@
|
|||||||
|
|
||||||
.duplicates-banner i.fa-exclamation-triangle {
|
.duplicates-banner i.fa-exclamation-triangle {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: oklch(var(--lora-warning));
|
color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner .banner-actions {
|
.duplicates-banner .banner-actions {
|
||||||
@@ -48,6 +51,29 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Improved exit button in banner */
|
||||||
|
.duplicates-banner button.btn-exit-mode {
|
||||||
|
min-width: 120px;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicates-banner button.btn-exit-mode:hover {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.duplicates-banner button {
|
.duplicates-banner button {
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -66,7 +92,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duplicates-banner button:hover {
|
.duplicates-banner button:hover {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||||
@@ -91,23 +117,42 @@
|
|||||||
/* Duplicate groups */
|
/* Duplicate groups */
|
||||||
.duplicate-group {
|
.duplicate-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 2px solid oklch(var(--lora-warning));
|
border: 2px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); /* Add subtle shadow to groups */
|
||||||
|
/* Add responsive width settings to match banner */
|
||||||
|
max-width: 1400px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add responsive container adjustments for duplicate groups - match container in banner */
|
||||||
|
@media (min-width: 2000px) {
|
||||||
|
.duplicate-group {
|
||||||
|
max-width: 1800px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 3000px) {
|
||||||
|
.duplicate-group {
|
||||||
|
max-width: 2400px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-group-header {
|
.duplicate-group-header {
|
||||||
background-color: var(--bg-color);
|
background-color: var(--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: 8px 16px;
|
padding: 10px 16px; /* Slightly increased padding */
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
border-left: 4px solid oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h)); /* Add accent border on the left */
|
||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-group-header span:last-child {
|
.duplicate-group-header span:last-child {
|
||||||
@@ -135,7 +180,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.duplicate-group-header button:hover {
|
.duplicate-group-header button:hover {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||||
@@ -190,7 +235,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-toggle-btn:hover {
|
.group-toggle-btn:hover {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var (--lora-accent-h);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
@@ -202,16 +247,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.lora-card.duplicate:hover {
|
.lora-card.duplicate:hover {
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lora-card.duplicate.latest {
|
.lora-card.duplicate.latest {
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: oklch(var(--lora-warning));
|
border-color: oklch(var(--lora-warning-l) var(--lora-warning-c) var(--lora-warning-h));
|
||||||
}
|
}
|
||||||
|
|
||||||
.lora-card.duplicate-selected {
|
.lora-card.duplicate-selected {
|
||||||
border: 2px solid oklch(var(--lora-accent));
|
border: 2px solid oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +276,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
background: oklch(var(--lora-accent));
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
@@ -239,6 +284,128 @@
|
|||||||
z-index: 5;
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Model tooltip for duplicates mode */
|
||||||
|
.model-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
|
padding: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 350px;
|
||||||
|
min-width: 250px;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9em;
|
||||||
|
pointer-events: none; /* Don't block mouse events */
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tooltip .tooltip-header {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tooltip .tooltip-info div {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tooltip .tooltip-info div strong {
|
||||||
|
margin-right: 5px;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge Styles */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 16px; /* Reduced from 20px */
|
||||||
|
height: 16px; /* Reduced from 20px */
|
||||||
|
border-radius: 8px; /* Adjusted for smaller size */
|
||||||
|
background-color: var(--lora-error);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px; /* Smaller font size */
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0 4px; /* Reduced padding */
|
||||||
|
position: absolute;
|
||||||
|
top: -8px; /* Moved closer to button */
|
||||||
|
right: -8px; /* Moved closer to button */
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); /* Softer shadow */
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the pulse animation more subtle */
|
||||||
|
.badge.pulse {
|
||||||
|
animation: badge-pulse 2s infinite; /* Slower animation */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes badge-pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1); /* Less expansion */
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help icon styling */
|
||||||
|
.help-icon {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: help;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-left: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Help tooltip */
|
||||||
|
.help-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: var(--z-overlay);
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: left;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-tooltip:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: 10px; /* Position the arrow near the left instead of center */
|
||||||
|
border-width: 0 8px 8px 8px;
|
||||||
|
border-style: solid;
|
||||||
|
border-color: transparent transparent var(--card-bg) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive adjustments */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.duplicates-banner .banner-content {
|
.duplicates-banner .banner-content {
|
||||||
@@ -269,4 +436,50 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-tooltip {
|
||||||
|
max-width: calc(100% - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove the fixed positioning adjustments for mobile since we're now using dynamic positioning */
|
||||||
|
.help-tooltip:after {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In dark mode, add additional distinction */
|
||||||
|
html[data-theme="dark"] .duplicates-banner {
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.4); /* Stronger shadow in dark mode */
|
||||||
|
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15); /* Slightly stronger background in dark mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .duplicate-group {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); /* Stronger shadow in dark mode */
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .help-tooltip {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles for disabled controls during duplicates mode */
|
||||||
|
.disabled-during-duplicates {
|
||||||
|
opacity: 0.5 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
user-select: none !important;
|
||||||
|
filter: grayscale(50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make the active duplicates button more prominent */
|
||||||
|
#findDuplicatesBtn.active {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#findDuplicatesBtn.active:hover {
|
||||||
|
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,30 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Badge styling */
|
||||||
|
.update-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--lora-error);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--card-bg);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-badge.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-badge.hidden,
|
||||||
|
.update-badge:not(.visible) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile adjustments */
|
/* Mobile adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-title {
|
.app-title {
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.scroll-indicator:hover {
|
.scroll-indicator:hover {
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
|
|
||||||
/* Keep the hover effect using accent color */
|
/* Keep the hover effect using accent color */
|
||||||
.trigger-word-tag:hover {
|
.trigger-word-tag:hover {
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trigger-words-edit-controls button:hover {
|
.trigger-words-edit-controls button:hover {
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +324,7 @@
|
|||||||
margin-top: var(--space-2);
|
margin-top: var(--space-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
|
position: relative; /* Added for dropdown positioning */
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-trigger-word-input {
|
.new-trigger-word-input {
|
||||||
@@ -346,7 +347,7 @@
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background: var(--bg-color);
|
background: var (--bg-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -371,6 +372,146 @@
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Trained Words Loading Indicator */
|
||||||
|
.trained-words-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9em;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-words-loading i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Trained Words Dropdown Styles */
|
||||||
|
.trained-words-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
margin-top: 4px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-words-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-words-header span {
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-words-header small {
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-words-container {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-word-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--lora-border);
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-word-item:hover {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-word-item.already-added {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-word-item.already-added:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border-color: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-word-text {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
font-size: 0.9em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-right: 4px;
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-word-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trained-word-freq {
|
||||||
|
color: var (--text-color);
|
||||||
|
font-size: 0.75em;
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 20px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .trained-word-freq {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.added-indicator {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-trained-words {
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Editable Fields */
|
/* Editable Fields */
|
||||||
.editable-field {
|
.editable-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -515,7 +656,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.preset-tag:hover {
|
.preset-tag:hover {
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,10 +690,6 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-name-wrapper:hover {
|
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-name-content {
|
.file-name-content {
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
@@ -749,7 +886,7 @@
|
|||||||
|
|
||||||
.tab-btn:hover {
|
.tab-btn:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: oklch(var(--lora-accent) / 0.05);
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn.active {
|
.tab-btn.active {
|
||||||
@@ -931,7 +1068,7 @@
|
|||||||
.model-description-content pre {
|
.model-description-content pre {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgba(0, 0, 0, 0.05);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: var(--space-1);
|
padding: var (--space-1);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -1373,6 +1510,34 @@
|
|||||||
|
|
||||||
/* Optional: add hover effect for creator info */
|
/* Optional: add hover effect for creator info */
|
||||||
.creator-info:hover {
|
.creator-info:hover {
|
||||||
background: oklch(var(--lora-accent) / 0.1);
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Class tokens styling */
|
||||||
|
.class-tokens-container {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-token-item {
|
||||||
|
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1) !important;
|
||||||
|
border: 1px solid var(--lora-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-badge {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7em;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-separator {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--lora-border);
|
||||||
|
margin: 5px 10px;
|
||||||
|
}
|
||||||
@@ -40,3 +40,80 @@
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NSFW Level Selector */
|
||||||
|
.nsfw-level-selector {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: var(--z-modal);
|
||||||
|
width: 300px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-level-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-level-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-nsfw-selector {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-nsfw-selector:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-level {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-level-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-level-btn {
|
||||||
|
flex: 1 0 calc(33% - 8px);
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-level-btn:hover {
|
||||||
|
background: var(--lora-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nsfw-level-btn.active {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
@@ -110,7 +110,7 @@ body.modal-open {
|
|||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cancel-btn, .delete-btn, .exclude-btn {
|
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
|
||||||
padding: 8px var(--space-2);
|
padding: 8px var(--space-2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -131,7 +131,7 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Style for exclude button - different from delete button */
|
/* Style for exclude button - different from delete button */
|
||||||
.exclude-btn {
|
.exclude-btn, .confirm-btn {
|
||||||
background: var(--lora-accent, #4f46e5);
|
background: var(--lora-accent, #4f46e5);
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ body.modal-open {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.exclude-btn:hover {
|
.exclude-btn:hover, .confirm-btn:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
|
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
|
||||||
}
|
}
|
||||||
@@ -306,6 +306,18 @@ body.modal-open {
|
|||||||
width: 100%; /* Full width */
|
width: 100%; /* Full width */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Migrate control styling */
|
||||||
|
.migrate-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.migrate-control input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* 统一各个 section 的样式 */
|
/* 统一各个 section 的样式 */
|
||||||
.support-section,
|
.support-section,
|
||||||
.changelog-section,
|
.changelog-section,
|
||||||
@@ -363,6 +375,12 @@ body.modal-open {
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Add disabled style for setting items */
|
||||||
|
.setting-item[data-requires-centralized="true"].disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Control row with label and input together */
|
/* Control row with label and input together */
|
||||||
.setting-row {
|
.setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -526,7 +544,7 @@ input:checked + .toggle-slider:before {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background-color: var(--card-bg);
|
background-color: var(--card-bg);
|
||||||
color: var(--text-color);
|
color: var (--text-color);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-sm);
|
border-radius: var(--border-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -554,6 +572,13 @@ input:checked + .toggle-slider:before {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.restart-required-icon {
|
||||||
|
color: var(--lora-warning);
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark theme specific button adjustments */
|
/* Dark theme specific button adjustments */
|
||||||
[data-theme="dark"] .primary-btn:hover {
|
[data-theme="dark"] .primary-btn:hover {
|
||||||
background-color: oklch(from var(--lora-accent) l c h / 75%);
|
background-color: oklch(from var(--lora-accent) l c h / 75%);
|
||||||
@@ -694,3 +719,257 @@ input:checked + .toggle-slider:before {
|
|||||||
.density-description li {
|
.density-description li {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Help Modal styles */
|
||||||
|
.help-modal {
|
||||||
|
max-width: 850px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-help-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-right: var(--space-2);
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab navigation styles */
|
||||||
|
.help-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--lora-border);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
border-bottom: 2px solid var(--lora-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab content styles */
|
||||||
|
.help-content {
|
||||||
|
padding: var(--space-1) 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video embed styles */
|
||||||
|
.video-embed {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-embed iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-embed.small {
|
||||||
|
max-width: 100%;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin: var(--space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Documentation link styles */
|
||||||
|
.docs-section {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-section h4 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-links {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-links li {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-links li:before {
|
||||||
|
content: "•";
|
||||||
|
position: absolute;
|
||||||
|
left: -15px;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-links a {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update video list styles */
|
||||||
|
.video-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
padding: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info h4 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info p {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] .tab-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update date badge styles */
|
||||||
|
.update-date-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: var(--lora-accent);
|
||||||
|
color: var(--lora-text);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-left: 10px;
|
||||||
|
vertical-align: middle;
|
||||||
|
animation: fadeIn 0.5s ease-in-out;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-date-badge i {
|
||||||
|
margin-right: 5px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-5px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
[data-theme="dark"] .update-date-badge {
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Re-link to Civitai Modal styles */
|
||||||
|
.warning-box {
|
||||||
|
background-color: rgba(255, 193, 7, 0.1);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.5);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box i {
|
||||||
|
color: var(--lora-warning);
|
||||||
|
margin-right: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: var(--space-1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-box li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--lora-surface);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
color: var(--lora-error);
|
||||||
|
font-size: 0.9em;
|
||||||
|
min-height: 20px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .warning-box {
|
||||||
|
background-color: rgba(255, 193, 7, 0.05);
|
||||||
|
border-color: rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
@@ -434,6 +434,8 @@ export function replaceModelPreview(filePath, modelType = 'lora') {
|
|||||||
// Delete a model (generic)
|
// Delete a model (generic)
|
||||||
export async function deleteModel(filePath, modelType = 'lora') {
|
export async function deleteModel(filePath, modelType = 'lora') {
|
||||||
try {
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading(`Deleting ${modelType}...`);
|
||||||
|
|
||||||
const endpoint = modelType === 'checkpoint'
|
const endpoint = modelType === 'checkpoint'
|
||||||
? '/api/checkpoints/delete'
|
? '/api/checkpoints/delete'
|
||||||
: '/api/delete_model';
|
: '/api/delete_model';
|
||||||
@@ -475,6 +477,8 @@ export async function deleteModel(filePath, modelType = 'lora') {
|
|||||||
console.error(`Error deleting ${modelType}:`, error);
|
console.error(`Error deleting ${modelType}:`, error);
|
||||||
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
|
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,6 +666,8 @@ export async function refreshSingleModelMetadata(filePath, modelType = 'lora') {
|
|||||||
// Generic function to exclude a model
|
// Generic function to exclude a model
|
||||||
export async function excludeModel(filePath, modelType = 'lora') {
|
export async function excludeModel(filePath, modelType = 'lora') {
|
||||||
try {
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading(`Excluding ${modelType}...`);
|
||||||
|
|
||||||
const endpoint = modelType === 'checkpoint'
|
const endpoint = modelType === 'checkpoint'
|
||||||
? '/api/checkpoints/exclude'
|
? '/api/checkpoints/exclude'
|
||||||
: '/api/loras/exclude';
|
: '/api/loras/exclude';
|
||||||
@@ -703,6 +709,8 @@ export async function excludeModel(filePath, modelType = 'lora') {
|
|||||||
console.error(`Error excluding ${modelType}:`, error);
|
console.error(`Error excluding ${modelType}:`, error);
|
||||||
showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error');
|
showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error');
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createPageControls } from './components/controls/index.js';
|
|||||||
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
||||||
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
|
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
|
||||||
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
|
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
|
||||||
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
|
|
||||||
// Initialize the Checkpoints page
|
// Initialize the Checkpoints page
|
||||||
class CheckpointsPageManager {
|
class CheckpointsPageManager {
|
||||||
@@ -14,6 +15,9 @@ class CheckpointsPageManager {
|
|||||||
// Initialize checkpoint download manager
|
// Initialize checkpoint download manager
|
||||||
window.checkpointDownloadManager = new CheckpointDownloadManager();
|
window.checkpointDownloadManager = new CheckpointDownloadManager();
|
||||||
|
|
||||||
|
// Initialize the ModelDuplicatesManager
|
||||||
|
this.duplicatesManager = new ModelDuplicatesManager(this, 'checkpoints');
|
||||||
|
|
||||||
// Expose only necessary functions to global scope
|
// Expose only necessary functions to global scope
|
||||||
this._exposeRequiredGlobalFunctions();
|
this._exposeRequiredGlobalFunctions();
|
||||||
}
|
}
|
||||||
@@ -29,6 +33,9 @@ class CheckpointsPageManager {
|
|||||||
window.checkpointManager = {
|
window.checkpointManager = {
|
||||||
loadCheckpoints: (reset) => loadMoreCheckpoints(reset)
|
loadCheckpoints: (reset) => loadMoreCheckpoints(reset)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Expose duplicates manager
|
||||||
|
window.modelDuplicatesManager = this.duplicatesManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
import { refreshSingleLoraMetadata } from '../api/loraApi.js';
|
|
||||||
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
|
|
||||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
|
||||||
import { getStorageItem } from '../utils/storageHelpers.js';
|
|
||||||
|
|
||||||
export class LoraContextMenu {
|
|
||||||
constructor() {
|
|
||||||
this.menu = document.getElementById('loraContextMenu');
|
|
||||||
this.currentCard = null;
|
|
||||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
document.addEventListener('click', () => this.hideMenu());
|
|
||||||
document.addEventListener('contextmenu', (e) => {
|
|
||||||
const card = e.target.closest('.lora-card');
|
|
||||||
if (!card) {
|
|
||||||
this.hideMenu();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
|
||||||
this.showMenu(e.clientX, e.clientY, card);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.menu.addEventListener('click', (e) => {
|
|
||||||
const menuItem = e.target.closest('.context-menu-item');
|
|
||||||
if (!menuItem || !this.currentCard) return;
|
|
||||||
|
|
||||||
const action = menuItem.dataset.action;
|
|
||||||
if (!action) return;
|
|
||||||
|
|
||||||
switch(action) {
|
|
||||||
case 'detail':
|
|
||||||
// Trigger the main card click which shows the modal
|
|
||||||
this.currentCard.click();
|
|
||||||
break;
|
|
||||||
case 'civitai':
|
|
||||||
// Only trigger if the card is from civitai
|
|
||||||
if (this.currentCard.dataset.from_civitai === 'true') {
|
|
||||||
if (this.currentCard.dataset.meta === '{}') {
|
|
||||||
showToast('Please fetch metadata from CivitAI first', 'info');
|
|
||||||
} else {
|
|
||||||
this.currentCard.querySelector('.fa-globe')?.click();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showToast('No CivitAI information available', 'info');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'copyname':
|
|
||||||
this.currentCard.querySelector('.fa-copy')?.click();
|
|
||||||
break;
|
|
||||||
case 'preview':
|
|
||||||
this.currentCard.querySelector('.fa-image')?.click();
|
|
||||||
break;
|
|
||||||
case 'delete':
|
|
||||||
this.currentCard.querySelector('.fa-trash')?.click();
|
|
||||||
break;
|
|
||||||
case 'move':
|
|
||||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
|
||||||
break;
|
|
||||||
case 'refresh-metadata':
|
|
||||||
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
|
|
||||||
break;
|
|
||||||
case 'set-nsfw':
|
|
||||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hideMenu();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize NSFW Level Selector events
|
|
||||||
this.initNSFWSelector();
|
|
||||||
}
|
|
||||||
|
|
||||||
initNSFWSelector() {
|
|
||||||
// Close button
|
|
||||||
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
|
|
||||||
closeBtn.addEventListener('click', () => {
|
|
||||||
this.nsfwSelector.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Level buttons
|
|
||||||
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
|
|
||||||
levelButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const level = parseInt(btn.dataset.level);
|
|
||||||
const filePath = this.nsfwSelector.dataset.cardPath;
|
|
||||||
|
|
||||||
if (!filePath) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
|
||||||
|
|
||||||
// Update card data
|
|
||||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
|
||||||
if (card) {
|
|
||||||
let metaData = {};
|
|
||||||
try {
|
|
||||||
metaData = JSON.parse(card.dataset.meta || '{}');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error parsing metadata:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
metaData.preview_nsfw_level = level;
|
|
||||||
card.dataset.meta = JSON.stringify(metaData);
|
|
||||||
card.dataset.nsfwLevel = level.toString();
|
|
||||||
|
|
||||||
// Apply blur effect immediately
|
|
||||||
this.updateCardBlurEffect(card, level);
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
|
|
||||||
this.nsfwSelector.style.display = 'none';
|
|
||||||
} catch (error) {
|
|
||||||
showToast(`Failed to set content rating: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (this.nsfwSelector.style.display === 'block' &&
|
|
||||||
!this.nsfwSelector.contains(e.target) &&
|
|
||||||
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
|
|
||||||
this.nsfwSelector.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveModelMetadata(filePath, data) {
|
|
||||||
const response = await fetch('/api/loras/save-metadata', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
file_path: filePath,
|
|
||||||
...data
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to save metadata');
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCardBlurEffect(card, level) {
|
|
||||||
// Get user settings for blur threshold
|
|
||||||
const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4');
|
|
||||||
|
|
||||||
// Get card preview container
|
|
||||||
const previewContainer = card.querySelector('.card-preview');
|
|
||||||
if (!previewContainer) return;
|
|
||||||
|
|
||||||
// Get preview media element
|
|
||||||
const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video');
|
|
||||||
if (!previewMedia) return;
|
|
||||||
|
|
||||||
// Check if blur should be applied
|
|
||||||
if (level >= blurThreshold) {
|
|
||||||
// Add blur class to the preview container
|
|
||||||
previewContainer.classList.add('blurred');
|
|
||||||
|
|
||||||
// Get or create the NSFW overlay
|
|
||||||
let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay');
|
|
||||||
if (!nsfwOverlay) {
|
|
||||||
// Create new overlay
|
|
||||||
nsfwOverlay = document.createElement('div');
|
|
||||||
nsfwOverlay.className = 'nsfw-overlay';
|
|
||||||
|
|
||||||
// Create and configure the warning content
|
|
||||||
const warningContent = document.createElement('div');
|
|
||||||
warningContent.className = 'nsfw-warning';
|
|
||||||
|
|
||||||
// Determine NSFW warning text based on level
|
|
||||||
let nsfwText = "Mature Content";
|
|
||||||
if (level >= NSFW_LEVELS.XXX) {
|
|
||||||
nsfwText = "XXX-rated Content";
|
|
||||||
} else if (level >= NSFW_LEVELS.X) {
|
|
||||||
nsfwText = "X-rated Content";
|
|
||||||
} else if (level >= NSFW_LEVELS.R) {
|
|
||||||
nsfwText = "R-rated Content";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add warning text and show button
|
|
||||||
warningContent.innerHTML = `
|
|
||||||
<p>${nsfwText}</p>
|
|
||||||
<button class="show-content-btn">Show</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add click event to the show button
|
|
||||||
const showBtn = warningContent.querySelector('.show-content-btn');
|
|
||||||
showBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
previewContainer.classList.remove('blurred');
|
|
||||||
nsfwOverlay.style.display = 'none';
|
|
||||||
|
|
||||||
// Update toggle button icon if it exists
|
|
||||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nsfwOverlay.appendChild(warningContent);
|
|
||||||
previewContainer.appendChild(nsfwOverlay);
|
|
||||||
} else {
|
|
||||||
// Update existing overlay
|
|
||||||
const warningText = nsfwOverlay.querySelector('p');
|
|
||||||
if (warningText) {
|
|
||||||
let nsfwText = "Mature Content";
|
|
||||||
if (level >= NSFW_LEVELS.XXX) {
|
|
||||||
nsfwText = "XXX-rated Content";
|
|
||||||
} else if (level >= NSFW_LEVELS.X) {
|
|
||||||
nsfwText = "X-rated Content";
|
|
||||||
} else if (level >= NSFW_LEVELS.R) {
|
|
||||||
nsfwText = "R-rated Content";
|
|
||||||
}
|
|
||||||
warningText.textContent = nsfwText;
|
|
||||||
}
|
|
||||||
nsfwOverlay.style.display = 'flex';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create the toggle button in the header
|
|
||||||
const cardHeader = previewContainer.querySelector('.card-header');
|
|
||||||
if (cardHeader) {
|
|
||||||
let toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
|
|
||||||
|
|
||||||
if (!toggleBtn) {
|
|
||||||
toggleBtn = document.createElement('button');
|
|
||||||
toggleBtn.className = 'toggle-blur-btn';
|
|
||||||
toggleBtn.title = 'Toggle blur';
|
|
||||||
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
|
|
||||||
|
|
||||||
// Add click event to toggle button
|
|
||||||
toggleBtn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const isBlurred = previewContainer.classList.toggle('blurred');
|
|
||||||
const icon = toggleBtn.querySelector('i');
|
|
||||||
|
|
||||||
// Update icon and overlay visibility
|
|
||||||
if (isBlurred) {
|
|
||||||
icon.className = 'fas fa-eye';
|
|
||||||
nsfwOverlay.style.display = 'flex';
|
|
||||||
} else {
|
|
||||||
icon.className = 'fas fa-eye-slash';
|
|
||||||
nsfwOverlay.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add to the beginning of header
|
|
||||||
cardHeader.insertBefore(toggleBtn, cardHeader.firstChild);
|
|
||||||
|
|
||||||
// Update base model label class
|
|
||||||
const baseModelLabel = cardHeader.querySelector('.base-model-label');
|
|
||||||
if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) {
|
|
||||||
baseModelLabel.classList.add('with-toggle');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Update existing toggle button
|
|
||||||
toggleBtn.querySelector('i').className = 'fas fa-eye';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remove blur
|
|
||||||
previewContainer.classList.remove('blurred');
|
|
||||||
|
|
||||||
// Hide overlay if it exists
|
|
||||||
const overlay = previewContainer.querySelector('.nsfw-overlay');
|
|
||||||
if (overlay) overlay.style.display = 'none';
|
|
||||||
|
|
||||||
// Update or remove toggle button
|
|
||||||
const toggleBtn = card.querySelector('.toggle-blur-btn');
|
|
||||||
if (toggleBtn) {
|
|
||||||
// We'll leave the button but update the icon
|
|
||||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showNSFWLevelSelector(x, y, card) {
|
|
||||||
const selector = document.getElementById('nsfwLevelSelector');
|
|
||||||
const currentLevelEl = document.getElementById('currentNSFWLevel');
|
|
||||||
|
|
||||||
// Get current NSFW level
|
|
||||||
let currentLevel = 0;
|
|
||||||
try {
|
|
||||||
const metaData = JSON.parse(card.dataset.meta || '{}');
|
|
||||||
currentLevel = metaData.preview_nsfw_level || 0;
|
|
||||||
|
|
||||||
// Update if we have no recorded level but have a dataset attribute
|
|
||||||
if (!currentLevel && card.dataset.nsfwLevel) {
|
|
||||||
currentLevel = parseInt(card.dataset.nsfwLevel) || 0;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error parsing metadata:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentLevelEl.textContent = getNSFWLevelName(currentLevel);
|
|
||||||
|
|
||||||
// Position the selector
|
|
||||||
if (x && y) {
|
|
||||||
const viewportWidth = document.documentElement.clientWidth;
|
|
||||||
const viewportHeight = document.documentElement.clientHeight;
|
|
||||||
const selectorRect = selector.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Center the selector if no coordinates provided
|
|
||||||
let finalX = (viewportWidth - selectorRect.width) / 2;
|
|
||||||
let finalY = (viewportHeight - selectorRect.height) / 2;
|
|
||||||
|
|
||||||
selector.style.left = `${finalX}px`;
|
|
||||||
selector.style.top = `${finalY}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlight current level button
|
|
||||||
document.querySelectorAll('.nsfw-level-btn').forEach(btn => {
|
|
||||||
if (parseInt(btn.dataset.level) === currentLevel) {
|
|
||||||
btn.classList.add('active');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store reference to current card
|
|
||||||
selector.dataset.cardPath = card.dataset.filepath;
|
|
||||||
|
|
||||||
// Show selector
|
|
||||||
selector.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
|
||||||
this.currentCard = card;
|
|
||||||
this.menu.style.display = 'block';
|
|
||||||
|
|
||||||
// 获取菜单尺寸
|
|
||||||
const menuRect = this.menu.getBoundingClientRect();
|
|
||||||
|
|
||||||
// 获取视口尺寸
|
|
||||||
const viewportWidth = document.documentElement.clientWidth;
|
|
||||||
const viewportHeight = document.documentElement.clientHeight;
|
|
||||||
|
|
||||||
// 计算最终位置 - 使用 clientX/Y,不需要考虑滚动偏移
|
|
||||||
let finalX = x;
|
|
||||||
let finalY = y;
|
|
||||||
|
|
||||||
// 确保菜单不会超出右侧边界
|
|
||||||
if (x + menuRect.width > viewportWidth) {
|
|
||||||
finalX = x - menuRect.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确保菜单不会超出底部边界
|
|
||||||
if (y + menuRect.height > viewportHeight) {
|
|
||||||
finalY = y - menuRect.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接设置位置,因为 position: fixed 是相对于视口定位的
|
|
||||||
this.menu.style.left = `${finalX}px`;
|
|
||||||
this.menu.style.top = `${finalY}px`;
|
|
||||||
}
|
|
||||||
|
|
||||||
hideMenu() {
|
|
||||||
this.menu.style.display = 'none';
|
|
||||||
this.currentCard = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For backward compatibility, re-export the LoraContextMenu class
|
|
||||||
// export { LoraContextMenu } from './ContextMenu/LoraContextMenu.js';
|
|
||||||
@@ -4,6 +4,9 @@ import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../util
|
|||||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||||
import { showExcludeModal } from '../../utils/modalUtils.js';
|
import { showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
import { resetAndReload } from '../../api/checkpointApi.js';
|
||||||
|
|
||||||
export class CheckpointContextMenu extends BaseContextMenu {
|
export class CheckpointContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -56,6 +59,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
// Refresh metadata from CivitAI
|
// Refresh metadata from CivitAI
|
||||||
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath);
|
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'relink-civitai':
|
||||||
|
// Handle re-link to Civitai
|
||||||
|
this.showRelinkCivitaiModal();
|
||||||
|
break;
|
||||||
case 'set-nsfw':
|
case 'set-nsfw':
|
||||||
// Set NSFW level
|
// Set NSFW level
|
||||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||||
@@ -319,4 +326,87 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
// Show selector
|
// Show selector
|
||||||
selector.style.display = 'block';
|
selector.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showRelinkCivitaiModal() {
|
||||||
|
const filePath = this.currentCard.dataset.filepath;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
// Set up confirm button handler
|
||||||
|
const confirmBtn = document.getElementById('confirmRelinkBtn');
|
||||||
|
const urlInput = document.getElementById('civitaiModelUrl');
|
||||||
|
const errorDiv = document.getElementById('civitaiModelUrlError');
|
||||||
|
|
||||||
|
// Remove previous event listener if exists
|
||||||
|
if (this._boundRelinkHandler) {
|
||||||
|
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new bound handler
|
||||||
|
this._boundRelinkHandler = async () => {
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
const modelVersionId = this.extractModelVersionId(url);
|
||||||
|
|
||||||
|
if (!modelVersionId) {
|
||||||
|
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
modalManager.closeModal('relinkCivitaiModal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
|
||||||
|
|
||||||
|
const response = await fetch('/api/checkpoints/relink-civitai', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_path: filePath,
|
||||||
|
model_version_id: modelVersionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to re-link model: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Model successfully re-linked to Civitai', 'success');
|
||||||
|
// Reload the current view to show updated data
|
||||||
|
await resetAndReload();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to re-link model');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error re-linking model:', error);
|
||||||
|
showToast(`Error: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set new event listener
|
||||||
|
confirmBtn.addEventListener('click', this._boundRelinkHandler);
|
||||||
|
|
||||||
|
// Clear previous input
|
||||||
|
urlInput.value = '';
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modalManager.showModal('relinkCivitaiModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
extractModelVersionId(url) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
||||||
|
return modelVersionId;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview } from '../../api/loraApi.js';
|
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview, resetAndReload } from '../../api/loraApi.js';
|
||||||
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
import { getStorageItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
export class LoraContextMenu extends BaseContextMenu {
|
export class LoraContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -64,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
case 'refresh-metadata':
|
case 'refresh-metadata':
|
||||||
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
|
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'relink-civitai':
|
||||||
|
this.showRelinkCivitaiModal();
|
||||||
|
break;
|
||||||
case 'set-nsfw':
|
case 'set-nsfw':
|
||||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||||
break;
|
break;
|
||||||
@@ -93,6 +98,90 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
sendLoraToWorkflow(loraSyntax, replaceMode, 'lora');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New method to handle re-link to Civitai
|
||||||
|
showRelinkCivitaiModal() {
|
||||||
|
const filePath = this.currentCard.dataset.filepath;
|
||||||
|
if (!filePath) return;
|
||||||
|
|
||||||
|
// Set up confirm button handler
|
||||||
|
const confirmBtn = document.getElementById('confirmRelinkBtn');
|
||||||
|
const urlInput = document.getElementById('civitaiModelUrl');
|
||||||
|
const errorDiv = document.getElementById('civitaiModelUrlError');
|
||||||
|
|
||||||
|
// Remove previous event listener if exists
|
||||||
|
if (this._boundRelinkHandler) {
|
||||||
|
confirmBtn.removeEventListener('click', this._boundRelinkHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new bound handler
|
||||||
|
this._boundRelinkHandler = async () => {
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
const modelVersionId = this.extractModelVersionId(url);
|
||||||
|
|
||||||
|
if (!modelVersionId) {
|
||||||
|
errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
modalManager.closeModal('relinkCivitaiModal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
|
||||||
|
|
||||||
|
const response = await fetch('/api/relink-civitai', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_path: filePath,
|
||||||
|
model_version_id: modelVersionId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to re-link model: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Model successfully re-linked to Civitai', 'success');
|
||||||
|
// Reload the current view to show updated data
|
||||||
|
await resetAndReload();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to re-link model');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error re-linking model:', error);
|
||||||
|
showToast(`Error: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set new event listener
|
||||||
|
confirmBtn.addEventListener('click', this._boundRelinkHandler);
|
||||||
|
|
||||||
|
// Clear previous input
|
||||||
|
urlInput.value = '';
|
||||||
|
errorDiv.textContent = '';
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modalManager.showModal('relinkCivitaiModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
extractModelVersionId(url) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
||||||
|
return modelVersionId;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NSFW Selector methods from the original context menu
|
// NSFW Selector methods from the original context menu
|
||||||
initNSFWSelector() {
|
initNSFWSelector() {
|
||||||
// Close button
|
// Close button
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { RecipeCard } from './RecipeCard.js';
|
import { RecipeCard } from './RecipeCard.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
|
||||||
|
|
||||||
export class DuplicatesManager {
|
export class DuplicatesManager {
|
||||||
constructor(recipeManager) {
|
constructor(recipeManager) {
|
||||||
@@ -14,8 +13,6 @@ export class DuplicatesManager {
|
|||||||
|
|
||||||
async findDuplicates() {
|
async findDuplicates() {
|
||||||
try {
|
try {
|
||||||
document.body.classList.add('loading');
|
|
||||||
|
|
||||||
const response = await fetch('/api/recipes/find-duplicates');
|
const response = await fetch('/api/recipes/find-duplicates');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to find duplicates');
|
throw new Error('Failed to find duplicates');
|
||||||
@@ -39,8 +36,6 @@ export class DuplicatesManager {
|
|||||||
console.error('Error finding duplicates:', error);
|
console.error('Error finding duplicates:', error);
|
||||||
showToast('Failed to find duplicates: ' + error.message, 'error');
|
showToast('Failed to find duplicates: ' + error.message, 'error');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
|
||||||
document.body.classList.remove('loading');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +95,7 @@ export class DuplicatesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-enable virtual scrolling
|
// Re-enable virtual scrolling
|
||||||
if (state.virtualScroller) {
|
state.virtualScroller.enable();
|
||||||
state.virtualScroller.enable();
|
|
||||||
} else {
|
|
||||||
// If virtual scroller doesn't exist, reinitialize it
|
|
||||||
setTimeout(() => {
|
|
||||||
initializeInfiniteScroll('recipes');
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderDuplicateGroups() {
|
renderDuplicateGroups() {
|
||||||
@@ -234,7 +222,7 @@ export class DuplicatesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedCount() {
|
updateSelectedCount() {
|
||||||
const selectedCountEl = document.getElementById('selectedCount');
|
const selectedCountEl = document.getElementById('duplicatesSelectedCount');
|
||||||
if (selectedCountEl) {
|
if (selectedCountEl) {
|
||||||
selectedCountEl.textContent = this.selectedForDeletion.size;
|
selectedCountEl.textContent = this.selectedForDeletion.size;
|
||||||
}
|
}
|
||||||
@@ -359,8 +347,6 @@ export class DuplicatesManager {
|
|||||||
// Add new method to execute deletion after confirmation
|
// Add new method to execute deletion after confirmation
|
||||||
async confirmDeleteDuplicates() {
|
async confirmDeleteDuplicates() {
|
||||||
try {
|
try {
|
||||||
document.body.classList.add('loading');
|
|
||||||
|
|
||||||
// Close the modal
|
// Close the modal
|
||||||
modalManager.closeModal('duplicateDeleteModal');
|
modalManager.closeModal('duplicateDeleteModal');
|
||||||
|
|
||||||
@@ -395,8 +381,6 @@ export class DuplicatesManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting recipes:', error);
|
console.error('Error deleting recipes:', error);
|
||||||
showToast('Failed to delete recipes: ' + error.message, 'error');
|
showToast('Failed to delete recipes: ' + error.message, 'error');
|
||||||
} finally {
|
|
||||||
document.body.classList.remove('loading');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ export class HeaderManager {
|
|||||||
const supportToggle = document.getElementById('supportToggleBtn');
|
const supportToggle = document.getElementById('supportToggleBtn');
|
||||||
if (supportToggle) {
|
if (supportToggle) {
|
||||||
supportToggle.addEventListener('click', () => {
|
supportToggle.addEventListener('click', () => {
|
||||||
// Handle support panel logic
|
if (window.modalManager) {
|
||||||
|
window.modalManager.toggleModal('supportModal');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,5 +108,15 @@ export class HeaderManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle help toggle
|
||||||
|
// const helpToggle = document.querySelector('.help-toggle');
|
||||||
|
// if (helpToggle) {
|
||||||
|
// helpToggle.addEventListener('click', () => {
|
||||||
|
// if (window.modalManager) {
|
||||||
|
// window.modalManager.toggleModal('helpModal');
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../utils/uiHelpers.js';
|
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../utils/uiHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showLoraModal } from './loraModal/index.js';
|
import { showLoraModal } from './loraModal/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||||
@@ -76,9 +76,13 @@ function handleLoraCardEvent(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
if (state.bulkMode) {
|
if (state.bulkMode) {
|
||||||
// Toggle selection using the bulk manager
|
// Toggle selection using the bulk manager
|
||||||
bulkManager.toggleCardSelection(card);
|
bulkManager.toggleCardSelection(card);
|
||||||
|
} else if (pageState && pageState.duplicatesMode) {
|
||||||
|
// In duplicates mode, don't open modal when clicking cards
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Normal behavior - show modal
|
// Normal behavior - show modal
|
||||||
const loraMeta = {
|
const loraMeta = {
|
||||||
|
|||||||
599
static/js/components/ModelDuplicatesManager.js
Normal file
599
static/js/components/ModelDuplicatesManager.js
Normal file
@@ -0,0 +1,599 @@
|
|||||||
|
// Model Duplicates Manager Component for LoRAs and Checkpoints
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
|
import { formatDate } from '../utils/formatters.js';
|
||||||
|
|
||||||
|
export class ModelDuplicatesManager {
|
||||||
|
constructor(pageManager, modelType = 'loras') {
|
||||||
|
this.pageManager = pageManager;
|
||||||
|
this.duplicateGroups = [];
|
||||||
|
this.inDuplicateMode = false;
|
||||||
|
this.selectedForDeletion = new Set();
|
||||||
|
this.modelType = modelType; // Use the provided modelType or default to 'loras'
|
||||||
|
|
||||||
|
// Bind methods
|
||||||
|
this.renderModelCard = this.renderModelCard.bind(this);
|
||||||
|
this.renderTooltip = this.renderTooltip.bind(this);
|
||||||
|
this.checkDuplicatesCount = this.checkDuplicatesCount.bind(this);
|
||||||
|
|
||||||
|
// Keep track of which controls need to be re-enabled
|
||||||
|
this.disabledControls = [];
|
||||||
|
|
||||||
|
// Check for duplicates on load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', this.checkDuplicatesCount);
|
||||||
|
} else {
|
||||||
|
this.checkDuplicatesCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to check for duplicates count using existing endpoint
|
||||||
|
async checkDuplicatesCount() {
|
||||||
|
try {
|
||||||
|
const endpoint = `/api/${this.modelType}/find-duplicates`;
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get duplicates count: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const duplicatesCount = (data.duplicates || []).length;
|
||||||
|
this.updateDuplicatesBadge(duplicatesCount);
|
||||||
|
} else {
|
||||||
|
this.updateDuplicatesBadge(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking duplicates count:', error);
|
||||||
|
this.updateDuplicatesBadge(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method to update the badge
|
||||||
|
updateDuplicatesBadge(count) {
|
||||||
|
const badge = document.getElementById('duplicatesBadge');
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
badge.textContent = count;
|
||||||
|
badge.classList.add('pulse');
|
||||||
|
} else {
|
||||||
|
badge.textContent = '';
|
||||||
|
badge.classList.remove('pulse');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle method to enter/exit duplicates mode
|
||||||
|
toggleDuplicateMode() {
|
||||||
|
if (this.inDuplicateMode) {
|
||||||
|
this.exitDuplicateMode();
|
||||||
|
} else {
|
||||||
|
this.findDuplicates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDuplicates() {
|
||||||
|
try {
|
||||||
|
// Determine API endpoint based on model type
|
||||||
|
const endpoint = `/api/${this.modelType}/find-duplicates`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to find duplicates: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Unknown error finding duplicates');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.duplicateGroups = data.duplicates || [];
|
||||||
|
|
||||||
|
// Update the badge with the current count
|
||||||
|
this.updateDuplicatesBadge(this.duplicateGroups.length);
|
||||||
|
|
||||||
|
if (this.duplicateGroups.length === 0) {
|
||||||
|
showToast('No duplicate models found', 'info');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enterDuplicateMode();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error finding duplicates:', error);
|
||||||
|
showToast('Failed to find duplicates: ' + error.message, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enterDuplicateMode() {
|
||||||
|
this.inDuplicateMode = true;
|
||||||
|
this.selectedForDeletion.clear();
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
pageState.duplicatesMode = true;
|
||||||
|
|
||||||
|
// Show duplicates banner
|
||||||
|
const banner = document.getElementById('duplicatesBanner');
|
||||||
|
const countSpan = document.getElementById('duplicatesCount');
|
||||||
|
|
||||||
|
if (banner && countSpan) {
|
||||||
|
countSpan.textContent = `Found ${this.duplicateGroups.length} duplicate group${this.duplicateGroups.length !== 1 ? 's' : ''}`;
|
||||||
|
banner.style.display = 'block';
|
||||||
|
|
||||||
|
// Setup help tooltip behavior
|
||||||
|
this.setupHelpTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable virtual scrolling if active
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
state.virtualScroller.disable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add duplicate-mode class to the body
|
||||||
|
document.body.classList.add('duplicate-mode');
|
||||||
|
|
||||||
|
// Render duplicate groups
|
||||||
|
this.renderDuplicateGroups();
|
||||||
|
|
||||||
|
// Update selected count
|
||||||
|
this.updateSelectedCount();
|
||||||
|
|
||||||
|
// Update Duplicates button to show active state
|
||||||
|
const duplicatesBtn = document.getElementById('findDuplicatesBtn');
|
||||||
|
if (duplicatesBtn) {
|
||||||
|
duplicatesBtn.classList.add('active');
|
||||||
|
duplicatesBtn.title = 'Exit Duplicates Mode';
|
||||||
|
// Change icon and text to indicate it's now an exit button
|
||||||
|
duplicatesBtn.innerHTML = '<i class="fas fa-times"></i> Exit Duplicates';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable all control buttons except the duplicates button
|
||||||
|
this.disableControlButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
exitDuplicateMode() {
|
||||||
|
this.inDuplicateMode = false;
|
||||||
|
this.selectedForDeletion.clear();
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
pageState.duplicatesMode = false;
|
||||||
|
|
||||||
|
// Hide duplicates banner
|
||||||
|
const banner = document.getElementById('duplicatesBanner');
|
||||||
|
if (banner) {
|
||||||
|
banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove duplicate-mode class from the body
|
||||||
|
document.body.classList.remove('duplicate-mode');
|
||||||
|
|
||||||
|
// Clear the model grid first
|
||||||
|
const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid');
|
||||||
|
if (modelGrid) {
|
||||||
|
modelGrid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable virtual scrolling
|
||||||
|
state.virtualScroller.enable();
|
||||||
|
|
||||||
|
// Restore Duplicates button to its original state
|
||||||
|
const duplicatesBtn = document.getElementById('findDuplicatesBtn');
|
||||||
|
if (duplicatesBtn) {
|
||||||
|
duplicatesBtn.classList.remove('active');
|
||||||
|
duplicatesBtn.title = 'Find duplicate models';
|
||||||
|
duplicatesBtn.innerHTML = '<i class="fas fa-clone"></i> Duplicates <span id="duplicatesBadge" class="badge"></span>';
|
||||||
|
|
||||||
|
// Restore badge
|
||||||
|
const newBadge = duplicatesBtn.querySelector('#duplicatesBadge');
|
||||||
|
const oldBadge = document.getElementById('duplicatesBadge');
|
||||||
|
if (oldBadge && oldBadge.textContent) {
|
||||||
|
newBadge.textContent = oldBadge.textContent;
|
||||||
|
newBadge.classList.add('pulse');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable all control buttons
|
||||||
|
this.enableControlButtons();
|
||||||
|
|
||||||
|
this.checkDuplicatesCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable all control buttons except the duplicates button
|
||||||
|
disableControlButtons() {
|
||||||
|
this.disabledControls = [];
|
||||||
|
|
||||||
|
// Select all control buttons except the duplicates button
|
||||||
|
const controlButtons = document.querySelectorAll('.control-group button:not(#findDuplicatesBtn), .dropdown-group, .toggle-folders-btn, #favoriteFilterBtn');
|
||||||
|
|
||||||
|
controlButtons.forEach(button => {
|
||||||
|
// Only disable enabled buttons (don't disable already disabled buttons)
|
||||||
|
if (!button.disabled && !button.classList.contains('disabled')) {
|
||||||
|
this.disabledControls.push(button);
|
||||||
|
button.disabled = true;
|
||||||
|
button.classList.add('disabled-during-duplicates');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-enable all previously disabled control buttons
|
||||||
|
enableControlButtons() {
|
||||||
|
this.disabledControls.forEach(button => {
|
||||||
|
button.disabled = false;
|
||||||
|
button.classList.remove('disabled-during-duplicates');
|
||||||
|
});
|
||||||
|
this.disabledControls = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDuplicateGroups() {
|
||||||
|
const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid');
|
||||||
|
if (!modelGrid) return;
|
||||||
|
|
||||||
|
// Clear existing content
|
||||||
|
modelGrid.innerHTML = '';
|
||||||
|
|
||||||
|
// Render each duplicate group
|
||||||
|
this.duplicateGroups.forEach((group, groupIndex) => {
|
||||||
|
const groupDiv = document.createElement('div');
|
||||||
|
groupDiv.className = 'duplicate-group';
|
||||||
|
groupDiv.dataset.hash = group.hash;
|
||||||
|
|
||||||
|
// Create group header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'duplicate-group-header';
|
||||||
|
header.innerHTML = `
|
||||||
|
<span>Duplicate Group #${groupIndex + 1} (${group.models.length} models with same hash: ${group.hash})</span>
|
||||||
|
<span>
|
||||||
|
<button class="btn-select-all" onclick="modelDuplicatesManager.toggleSelectAllInGroup('${group.hash}')">
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
groupDiv.appendChild(header);
|
||||||
|
|
||||||
|
// Create cards container
|
||||||
|
const cardsDiv = document.createElement('div');
|
||||||
|
cardsDiv.className = 'card-group-container';
|
||||||
|
|
||||||
|
// Add scrollable class if there are many models in the group
|
||||||
|
if (group.models.length > 6) {
|
||||||
|
cardsDiv.classList.add('scrollable');
|
||||||
|
|
||||||
|
// Add expand/collapse toggle button
|
||||||
|
const toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.className = 'group-toggle-btn';
|
||||||
|
toggleBtn.innerHTML = '<i class="fas fa-chevron-down"></i>';
|
||||||
|
toggleBtn.title = "Expand/Collapse";
|
||||||
|
toggleBtn.onclick = function() {
|
||||||
|
cardsDiv.classList.toggle('scrollable');
|
||||||
|
this.innerHTML = cardsDiv.classList.contains('scrollable') ?
|
||||||
|
'<i class="fas fa-chevron-down"></i>' :
|
||||||
|
'<i class="fas fa-chevron-up"></i>';
|
||||||
|
};
|
||||||
|
groupDiv.appendChild(toggleBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all model cards in this group
|
||||||
|
group.models.forEach(model => {
|
||||||
|
const card = this.renderModelCard(model, group.hash);
|
||||||
|
cardsDiv.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
groupDiv.appendChild(cardsDiv);
|
||||||
|
modelGrid.appendChild(groupDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderModelCard(model, groupHash) {
|
||||||
|
// Create basic card structure
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'lora-card duplicate';
|
||||||
|
card.dataset.hash = model.sha256;
|
||||||
|
card.dataset.filePath = model.file_path;
|
||||||
|
|
||||||
|
// Create card content using structure similar to createLoraCard in LoraCard.js
|
||||||
|
const previewContainer = document.createElement('div');
|
||||||
|
previewContainer.className = 'card-preview';
|
||||||
|
|
||||||
|
// Determine if preview is a video
|
||||||
|
const isVideo = model.preview_url && model.preview_url.endsWith('.mp4');
|
||||||
|
let preview;
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
// Create video element for MP4 previews
|
||||||
|
preview = document.createElement('video');
|
||||||
|
preview.loading = 'lazy';
|
||||||
|
preview.controls = true;
|
||||||
|
preview.muted = true;
|
||||||
|
preview.loop = true;
|
||||||
|
|
||||||
|
const source = document.createElement('source');
|
||||||
|
source.src = model.preview_url;
|
||||||
|
source.type = 'video/mp4';
|
||||||
|
preview.appendChild(source);
|
||||||
|
} else {
|
||||||
|
// Create image element for standard previews
|
||||||
|
preview = document.createElement('img');
|
||||||
|
preview.loading = 'lazy';
|
||||||
|
preview.alt = model.model_name;
|
||||||
|
|
||||||
|
if (model.preview_url) {
|
||||||
|
preview.src = model.preview_url;
|
||||||
|
} else {
|
||||||
|
// Use placeholder
|
||||||
|
preview.src = '/loras_static/images/no-preview.png';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add NSFW blur if needed
|
||||||
|
if (model.preview_nsfw_level > 0) {
|
||||||
|
preview.classList.add('nsfw');
|
||||||
|
}
|
||||||
|
|
||||||
|
previewContainer.appendChild(preview);
|
||||||
|
|
||||||
|
// Move tooltip listeners to the preview container for consistent behavior
|
||||||
|
// regardless of whether the preview is an image or video
|
||||||
|
previewContainer.addEventListener('mouseover', () => this.renderTooltip(card, model));
|
||||||
|
previewContainer.addEventListener('mouseout', () => {
|
||||||
|
const tooltip = document.querySelector('.model-tooltip');
|
||||||
|
if (tooltip) tooltip.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add card footer with just model name
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'card-footer';
|
||||||
|
|
||||||
|
const modelInfo = document.createElement('div');
|
||||||
|
modelInfo.className = 'model-info';
|
||||||
|
|
||||||
|
const modelName = document.createElement('span');
|
||||||
|
modelName.className = 'model-name';
|
||||||
|
modelName.textContent = model.model_name;
|
||||||
|
modelInfo.appendChild(modelName);
|
||||||
|
|
||||||
|
footer.appendChild(modelInfo);
|
||||||
|
previewContainer.appendChild(footer);
|
||||||
|
card.appendChild(previewContainer);
|
||||||
|
|
||||||
|
// Add selection checkbox
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.className = 'selector-checkbox';
|
||||||
|
checkbox.dataset.filePath = model.file_path;
|
||||||
|
checkbox.dataset.groupHash = groupHash;
|
||||||
|
|
||||||
|
// Check if already selected
|
||||||
|
if (this.selectedForDeletion.has(model.file_path)) {
|
||||||
|
checkbox.checked = true;
|
||||||
|
card.classList.add('duplicate-selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add change event to checkbox
|
||||||
|
checkbox.addEventListener('change', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.toggleCardSelection(model.file_path, card, checkbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make the entire card clickable for selection
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
// Don't toggle if clicking on the checkbox directly or card actions
|
||||||
|
if (e.target === checkbox || e.target.closest('.card-actions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle checkbox state
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
this.toggleCardSelection(model.file_path, card, checkbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(checkbox);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTooltip(card, model) {
|
||||||
|
// Remove any existing tooltips
|
||||||
|
const existingTooltip = document.querySelector('.model-tooltip');
|
||||||
|
if (existingTooltip) existingTooltip.remove();
|
||||||
|
|
||||||
|
// Create tooltip
|
||||||
|
const tooltip = document.createElement('div');
|
||||||
|
tooltip.className = 'model-tooltip';
|
||||||
|
|
||||||
|
// Add model information to tooltip
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div class="tooltip-header">${model.model_name}</div>
|
||||||
|
<div class="tooltip-info">
|
||||||
|
<div><strong>Version:</strong> ${model.civitai?.name || 'Unknown'}</div>
|
||||||
|
<div><strong>Filename:</strong> ${model.file_name}</div>
|
||||||
|
<div><strong>Path:</strong> ${model.file_path}</div>
|
||||||
|
<div><strong>Base Model:</strong> ${model.base_model || 'Unknown'}</div>
|
||||||
|
<div><strong>Modified:</strong> ${formatDate(model.modified)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Position tooltip relative to card
|
||||||
|
const cardRect = card.getBoundingClientRect();
|
||||||
|
tooltip.style.top = `${cardRect.top + window.scrollY - 10}px`;
|
||||||
|
tooltip.style.left = `${cardRect.left + window.scrollX + cardRect.width + 10}px`;
|
||||||
|
|
||||||
|
// Add tooltip to document
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
|
||||||
|
// Check if tooltip is outside viewport and adjust if needed
|
||||||
|
const tooltipRect = tooltip.getBoundingClientRect();
|
||||||
|
if (tooltipRect.right > window.innerWidth) {
|
||||||
|
tooltip.style.left = `${cardRect.left + window.scrollX - tooltipRect.width - 10}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to toggle card selection state
|
||||||
|
toggleCardSelection(filePath, card, checkbox) {
|
||||||
|
if (checkbox.checked) {
|
||||||
|
this.selectedForDeletion.add(filePath);
|
||||||
|
card.classList.add('duplicate-selected');
|
||||||
|
} else {
|
||||||
|
this.selectedForDeletion.delete(filePath);
|
||||||
|
card.classList.remove('duplicate-selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectedCount() {
|
||||||
|
const selectedCountEl = document.getElementById('duplicatesSelectedCount');
|
||||||
|
if (selectedCountEl) {
|
||||||
|
selectedCountEl.textContent = this.selectedForDeletion.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update delete button state
|
||||||
|
const deleteBtn = document.querySelector('.btn-delete-selected');
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.disabled = this.selectedForDeletion.size === 0;
|
||||||
|
deleteBtn.classList.toggle('disabled', this.selectedForDeletion.size === 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAllInGroup(hash) {
|
||||||
|
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`);
|
||||||
|
const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked);
|
||||||
|
|
||||||
|
// If all are selected, deselect all; otherwise select all
|
||||||
|
checkboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = !allSelected;
|
||||||
|
const filePath = checkbox.dataset.filePath;
|
||||||
|
const card = checkbox.closest('.lora-card');
|
||||||
|
|
||||||
|
if (!allSelected) {
|
||||||
|
this.selectedForDeletion.add(filePath);
|
||||||
|
card.classList.add('duplicate-selected');
|
||||||
|
} else {
|
||||||
|
this.selectedForDeletion.delete(filePath);
|
||||||
|
card.classList.remove('duplicate-selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the button text
|
||||||
|
const button = document.querySelector(`.duplicate-group[data-hash="${hash}"] .btn-select-all`);
|
||||||
|
if (button) {
|
||||||
|
button.textContent = !allSelected ? "Deselect All" : "Select All";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSelectedCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSelectedDuplicates() {
|
||||||
|
if (this.selectedForDeletion.size === 0) {
|
||||||
|
showToast('No models selected for deletion', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show the delete confirmation modal instead of a simple confirm
|
||||||
|
const modelDuplicateDeleteCount = document.getElementById('modelDuplicateDeleteCount');
|
||||||
|
if (modelDuplicateDeleteCount) {
|
||||||
|
modelDuplicateDeleteCount.textContent = this.selectedForDeletion.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the modal manager to show the confirmation modal
|
||||||
|
modalManager.showModal('modelDuplicateDeleteModal');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error preparing delete:', error);
|
||||||
|
showToast('Error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute deletion after confirmation
|
||||||
|
async confirmDeleteDuplicates() {
|
||||||
|
try {
|
||||||
|
// Close the modal
|
||||||
|
modalManager.closeModal('modelDuplicateDeleteModal');
|
||||||
|
|
||||||
|
// Prepare file paths for deletion
|
||||||
|
const filePaths = Array.from(this.selectedForDeletion);
|
||||||
|
|
||||||
|
// Call API to bulk delete
|
||||||
|
const response = await fetch(`/api/${this.modelType}/bulk-delete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ file_paths: filePaths })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete selected models');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.error || 'Unknown error deleting models');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Successfully deleted ${data.total_deleted} models`, 'success');
|
||||||
|
|
||||||
|
// Exit duplicate mode if deletions were successful
|
||||||
|
if (data.total_deleted > 0) {
|
||||||
|
// Check duplicates count after deletion
|
||||||
|
this.checkDuplicatesCount();
|
||||||
|
this.exitDuplicateMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting models:', error);
|
||||||
|
showToast('Failed to delete models: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to update the badge after refresh
|
||||||
|
updateDuplicatesBadgeAfterRefresh() {
|
||||||
|
// Use this method after refresh operations
|
||||||
|
this.checkDuplicatesCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add this new method for tooltip behavior
|
||||||
|
setupHelpTooltip() {
|
||||||
|
const helpIcon = document.getElementById('duplicatesHelp');
|
||||||
|
const helpTooltip = document.getElementById('duplicatesHelpTooltip');
|
||||||
|
|
||||||
|
if (!helpIcon || !helpTooltip) return;
|
||||||
|
|
||||||
|
helpIcon.addEventListener('mouseenter', (e) => {
|
||||||
|
// Get the container's positioning context
|
||||||
|
const bannerContent = helpIcon.closest('.banner-content');
|
||||||
|
|
||||||
|
// Get positions relative to the viewport
|
||||||
|
const iconRect = helpIcon.getBoundingClientRect();
|
||||||
|
const bannerRect = bannerContent.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Set initial position relative to the banner content
|
||||||
|
helpTooltip.style.display = 'block';
|
||||||
|
helpTooltip.style.top = `${iconRect.bottom - bannerRect.top + 10}px`;
|
||||||
|
helpTooltip.style.left = `${iconRect.left - bannerRect.left - 10}px`;
|
||||||
|
|
||||||
|
// Check if the tooltip is going off-screen to the right
|
||||||
|
const tooltipRect = helpTooltip.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
|
||||||
|
if (tooltipRect.right > viewportWidth - 20) {
|
||||||
|
// Reposition relative to container if too close to right edge
|
||||||
|
helpTooltip.style.left = `${bannerContent.offsetWidth - tooltipRect.width - 20}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rest of the event listeners remain unchanged
|
||||||
|
helpIcon.addEventListener('mouseleave', () => {
|
||||||
|
helpTooltip.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!helpIcon.contains(e.target)) {
|
||||||
|
helpTooltip.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,44 +2,22 @@
|
|||||||
* ShowcaseView.js
|
* ShowcaseView.js
|
||||||
* Handles showcase content (images, videos) display for checkpoint modal
|
* Handles showcase content (images, videos) display for checkpoint modal
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import {
|
||||||
|
toggleShowcase,
|
||||||
|
setupShowcaseScroll,
|
||||||
|
scrollToTop
|
||||||
|
} from '../../utils/uiHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the local URL for an example image if available
|
|
||||||
* @param {Object} img - Image object
|
|
||||||
* @param {number} index - Image index
|
|
||||||
* @param {string} modelHash - Model hash
|
|
||||||
* @returns {string|null} - Local URL or null if not available
|
|
||||||
*/
|
|
||||||
function getLocalExampleImageUrl(img, index, modelHash) {
|
|
||||||
if (!modelHash) return null;
|
|
||||||
|
|
||||||
// Get remote extension
|
|
||||||
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
|
|
||||||
|
|
||||||
// If it's a video (mp4), use that extension
|
|
||||||
if (remoteExt === 'mp4') {
|
|
||||||
return `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For images, check if optimization is enabled (defaults to true)
|
|
||||||
const optimizeImages = state.settings.optimizeExampleImages !== false;
|
|
||||||
|
|
||||||
// Use .webp for images if optimization enabled, otherwise use original extension
|
|
||||||
const extension = optimizeImages ? 'webp' : remoteExt;
|
|
||||||
|
|
||||||
return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {string} modelHash - Model hash for identifying local files
|
* @param {string} modelHash - Model hash for identifying local files
|
||||||
|
* @param {Array} exampleFiles - Local example files already fetched
|
||||||
* @returns {string} HTML content
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
export function renderShowcaseContent(images, modelHash) {
|
export function renderShowcaseContent(images, exampleFiles = []) {
|
||||||
if (!images?.length) return '<div class="no-examples">No example images available</div>';
|
if (!images?.length) return '<div class="no-examples">No example images available</div>';
|
||||||
|
|
||||||
// Filter images based on SFW setting
|
// Filter images based on SFW setting
|
||||||
@@ -82,9 +60,85 @@ export function renderShowcaseContent(images, modelHash) {
|
|||||||
${hiddenNotification}
|
${hiddenNotification}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
${filteredImages.map((img, index) => {
|
${filteredImages.map((img, index) => {
|
||||||
// Try to get local URL for the example image
|
// Find matching file in our list of actual files
|
||||||
const localUrl = getLocalExampleImageUrl(img, index, modelHash);
|
let localFile = null;
|
||||||
return generateMediaWrapper(img, localUrl);
|
if (exampleFiles.length > 0) {
|
||||||
|
// Try to find the corresponding file by index first
|
||||||
|
localFile = exampleFiles.find(file => {
|
||||||
|
const match = file.name.match(/image_(\d+)\./);
|
||||||
|
return match && parseInt(match[1]) === index;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If not found by index, just use the same position in the array if available
|
||||||
|
if (!localFile && index < exampleFiles.length) {
|
||||||
|
localFile = exampleFiles[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteUrl = img.url || '';
|
||||||
|
const localUrl = localFile ? localFile.path : '';
|
||||||
|
const isVideo = localFile ? localFile.is_video :
|
||||||
|
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
|
||||||
|
|
||||||
|
// Calculate appropriate aspect ratio
|
||||||
|
const aspectRatio = (img.height / img.width) * 100;
|
||||||
|
const containerWidth = 800; // modal content maximum width
|
||||||
|
const minHeightPercent = 40;
|
||||||
|
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||||
|
const heightPercent = Math.max(
|
||||||
|
minHeightPercent,
|
||||||
|
Math.min(maxHeightPercent, aspectRatio)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if media should be blurred
|
||||||
|
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||||
|
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
|
||||||
|
|
||||||
|
// Determine NSFW warning text based on level
|
||||||
|
let nsfwText = "Mature Content";
|
||||||
|
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||||
|
nsfwText = "XXX-rated Content";
|
||||||
|
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||||
|
nsfwText = "X-rated Content";
|
||||||
|
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||||
|
nsfwText = "R-rated Content";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from the image
|
||||||
|
const meta = img.meta || {};
|
||||||
|
const prompt = meta.prompt || '';
|
||||||
|
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
|
||||||
|
const size = meta.Size || `${img.width}x${img.height}`;
|
||||||
|
const seed = meta.seed || '';
|
||||||
|
const model = meta.Model || '';
|
||||||
|
const steps = meta.steps || '';
|
||||||
|
const sampler = meta.sampler || '';
|
||||||
|
const cfgScale = meta.cfgScale || '';
|
||||||
|
const clipSkip = meta.clipSkip || '';
|
||||||
|
|
||||||
|
// Check if we have any meaningful generation parameters
|
||||||
|
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
||||||
|
const hasPrompts = prompt || negativePrompt;
|
||||||
|
|
||||||
|
// Create metadata panel content
|
||||||
|
const metadataPanel = generateMetadataPanel(
|
||||||
|
hasParams, hasPrompts,
|
||||||
|
prompt, negativePrompt,
|
||||||
|
size, seed, model, steps, sampler, cfgScale, clipSkip
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this is a video or image
|
||||||
|
if (isVideo) {
|
||||||
|
return generateVideoWrapper(
|
||||||
|
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||||
|
localUrl, remoteUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generateImageWrapper(
|
||||||
|
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||||
|
localUrl, remoteUrl
|
||||||
|
);
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,11 +150,8 @@ export function renderShowcaseContent(images, modelHash) {
|
|||||||
* @param {Object} media - Media object with image or video data
|
* @param {Object} media - Media object with image or video data
|
||||||
* @returns {string} HTML content
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
function generateMediaWrapper(media, localUrl = null) {
|
function generateMediaWrapper(media, urls) {
|
||||||
// Calculate appropriate aspect ratio:
|
// Calculate appropriate aspect ratio
|
||||||
// 1. Keep original aspect ratio
|
|
||||||
// 2. Limit maximum height to 60% of viewport height
|
|
||||||
// 3. Ensure minimum height is 40% of container width
|
|
||||||
const aspectRatio = (media.height / media.width) * 100;
|
const aspectRatio = (media.height / media.width) * 100;
|
||||||
const containerWidth = 800; // modal content maximum width
|
const containerWidth = 800; // modal content maximum width
|
||||||
const minHeightPercent = 40;
|
const minHeightPercent = 40;
|
||||||
@@ -149,10 +200,10 @@ function generateMediaWrapper(media, localUrl = null) {
|
|||||||
|
|
||||||
// Check if this is a video or image
|
// Check if this is a video or image
|
||||||
if (media.type === 'video') {
|
if (media.type === 'video') {
|
||||||
return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl);
|
return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl);
|
return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -225,7 +276,7 @@ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, si
|
|||||||
/**
|
/**
|
||||||
* Generate video wrapper HTML
|
* Generate video wrapper HTML
|
||||||
*/
|
*/
|
||||||
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) {
|
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -236,9 +287,9 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
|
|||||||
<video controls autoplay muted loop crossorigin="anonymous"
|
<video controls autoplay muted loop crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
data-local-src="${localUrl || ''}"
|
data-local-src="${localUrl || ''}"
|
||||||
data-remote-src="${media.url}"
|
data-remote-src="${remoteUrl}"
|
||||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||||
<source data-local-src="${localUrl || ''}" data-remote-src="${media.url}" type="video/mp4">
|
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
|
||||||
Your browser does not support video playback
|
Your browser does not support video playback
|
||||||
</video>
|
</video>
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -257,7 +308,7 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
|
|||||||
/**
|
/**
|
||||||
* Generate image wrapper HTML
|
* Generate image wrapper HTML
|
||||||
*/
|
*/
|
||||||
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) {
|
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
|
||||||
return `
|
return `
|
||||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||||
${shouldBlur ? `
|
${shouldBlur ? `
|
||||||
@@ -266,7 +317,7 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
|
|||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
<img data-local-src="${localUrl || ''}"
|
<img data-local-src="${localUrl || ''}"
|
||||||
data-remote-src="${media.url}"
|
data-remote-src="${remoteUrl}"
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
crossorigin="anonymous"
|
crossorigin="anonymous"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@@ -286,410 +337,10 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Use the shared setupShowcaseScroll function with the correct modal ID
|
||||||
* Toggle showcase expansion
|
export { setupShowcaseScroll, scrollToTop, toggleShowcase };
|
||||||
*/
|
|
||||||
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');
|
// Initialize the showcase scroll when this module is imported
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (isCollapsed) {
|
setupShowcaseScroll('checkpointModal');
|
||||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
});
|
||||||
indicator.textContent = `Scroll or click to hide examples`;
|
|
||||||
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
|
|
||||||
initLazyLoading(carousel);
|
|
||||||
|
|
||||||
// Initialize NSFW content blur toggle handlers
|
|
||||||
initNsfwBlurHandlers(carousel);
|
|
||||||
|
|
||||||
// Initialize metadata panel interaction handlers
|
|
||||||
initMetadataPanelHandlers(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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize metadata panel interaction handlers
|
|
||||||
*/
|
|
||||||
function initMetadataPanelHandlers(container) {
|
|
||||||
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
|
||||||
|
|
||||||
mediaWrappers.forEach(wrapper => {
|
|
||||||
// Get the metadata panel and media element (img or video)
|
|
||||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
|
||||||
const mediaElement = wrapper.querySelector('img, video');
|
|
||||||
|
|
||||||
if (!metadataPanel || !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 when over media content or metadata panel itself
|
|
||||||
if (isOverMedia || isOverMetadataPanel) {
|
|
||||||
metadataPanel.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
metadataPanel.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.addEventListener('mouseleave', () => {
|
|
||||||
// Only hide panel when mouse leaves the wrapper and not over the metadata panel
|
|
||||||
if (!isOverMetadataPanel) {
|
|
||||||
metadataPanel.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add mouse enter/leave events for the metadata panel itself
|
|
||||||
metadataPanel.addEventListener('mouseenter', () => {
|
|
||||||
isOverMetadataPanel = true;
|
|
||||||
metadataPanel.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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent events from bubbling
|
|
||||||
metadataPanel.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle copy prompt buttons
|
|
||||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
|
||||||
copyBtns.forEach(copyBtn => {
|
|
||||||
const promptIndex = copyBtn.dataset.promptIndex;
|
|
||||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!promptElement) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
showToast('Copy failed', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent panel scroll from causing modal scroll
|
|
||||||
metadataPanel.addEventListener('wheel', (e) => {
|
|
||||||
const isAtTop = metadataPanel.scrollTop === 0;
|
|
||||||
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
|
||||||
|
|
||||||
// Only prevent default if scrolling would cause the panel to scroll
|
|
||||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
|
||||||
e.stopPropagation();
|
|
||||||
}
|
|
||||||
}, { passive: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the actual rendered rectangle of a media element with object-fit: contain
|
|
||||||
* @param {HTMLElement} mediaElement - The img or video element
|
|
||||||
* @param {number} containerWidth - Width of the container
|
|
||||||
* @param {number} containerHeight - Height of the container
|
|
||||||
* @returns {Object} - Rect with left, top, right, bottom coordinates
|
|
||||||
*/
|
|
||||||
function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
|
|
||||||
// Get natural dimensions of the media
|
|
||||||
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
|
|
||||||
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
|
|
||||||
|
|
||||||
if (!naturalWidth || !naturalHeight) {
|
|
||||||
// Fallback if dimensions cannot be determined
|
|
||||||
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate aspect ratios
|
|
||||||
const containerRatio = containerWidth / containerHeight;
|
|
||||||
const mediaRatio = naturalWidth / naturalHeight;
|
|
||||||
|
|
||||||
let renderedWidth, renderedHeight, left = 0, top = 0;
|
|
||||||
|
|
||||||
// Apply object-fit: contain logic
|
|
||||||
if (containerRatio > mediaRatio) {
|
|
||||||
// Container is wider than media - will have empty space on sides
|
|
||||||
renderedHeight = containerHeight;
|
|
||||||
renderedWidth = renderedHeight * mediaRatio;
|
|
||||||
left = (containerWidth - renderedWidth) / 2;
|
|
||||||
} else {
|
|
||||||
// Container is taller than media - will have empty space top/bottom
|
|
||||||
renderedWidth = containerWidth;
|
|
||||||
renderedHeight = renderedWidth / mediaRatio;
|
|
||||||
top = (containerHeight - renderedHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
right: left + renderedWidth,
|
|
||||||
bottom: top + renderedHeight
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize blur toggle handlers
|
|
||||||
*/
|
|
||||||
function initNsfwBlurHandlers(container) {
|
|
||||||
// Handle toggle blur buttons
|
|
||||||
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
|
|
||||||
toggleButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const wrapper = btn.closest('.media-wrapper');
|
|
||||||
const media = wrapper.querySelector('img, video');
|
|
||||||
const isBlurred = media.classList.toggle('blurred');
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
|
|
||||||
// Update the icon based on blur state
|
|
||||||
if (isBlurred) {
|
|
||||||
icon.className = 'fas fa-eye';
|
|
||||||
} else {
|
|
||||||
icon.className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle the overlay visibility
|
|
||||||
const overlay = wrapper.querySelector('.nsfw-overlay');
|
|
||||||
if (overlay) {
|
|
||||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle "Show" buttons in overlays
|
|
||||||
const showButtons = container.querySelectorAll('.show-content-btn');
|
|
||||||
showButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const wrapper = btn.closest('.media-wrapper');
|
|
||||||
const media = wrapper.querySelector('img, video');
|
|
||||||
media.classList.remove('blurred');
|
|
||||||
|
|
||||||
// Update the toggle button icon
|
|
||||||
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the overlay
|
|
||||||
const overlay = wrapper.querySelector('.nsfw-overlay');
|
|
||||||
if (overlay) {
|
|
||||||
overlay.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize lazy loading for images and videos
|
|
||||||
*/
|
|
||||||
function initLazyLoading(container) {
|
|
||||||
const lazyElements = container.querySelectorAll('.lazy');
|
|
||||||
|
|
||||||
const lazyLoad = (element) => {
|
|
||||||
const localSrc = element.dataset.localSrc;
|
|
||||||
const remoteSrc = element.dataset.remoteSrc;
|
|
||||||
|
|
||||||
// Check if element is an image or video
|
|
||||||
if (element.tagName.toLowerCase() === 'video') {
|
|
||||||
// Try local first, then remote
|
|
||||||
tryLocalOrFallbackToRemote(element, localSrc, remoteSrc);
|
|
||||||
} else {
|
|
||||||
// For images, we'll use an Image object to test if local file exists
|
|
||||||
tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.classList.remove('lazy');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to load local image first, fall back to remote if local fails
|
|
||||||
const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => {
|
|
||||||
// Only try local if we have a local path
|
|
||||||
if (localSrc) {
|
|
||||||
const testImg = new Image();
|
|
||||||
testImg.onload = () => {
|
|
||||||
// Local image loaded successfully
|
|
||||||
imgElement.src = localSrc;
|
|
||||||
};
|
|
||||||
testImg.onerror = () => {
|
|
||||||
// Local image failed, use remote
|
|
||||||
imgElement.src = remoteSrc;
|
|
||||||
};
|
|
||||||
// Start loading test image
|
|
||||||
testImg.src = localSrc;
|
|
||||||
} else {
|
|
||||||
// No local path, use remote directly
|
|
||||||
imgElement.src = remoteSrc;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to load local video first, fall back to remote if local fails
|
|
||||||
const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => {
|
|
||||||
// Only try local if we have a local path
|
|
||||||
if (localSrc) {
|
|
||||||
// Try to fetch local file headers to see if it exists
|
|
||||||
fetch(localSrc, { method: 'HEAD' })
|
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
// Local video exists, use it
|
|
||||||
videoElement.src = localSrc;
|
|
||||||
videoElement.querySelector('source').src = localSrc;
|
|
||||||
} else {
|
|
||||||
// Local video doesn't exist, use remote
|
|
||||||
videoElement.src = remoteSrc;
|
|
||||||
videoElement.querySelector('source').src = remoteSrc;
|
|
||||||
}
|
|
||||||
videoElement.load();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Error fetching, use remote
|
|
||||||
videoElement.src = remoteSrc;
|
|
||||||
videoElement.querySelector('source').src = remoteSrc;
|
|
||||||
videoElement.load();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No local path, use remote directly
|
|
||||||
videoElement.src = remoteSrc;
|
|
||||||
videoElement.querySelector('source').src = remoteSrc;
|
|
||||||
videoElement.load();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
lazyLoad(entry.target);
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
lazyElements.forEach(element => observer.observe(element));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up showcase scroll functionality
|
|
||||||
*/
|
|
||||||
export function setupShowcaseScroll() {
|
|
||||||
// Listen for wheel events
|
|
||||||
document.addEventListener('wheel', (event) => {
|
|
||||||
const modalContent = document.querySelector('#checkpointModal .modal-content');
|
|
||||||
if (!modalContent) return;
|
|
||||||
|
|
||||||
const showcase = modalContent.querySelector('.showcase-section');
|
|
||||||
if (!showcase) return;
|
|
||||||
|
|
||||||
const carousel = showcase.querySelector('.carousel');
|
|
||||||
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
|
||||||
|
|
||||||
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
|
||||||
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
|
||||||
|
|
||||||
if (isNearBottom) {
|
|
||||||
toggleShowcase(scrollIndicator);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
// Use MutationObserver to set up back-to-top button when modal content is added
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.type === 'childList' && mutation.addedNodes.length) {
|
|
||||||
const checkpointModal = document.getElementById('checkpointModal');
|
|
||||||
if (checkpointModal && checkpointModal.querySelector('.modal-content')) {
|
|
||||||
setupBackToTopButton(checkpointModal.querySelector('.modal-content'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start observing the document body for changes
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
// Also try to set up the button immediately in case the modal is already open
|
|
||||||
const modalContent = document.querySelector('#checkpointModal .modal-content');
|
|
||||||
if (modalContent) {
|
|
||||||
setupBackToTopButton(modalContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up back-to-top button
|
|
||||||
*/
|
|
||||||
function setupBackToTopButton(modalContent) {
|
|
||||||
// Remove any existing scroll listeners to avoid duplicates
|
|
||||||
modalContent.onscroll = null;
|
|
||||||
|
|
||||||
// Add new scroll listener
|
|
||||||
modalContent.addEventListener('scroll', () => {
|
|
||||||
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
|
||||||
if (backToTopBtn) {
|
|
||||||
if (modalContent.scrollTop > 300) {
|
|
||||||
backToTopBtn.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
backToTopBtn.classList.remove('visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger a scroll event to check initial position
|
|
||||||
modalContent.dispatchEvent(new Event('scroll'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Scroll to top of modal content
|
|
||||||
*/
|
|
||||||
export function scrollToTop(button) {
|
|
||||||
const modalContent = button.closest('.modal-content');
|
|
||||||
if (modalContent) {
|
|
||||||
modalContent.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Modularized checkpoint modal component that handles checkpoint model details display
|
* Modularized checkpoint modal component that handles checkpoint model details display
|
||||||
*/
|
*/
|
||||||
import { showToast } from '../../utils/uiHelpers.js';
|
import { showToast, getExampleImageFiles, initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers } from '../../utils/uiHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
||||||
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
import { saveModelMetadata } from '../../api/checkpointApi.js';
|
import { saveModelMetadata } from '../../api/checkpointApi.js';
|
||||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||||
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
|
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the checkpoint modal with the given checkpoint data
|
* Display the checkpoint modal with the given checkpoint data
|
||||||
@@ -97,7 +97,7 @@ export function showCheckpointModal(checkpoint) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-item full-width">
|
<div class="info-item full-width">
|
||||||
<label>About this version</label>
|
<label>About this version</label>
|
||||||
<div class="description-text">${checkpoint.description || 'N/A'}</div>
|
<div class="description-text">${checkpoint.civitai?.description || 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +110,9 @@ export function showCheckpointModal(checkpoint) {
|
|||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div id="showcase-tab" class="tab-pane active">
|
<div id="showcase-tab" class="tab-pane active">
|
||||||
${renderShowcaseContent(checkpoint.civitai?.images || [], checkpoint.sha256)}
|
<div class="recipes-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="description-tab" class="tab-pane">
|
<div id="description-tab" class="tab-pane">
|
||||||
@@ -146,6 +148,69 @@ export function showCheckpointModal(checkpoint) {
|
|||||||
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
|
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
|
||||||
loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path);
|
loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load example images asynchronously
|
||||||
|
loadExampleImages(checkpoint.civitai?.images, checkpoint.sha256, checkpoint.file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load example images asynchronously
|
||||||
|
* @param {Array} images - Array of image objects
|
||||||
|
* @param {string} modelHash - Model hash for fetching local files
|
||||||
|
* @param {string} filePath - File path for fetching local files
|
||||||
|
*/
|
||||||
|
async function loadExampleImages(images, modelHash, filePath) {
|
||||||
|
try {
|
||||||
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
|
if (!showcaseTab) return;
|
||||||
|
|
||||||
|
// First fetch local example files
|
||||||
|
let localFiles = [];
|
||||||
|
try {
|
||||||
|
// Choose endpoint based on centralized examples setting
|
||||||
|
const useCentralized = state.global.settings.useCentralizedExamples !== false;
|
||||||
|
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
|
||||||
|
|
||||||
|
// Use different params based on endpoint
|
||||||
|
const params = useCentralized ?
|
||||||
|
`model_hash=${modelHash}` :
|
||||||
|
`file_path=${encodeURIComponent(filePath)}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${endpoint}?${params}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
localFiles = result.files;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get example files:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then render with both remote images and local files
|
||||||
|
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
|
||||||
|
|
||||||
|
// Re-initialize the showcase event listeners
|
||||||
|
const carousel = showcaseTab.querySelector('.carousel');
|
||||||
|
if (carousel) {
|
||||||
|
// Only initialize if we actually have examples and they're expanded
|
||||||
|
if (!carousel.classList.contains('collapsed')) {
|
||||||
|
initLazyLoading(carousel);
|
||||||
|
initNsfwBlurHandlers(carousel);
|
||||||
|
initMetadataPanelHandlers(carousel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading example images:', error);
|
||||||
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
|
if (showcaseTab) {
|
||||||
|
showcaseTab.innerHTML = `
|
||||||
|
<div class="error-message">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
Error loading example images
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ export class PageControls {
|
|||||||
downloadButton.addEventListener('click', () => this.showDownloadModal());
|
downloadButton.addEventListener('click', () => this.showDownloadModal());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find duplicates button - available for both loras and checkpoints
|
||||||
|
const duplicatesButton = document.querySelector('[data-action="find-duplicates"]');
|
||||||
|
if (duplicatesButton) {
|
||||||
|
duplicatesButton.addEventListener('click', () => this.findDuplicates());
|
||||||
|
}
|
||||||
|
|
||||||
if (this.pageType === 'loras') {
|
if (this.pageType === 'loras') {
|
||||||
// Bulk operations button - LoRAs only
|
// Bulk operations button - LoRAs only
|
||||||
const bulkButton = document.querySelector('[data-action="bulk"]');
|
const bulkButton = document.querySelector('[data-action="bulk"]');
|
||||||
@@ -399,6 +405,11 @@ export class PageControls {
|
|||||||
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, error);
|
||||||
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
|
showToast(`Failed to ${fullRebuild ? 'rebuild' : 'refresh'} ${this.pageType}: ${error.message}`, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (window.modelDuplicatesManager) {
|
||||||
|
// Update duplicates badge after refresh
|
||||||
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,4 +510,16 @@ export class PageControls {
|
|||||||
// Reload models with new filter
|
// Reload models with new filter
|
||||||
await this.resetAndReload(true);
|
await this.resetAndReload(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find duplicate models
|
||||||
|
*/
|
||||||
|
findDuplicates() {
|
||||||
|
if (window.modelDuplicatesManager) {
|
||||||
|
// Change to toggle functionality
|
||||||
|
window.modelDuplicatesManager.toggleDuplicateMode();
|
||||||
|
} else {
|
||||||
|
console.error('Model duplicates manager not available');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { PageControls } from './PageControls.js';
|
import { PageControls } from './PageControls.js';
|
||||||
import { LorasControls } from './LorasControls.js';
|
import { LorasControls } from './LorasControls.js';
|
||||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||||
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
|
|
||||||
|
|
||||||
// Export the classes
|
// Export the classes
|
||||||
export { PageControls, LorasControls, CheckpointsControls };
|
export { PageControls, LorasControls, CheckpointsControls };
|
||||||
@@ -22,16 +21,3 @@ export function createPageControls(pageType) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example for a filter method:
|
|
||||||
function applyFilter(filterType, value) {
|
|
||||||
// ...existing filter logic...
|
|
||||||
|
|
||||||
// After filters are applied, refresh the virtual scroll if it exists
|
|
||||||
if (state.virtualScroller) {
|
|
||||||
refreshVirtualScroll();
|
|
||||||
} else {
|
|
||||||
// Fall back to existing reset and reload logic
|
|
||||||
resetAndReload(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,44 +2,21 @@
|
|||||||
* ShowcaseView.js
|
* ShowcaseView.js
|
||||||
* 处理LoRA模型展示内容(图片、视频)的功能模块
|
* 处理LoRA模型展示内容(图片、视频)的功能模块
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import {
|
||||||
|
toggleShowcase,
|
||||||
|
setupShowcaseScroll,
|
||||||
|
scrollToTop
|
||||||
|
} from '../../utils/uiHelpers.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the local URL for an example image if available
|
* 获取展示内容并进行渲染
|
||||||
* @param {Object} img - Image object
|
|
||||||
* @param {number} index - Image index
|
|
||||||
* @param {string} modelHash - Model hash
|
|
||||||
* @returns {string|null} - Local URL or null if not available
|
|
||||||
*/
|
|
||||||
function getLocalExampleImageUrl(img, index, modelHash) {
|
|
||||||
if (!modelHash) return null;
|
|
||||||
|
|
||||||
// Get remote extension
|
|
||||||
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
|
|
||||||
|
|
||||||
// If it's a video (mp4), use that extension
|
|
||||||
if (remoteExt === 'mp4') {
|
|
||||||
return `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For images, check if optimization is enabled (defaults to true)
|
|
||||||
const optimizeImages = state.settings.optimizeExampleImages !== false;
|
|
||||||
|
|
||||||
// Use .webp for images if optimization enabled, otherwise use original extension
|
|
||||||
const extension = optimizeImages ? 'webp' : remoteExt;
|
|
||||||
|
|
||||||
return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 渲染展示内容
|
|
||||||
* @param {Array} images - 要展示的图片/视频数组
|
* @param {Array} images - 要展示的图片/视频数组
|
||||||
* @param {string} modelHash - Model hash for identifying local files
|
* @param {Array} exampleFiles - Local example files already fetched
|
||||||
* @returns {string} HTML内容
|
* @returns {Promise<string>} HTML内容
|
||||||
*/
|
*/
|
||||||
export function renderShowcaseContent(images, modelHash) {
|
export function renderShowcaseContent(images, exampleFiles = []) {
|
||||||
if (!images?.length) return '<div class="no-examples">No example images available</div>';
|
if (!images?.length) return '<div class="no-examples">No example images available</div>';
|
||||||
|
|
||||||
// Filter images based on SFW setting
|
// Filter images based on SFW setting
|
||||||
@@ -82,21 +59,30 @@ export function renderShowcaseContent(images, modelHash) {
|
|||||||
${hiddenNotification}
|
${hiddenNotification}
|
||||||
<div class="carousel-container">
|
<div class="carousel-container">
|
||||||
${filteredImages.map((img, index) => {
|
${filteredImages.map((img, index) => {
|
||||||
// Try to get local URL for the example image
|
// Find matching file in our list of actual files
|
||||||
const localUrl = getLocalExampleImageUrl(img, index, modelHash);
|
let localFile = null;
|
||||||
|
if (exampleFiles.length > 0) {
|
||||||
|
// Try to find the corresponding file by index first
|
||||||
|
localFile = exampleFiles.find(file => {
|
||||||
|
const match = file.name.match(/image_(\d+)\./);
|
||||||
|
return match && parseInt(match[1]) === index;
|
||||||
|
});
|
||||||
|
|
||||||
// Create data attributes for both remote and local URLs
|
// If not found by index, just use the same position in the array if available
|
||||||
const remoteUrl = img.url;
|
if (!localFile && index < exampleFiles.length) {
|
||||||
const dataRemoteSrc = remoteUrl;
|
localFile = exampleFiles[index];
|
||||||
const dataLocalSrc = localUrl;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 计算适当的展示高度:
|
const remoteUrl = img.url || '';
|
||||||
// 1. 保持原始宽高比
|
const localUrl = localFile ? localFile.path : '';
|
||||||
// 2. 限制最大高度为视窗高度的60%
|
const isVideo = localFile ? localFile.is_video :
|
||||||
// 3. 确保最小高度为容器宽度的40%
|
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
|
||||||
|
|
||||||
|
// 计算适当的展示高度
|
||||||
const aspectRatio = (img.height / img.width) * 100;
|
const aspectRatio = (img.height / img.width) * 100;
|
||||||
const containerWidth = 800; // modal content的最大宽度
|
const containerWidth = 800;
|
||||||
const minHeightPercent = 40; // 最小高度为容器宽度的40%
|
const minHeightPercent = 40;
|
||||||
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||||
const heightPercent = Math.max(
|
const heightPercent = Math.max(
|
||||||
minHeightPercent,
|
minHeightPercent,
|
||||||
@@ -129,96 +115,98 @@ export function renderShowcaseContent(images, modelHash) {
|
|||||||
const cfgScale = meta.cfgScale || '';
|
const cfgScale = meta.cfgScale || '';
|
||||||
const clipSkip = meta.clipSkip || '';
|
const clipSkip = meta.clipSkip || '';
|
||||||
|
|
||||||
// Check if we have any meaningful generation parameters
|
|
||||||
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
||||||
const hasPrompts = prompt || negativePrompt;
|
const hasPrompts = prompt || negativePrompt;
|
||||||
|
|
||||||
// If no metadata available, show a message
|
const metadataPanel = generateMetadataPanel(
|
||||||
if (!hasParams && !hasPrompts) {
|
hasParams, hasPrompts,
|
||||||
const metadataPanel = `
|
prompt, negativePrompt,
|
||||||
<div class="image-metadata-panel">
|
size, seed, model, steps, sampler, cfgScale, clipSkip
|
||||||
<div class="metadata-content">
|
);
|
||||||
<div class="no-metadata-message">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<span>No generation parameters available</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (img.type === 'video') {
|
if (isVideo) {
|
||||||
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
|
return generateVideoWrapper(
|
||||||
}
|
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||||
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
|
localUrl, remoteUrl
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return generateImageWrapper(
|
||||||
// Create a data attribute with the prompt for copying instead of trying to handle it in the onclick
|
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||||
// This avoids issues with quotes and special characters
|
localUrl, remoteUrl
|
||||||
const promptIndex = Math.random().toString(36).substring(2, 15);
|
);
|
||||||
const negPromptIndex = Math.random().toString(36).substring(2, 15);
|
|
||||||
|
|
||||||
// Create parameter tags HTML
|
|
||||||
const paramTags = `
|
|
||||||
<div class="params-tags">
|
|
||||||
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
|
||||||
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
|
||||||
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
|
||||||
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
|
||||||
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
|
||||||
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
|
||||||
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Metadata panel HTML
|
|
||||||
const metadataPanel = `
|
|
||||||
<div class="image-metadata-panel">
|
|
||||||
<div class="metadata-content">
|
|
||||||
${hasParams ? paramTags : ''}
|
|
||||||
${!hasParams && !hasPrompts ? `
|
|
||||||
<div class="no-metadata-message">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
<span>No generation parameters available</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
${prompt ? `
|
|
||||||
<div class="metadata-row prompt-row">
|
|
||||||
<span class="metadata-label">Prompt:</span>
|
|
||||||
<div class="metadata-prompt-wrapper">
|
|
||||||
<div class="metadata-prompt">${prompt}</div>
|
|
||||||
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
|
|
||||||
` : ''}
|
|
||||||
${negativePrompt ? `
|
|
||||||
<div class="metadata-row prompt-row">
|
|
||||||
<span class="metadata-label">Negative Prompt:</span>
|
|
||||||
<div class="metadata-prompt-wrapper">
|
|
||||||
<div class="metadata-prompt">${negativePrompt}</div>
|
|
||||||
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
|
|
||||||
<i class="fas fa-copy"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (img.type === 'video') {
|
|
||||||
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
|
|
||||||
}
|
|
||||||
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
|
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate metadata panel HTML
|
||||||
|
*/
|
||||||
|
function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) {
|
||||||
|
// Create unique IDs for prompt copying
|
||||||
|
const promptIndex = Math.random().toString(36).substring(2, 15);
|
||||||
|
const negPromptIndex = Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
|
let content = '<div class="image-metadata-panel"><div class="metadata-content">';
|
||||||
|
|
||||||
|
if (hasParams) {
|
||||||
|
content += `
|
||||||
|
<div class="params-tags">
|
||||||
|
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
||||||
|
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
||||||
|
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
||||||
|
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
||||||
|
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
||||||
|
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
||||||
|
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasParams && !hasPrompts) {
|
||||||
|
content += `
|
||||||
|
<div class="no-metadata-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
<span>No generation parameters available</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt) {
|
||||||
|
content += `
|
||||||
|
<div class="metadata-row prompt-row">
|
||||||
|
<span class="metadata-label">Prompt:</span>
|
||||||
|
<div class="metadata-prompt-wrapper">
|
||||||
|
<div class="metadata-prompt">${prompt}</div>
|
||||||
|
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (negativePrompt) {
|
||||||
|
content += `
|
||||||
|
<div class="metadata-row prompt-row">
|
||||||
|
<span class="metadata-label">Negative Prompt:</span>
|
||||||
|
<div class="metadata-prompt-wrapper">
|
||||||
|
<div class="metadata-prompt">${negativePrompt}</div>
|
||||||
|
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
content += '</div></div>';
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成视频包装HTML
|
* 生成视频包装HTML
|
||||||
*/
|
*/
|
||||||
@@ -283,422 +271,10 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Use the shared setupShowcaseScroll function with the correct modal ID
|
||||||
* 切换展示区域的显示状态
|
export { setupShowcaseScroll, scrollToTop, toggleShowcase };
|
||||||
*/
|
|
||||||
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');
|
// Initialize the showcase scroll when this module is imported
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (isCollapsed) {
|
setupShowcaseScroll('loraModal');
|
||||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
});
|
||||||
indicator.textContent = `Scroll or click to hide examples`;
|
|
||||||
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
|
|
||||||
initLazyLoading(carousel);
|
|
||||||
|
|
||||||
// Initialize NSFW content blur toggle handlers
|
|
||||||
initNsfwBlurHandlers(carousel);
|
|
||||||
|
|
||||||
// Initialize metadata panel interaction handlers
|
|
||||||
initMetadataPanelHandlers(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化元数据面板交互处理
|
|
||||||
*/
|
|
||||||
function initMetadataPanelHandlers(container) {
|
|
||||||
// Find all media wrappers
|
|
||||||
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
|
||||||
|
|
||||||
mediaWrappers.forEach(wrapper => {
|
|
||||||
// Get the metadata panel and media element (img or video)
|
|
||||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
|
||||||
const mediaElement = wrapper.querySelector('img, video');
|
|
||||||
|
|
||||||
if (!metadataPanel || !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 when over media content
|
|
||||||
if (isOverMedia || isOverMetadataPanel) {
|
|
||||||
metadataPanel.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
metadataPanel.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
wrapper.addEventListener('mouseleave', () => {
|
|
||||||
// Only hide panel when mouse leaves the wrapper and not over the metadata panel
|
|
||||||
if (!isOverMetadataPanel) {
|
|
||||||
metadataPanel.classList.remove('visible');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add mouse enter/leave events for the metadata panel itself
|
|
||||||
metadataPanel.addEventListener('mouseenter', () => {
|
|
||||||
isOverMetadataPanel = true;
|
|
||||||
metadataPanel.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');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent events from the metadata panel from bubbling
|
|
||||||
metadataPanel.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle copy prompt button clicks
|
|
||||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
|
||||||
copyBtns.forEach(copyBtn => {
|
|
||||||
const promptIndex = copyBtn.dataset.promptIndex;
|
|
||||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
|
||||||
|
|
||||||
copyBtn.addEventListener('click', async (e) => {
|
|
||||||
e.stopPropagation(); // Prevent bubbling
|
|
||||||
|
|
||||||
if (!promptElement) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Copy failed:', err);
|
|
||||||
showToast('Copy failed', 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent scrolling in the metadata panel from scrolling the whole modal
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the actual rendered rectangle of a media element with object-fit: contain
|
|
||||||
* @param {HTMLElement} mediaElement - The img or video element
|
|
||||||
* @param {number} containerWidth - Width of the container
|
|
||||||
* @param {number} containerHeight - Height of the container
|
|
||||||
* @returns {Object} - Rect with left, top, right, bottom coordinates
|
|
||||||
*/
|
|
||||||
function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
|
|
||||||
// Get natural dimensions of the media
|
|
||||||
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
|
|
||||||
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
|
|
||||||
|
|
||||||
if (!naturalWidth || !naturalHeight) {
|
|
||||||
// Fallback if dimensions cannot be determined
|
|
||||||
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate aspect ratios
|
|
||||||
const containerRatio = containerWidth / containerHeight;
|
|
||||||
const mediaRatio = naturalWidth / naturalHeight;
|
|
||||||
|
|
||||||
let renderedWidth, renderedHeight, left = 0, top = 0;
|
|
||||||
|
|
||||||
// Apply object-fit: contain logic
|
|
||||||
if (containerRatio > mediaRatio) {
|
|
||||||
// Container is wider than media - will have empty space on sides
|
|
||||||
renderedHeight = containerHeight;
|
|
||||||
renderedWidth = renderedHeight * mediaRatio;
|
|
||||||
left = (containerWidth - renderedWidth) / 2;
|
|
||||||
} else {
|
|
||||||
// Container is taller than media - will have empty space top/bottom
|
|
||||||
renderedWidth = containerWidth;
|
|
||||||
renderedHeight = renderedWidth / mediaRatio;
|
|
||||||
top = (containerHeight - renderedHeight) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
left,
|
|
||||||
top,
|
|
||||||
right: left + renderedWidth,
|
|
||||||
bottom: top + renderedHeight
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化模糊切换处理
|
|
||||||
*/
|
|
||||||
function initNsfwBlurHandlers(container) {
|
|
||||||
// Handle toggle blur buttons
|
|
||||||
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
|
|
||||||
toggleButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const wrapper = btn.closest('.media-wrapper');
|
|
||||||
const media = wrapper.querySelector('img, video');
|
|
||||||
const isBlurred = media.classList.toggle('blurred');
|
|
||||||
const icon = btn.querySelector('i');
|
|
||||||
|
|
||||||
// Update the icon based on blur state
|
|
||||||
if (isBlurred) {
|
|
||||||
icon.className = 'fas fa-eye';
|
|
||||||
} else {
|
|
||||||
icon.className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle the overlay visibility
|
|
||||||
const overlay = wrapper.querySelector('.nsfw-overlay');
|
|
||||||
if (overlay) {
|
|
||||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle "Show" buttons in overlays
|
|
||||||
const showButtons = container.querySelectorAll('.show-content-btn');
|
|
||||||
showButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const wrapper = btn.closest('.media-wrapper');
|
|
||||||
const media = wrapper.querySelector('img, video');
|
|
||||||
media.classList.remove('blurred');
|
|
||||||
|
|
||||||
// Update the toggle button icon
|
|
||||||
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the overlay
|
|
||||||
const overlay = wrapper.querySelector('.nsfw-overlay');
|
|
||||||
if (overlay) {
|
|
||||||
overlay.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化延迟加载
|
|
||||||
*/
|
|
||||||
function initLazyLoading(container) {
|
|
||||||
const lazyElements = container.querySelectorAll('.lazy');
|
|
||||||
|
|
||||||
const lazyLoad = (element) => {
|
|
||||||
const localSrc = element.dataset.localSrc;
|
|
||||||
const remoteSrc = element.dataset.remoteSrc;
|
|
||||||
|
|
||||||
// Check if element is an image or video
|
|
||||||
if (element.tagName.toLowerCase() === 'video') {
|
|
||||||
// Try local first, then remote
|
|
||||||
tryLocalOrFallbackToRemote(element, localSrc, remoteSrc);
|
|
||||||
} else {
|
|
||||||
// For images, we'll use an Image object to test if local file exists
|
|
||||||
tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.classList.remove('lazy');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to load local image first, fall back to remote if local fails
|
|
||||||
const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => {
|
|
||||||
// Only try local if we have a local path
|
|
||||||
if (localSrc) {
|
|
||||||
const testImg = new Image();
|
|
||||||
testImg.onload = () => {
|
|
||||||
// Local image loaded successfully
|
|
||||||
imgElement.src = localSrc;
|
|
||||||
};
|
|
||||||
testImg.onerror = () => {
|
|
||||||
// Local image failed, use remote
|
|
||||||
imgElement.src = remoteSrc;
|
|
||||||
};
|
|
||||||
// Start loading test image
|
|
||||||
testImg.src = localSrc;
|
|
||||||
} else {
|
|
||||||
// No local path, use remote directly
|
|
||||||
imgElement.src = remoteSrc;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try to load local video first, fall back to remote if local fails
|
|
||||||
const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => {
|
|
||||||
// Only try local if we have a local path
|
|
||||||
if (localSrc) {
|
|
||||||
// Try to fetch local file headers to see if it exists
|
|
||||||
fetch(localSrc, { method: 'HEAD' })
|
|
||||||
.then(response => {
|
|
||||||
if (response.ok) {
|
|
||||||
// Local video exists, use it
|
|
||||||
videoElement.src = localSrc;
|
|
||||||
videoElement.querySelector('source').src = localSrc;
|
|
||||||
} else {
|
|
||||||
// Local video doesn't exist, use remote
|
|
||||||
videoElement.src = remoteSrc;
|
|
||||||
videoElement.querySelector('source').src = remoteSrc;
|
|
||||||
}
|
|
||||||
videoElement.load();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Error fetching, use remote
|
|
||||||
videoElement.src = remoteSrc;
|
|
||||||
videoElement.querySelector('source').src = remoteSrc;
|
|
||||||
videoElement.load();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// No local path, use remote directly
|
|
||||||
videoElement.src = remoteSrc;
|
|
||||||
videoElement.querySelector('source').src = remoteSrc;
|
|
||||||
videoElement.load();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
lazyLoad(entry.target);
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
lazyElements.forEach(element => observer.observe(element));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置展示区域的滚动处理
|
|
||||||
*/
|
|
||||||
export function setupShowcaseScroll() {
|
|
||||||
// Add event listener to document for wheel events
|
|
||||||
document.addEventListener('wheel', (event) => {
|
|
||||||
// Find the active modal content
|
|
||||||
const modalContent = document.querySelector('#loraModal .modal-content');
|
|
||||||
if (!modalContent) return;
|
|
||||||
|
|
||||||
const showcase = modalContent.querySelector('.showcase-section');
|
|
||||||
if (!showcase) return;
|
|
||||||
|
|
||||||
const carousel = showcase.querySelector('.carousel');
|
|
||||||
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
|
||||||
|
|
||||||
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
|
||||||
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
|
||||||
|
|
||||||
if (isNearBottom) {
|
|
||||||
toggleShowcase(scrollIndicator);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
// Use MutationObserver instead of deprecated DOMNodeInserted
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
if (mutation.type === 'childList' && mutation.addedNodes.length) {
|
|
||||||
// Check if loraModal content was added
|
|
||||||
const loraModal = document.getElementById('loraModal');
|
|
||||||
if (loraModal && loraModal.querySelector('.modal-content')) {
|
|
||||||
setupBackToTopButton(loraModal.querySelector('.modal-content'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start observing the document body for changes
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
// Also try to set up the button immediately in case the modal is already open
|
|
||||||
const modalContent = document.querySelector('#loraModal .modal-content');
|
|
||||||
if (modalContent) {
|
|
||||||
setupBackToTopButton(modalContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置返回顶部按钮
|
|
||||||
*/
|
|
||||||
function setupBackToTopButton(modalContent) {
|
|
||||||
// Remove any existing scroll listeners to avoid duplicates
|
|
||||||
modalContent.onscroll = null;
|
|
||||||
|
|
||||||
// Add new scroll listener
|
|
||||||
modalContent.addEventListener('scroll', () => {
|
|
||||||
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
|
||||||
if (backToTopBtn) {
|
|
||||||
if (modalContent.scrollTop > 300) {
|
|
||||||
backToTopBtn.classList.add('visible');
|
|
||||||
} else {
|
|
||||||
backToTopBtn.classList.remove('visible');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Trigger a scroll event to check initial position
|
|
||||||
modalContent.dispatchEvent(new Event('scroll'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 滚动到顶部
|
|
||||||
*/
|
|
||||||
export function scrollToTop(button) {
|
|
||||||
const modalContent = button.closest('.modal-content');
|
|
||||||
if (modalContent) {
|
|
||||||
modalContent.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,15 +1,181 @@
|
|||||||
/**
|
/**
|
||||||
* TriggerWords.js
|
* TriggerWords.js
|
||||||
* 处理LoRA模型触发词相关的功能模块
|
* Module that handles trigger word functionality for LoRA models
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||||
import { saveModelMetadata } from '../../api/loraApi.js';
|
import { saveModelMetadata } from '../../api/loraApi.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染触发词
|
* Fetch trained words for a model
|
||||||
* @param {Array} words - 触发词数组
|
* @param {string} filePath - Path to the model file
|
||||||
* @param {string} filePath - 文件路径
|
* @returns {Promise<Object>} - Object with trained words and class tokens
|
||||||
* @returns {string} HTML内容
|
*/
|
||||||
|
async function fetchTrainedWords(filePath) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/trained-words?file_path=${encodeURIComponent(filePath)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
return {
|
||||||
|
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
|
||||||
|
classTokens: data.class_tokens // Can be null or a string
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Failed to fetch trained words');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching trained words:', error);
|
||||||
|
showToast('Could not load trained words', 'error');
|
||||||
|
return { trainedWords: [], classTokens: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create suggestion dropdown with trained words as tags
|
||||||
|
* @param {Array} trainedWords - Array of [word, frequency] pairs
|
||||||
|
* @param {string|null} classTokens - Class tokens from training
|
||||||
|
* @param {Array} existingWords - Already added trigger words
|
||||||
|
* @returns {HTMLElement} - Dropdown element
|
||||||
|
*/
|
||||||
|
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
|
||||||
|
const dropdown = document.createElement('div');
|
||||||
|
dropdown.className = 'trained-words-dropdown';
|
||||||
|
|
||||||
|
// Create header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'trained-words-header';
|
||||||
|
|
||||||
|
// No suggestions case
|
||||||
|
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
|
||||||
|
header.innerHTML = '<span>No suggestions available</span>';
|
||||||
|
dropdown.appendChild(header);
|
||||||
|
dropdown.innerHTML += '<div class="no-trained-words">No trained words or class tokens found in this model. You can manually enter trigger words.</div>';
|
||||||
|
return dropdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort trained words by frequency (highest first) if available
|
||||||
|
if (trainedWords && trainedWords.length > 0) {
|
||||||
|
trainedWords.sort((a, b) => b[1] - a[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add class tokens section if available
|
||||||
|
if (classTokens) {
|
||||||
|
// Add class tokens header
|
||||||
|
const classTokensHeader = document.createElement('div');
|
||||||
|
classTokensHeader.className = 'trained-words-header';
|
||||||
|
classTokensHeader.innerHTML = `
|
||||||
|
<span>Class Token</span>
|
||||||
|
<small>Add to your prompt for best results</small>
|
||||||
|
`;
|
||||||
|
dropdown.appendChild(classTokensHeader);
|
||||||
|
|
||||||
|
// Add class tokens container
|
||||||
|
const classTokensContainer = document.createElement('div');
|
||||||
|
classTokensContainer.className = 'class-tokens-container';
|
||||||
|
|
||||||
|
// Create a special item for the class token
|
||||||
|
const tokenItem = document.createElement('div');
|
||||||
|
tokenItem.className = `trained-word-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
|
||||||
|
tokenItem.title = `Class token: ${classTokens}`;
|
||||||
|
tokenItem.innerHTML = `
|
||||||
|
<span class="trained-word-text">${classTokens}</span>
|
||||||
|
<div class="trained-word-meta">
|
||||||
|
<span class="token-badge">Class Token</span>
|
||||||
|
${existingWords.includes(classTokens) ?
|
||||||
|
'<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add click handler if not already added
|
||||||
|
if (!existingWords.includes(classTokens)) {
|
||||||
|
tokenItem.addEventListener('click', () => {
|
||||||
|
// Automatically add this word
|
||||||
|
addNewTriggerWord(classTokens);
|
||||||
|
|
||||||
|
// Also populate the input field for potential editing
|
||||||
|
const input = document.querySelector('.new-trigger-word-input');
|
||||||
|
if (input) input.value = classTokens;
|
||||||
|
|
||||||
|
// Focus on the input
|
||||||
|
if (input) input.focus();
|
||||||
|
|
||||||
|
// Update dropdown without removing it
|
||||||
|
updateTrainedWordsDropdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
classTokensContainer.appendChild(tokenItem);
|
||||||
|
dropdown.appendChild(classTokensContainer);
|
||||||
|
|
||||||
|
// Add separator if we also have trained words
|
||||||
|
if (trainedWords && trainedWords.length > 0) {
|
||||||
|
const separator = document.createElement('div');
|
||||||
|
separator.className = 'dropdown-separator';
|
||||||
|
dropdown.appendChild(separator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add trained words header if we have any
|
||||||
|
if (trainedWords && trainedWords.length > 0) {
|
||||||
|
header.innerHTML = `
|
||||||
|
<span>Word Suggestions</span>
|
||||||
|
<small>${trainedWords.length} words found</small>
|
||||||
|
`;
|
||||||
|
dropdown.appendChild(header);
|
||||||
|
|
||||||
|
// Create tag container for trained words
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'trained-words-container';
|
||||||
|
|
||||||
|
// Add each trained word as a tag
|
||||||
|
trainedWords.forEach(([word, frequency]) => {
|
||||||
|
const isAdded = existingWords.includes(word);
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `trained-word-item ${isAdded ? 'already-added' : ''}`;
|
||||||
|
item.title = word; // Show full word on hover if truncated
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="trained-word-text">${word}</span>
|
||||||
|
<div class="trained-word-meta">
|
||||||
|
<span class="trained-word-freq">${frequency}</span>
|
||||||
|
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!isAdded) {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
// Automatically add this word
|
||||||
|
addNewTriggerWord(word);
|
||||||
|
|
||||||
|
// Also populate the input field for potential editing
|
||||||
|
const input = document.querySelector('.new-trigger-word-input');
|
||||||
|
if (input) input.value = word;
|
||||||
|
|
||||||
|
// Focus on the input
|
||||||
|
if (input) input.focus();
|
||||||
|
|
||||||
|
// Update dropdown without removing it
|
||||||
|
updateTrainedWordsDropdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropdown.appendChild(container);
|
||||||
|
} else if (!classTokens) {
|
||||||
|
// If we have neither class tokens nor trained words
|
||||||
|
dropdown.innerHTML += '<div class="no-trained-words">No word suggestions found in this model. You can manually enter trigger words.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return dropdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render trigger words
|
||||||
|
* @param {Array} words - Array of trigger words
|
||||||
|
* @param {string} filePath - File path
|
||||||
|
* @returns {string} HTML content
|
||||||
*/
|
*/
|
||||||
export function renderTriggerWords(words, filePath) {
|
export function renderTriggerWords(words, filePath) {
|
||||||
if (!words.length) return `
|
if (!words.length) return `
|
||||||
@@ -25,17 +191,12 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
<div class="trigger-words-tags" style="display:none;"></div>
|
<div class="trigger-words-tags" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="trigger-words-edit-controls" style="display:none;">
|
<div class="trigger-words-edit-controls" style="display:none;">
|
||||||
<button class="add-trigger-word-btn" title="Add a trigger word">
|
|
||||||
<i class="fas fa-plus"></i> Add
|
|
||||||
</button>
|
|
||||||
<button class="save-trigger-words-btn" title="Save changes">
|
<button class="save-trigger-words-btn" title="Save changes">
|
||||||
<i class="fas fa-save"></i> Save
|
<i class="fas fa-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="add-trigger-word-form" style="display:none;">
|
<div class="add-trigger-word-form" style="display:none;">
|
||||||
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
|
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
|
||||||
<button class="confirm-add-trigger-word-btn">Add</button>
|
|
||||||
<button class="cancel-add-trigger-word-btn">Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -64,43 +225,53 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="trigger-words-edit-controls" style="display:none;">
|
<div class="trigger-words-edit-controls" style="display:none;">
|
||||||
<button class="add-trigger-word-btn" title="Add a trigger word">
|
|
||||||
<i class="fas fa-plus"></i> Add
|
|
||||||
</button>
|
|
||||||
<button class="save-trigger-words-btn" title="Save changes">
|
<button class="save-trigger-words-btn" title="Save changes">
|
||||||
<i class="fas fa-save"></i> Save
|
<i class="fas fa-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="add-trigger-word-form" style="display:none;">
|
<div class="add-trigger-word-form" style="display:none;">
|
||||||
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
|
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
|
||||||
<button class="confirm-add-trigger-word-btn">Add</button>
|
|
||||||
<button class="cancel-add-trigger-word-btn">Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置触发词编辑模式
|
* Set up trigger words edit mode
|
||||||
*/
|
*/
|
||||||
export function setupTriggerWordsEditMode() {
|
export function setupTriggerWordsEditMode() {
|
||||||
|
// Store trained words data
|
||||||
|
let trainedWordsList = [];
|
||||||
|
let classTokensValue = null;
|
||||||
|
let isTrainedWordsLoaded = false;
|
||||||
|
// Store original trigger words for restoring on cancel
|
||||||
|
let originalTriggerWords = [];
|
||||||
|
|
||||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||||
if (!editBtn) return;
|
if (!editBtn) return;
|
||||||
|
|
||||||
editBtn.addEventListener('click', function() {
|
editBtn.addEventListener('click', async function() {
|
||||||
const triggerWordsSection = this.closest('.trigger-words');
|
const triggerWordsSection = this.closest('.trigger-words');
|
||||||
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
||||||
|
const filePath = this.dataset.filePath;
|
||||||
|
|
||||||
// Toggle edit mode UI elements
|
// Toggle edit mode UI elements
|
||||||
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||||
const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls');
|
const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls');
|
||||||
|
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
|
||||||
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
||||||
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
||||||
this.title = "Cancel editing";
|
this.title = "Cancel editing";
|
||||||
|
|
||||||
|
// Store original trigger words for potential restoration
|
||||||
|
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
|
// Show edit controls and input form
|
||||||
editControls.style.display = 'flex';
|
editControls.style.display = 'flex';
|
||||||
|
addForm.style.display = 'flex';
|
||||||
|
|
||||||
// If we have no trigger words yet, hide the "No trigger word needed" text
|
// If we have no trigger words yet, hide the "No trigger word needed" text
|
||||||
// and show the empty tags container
|
// and show the empty tags container
|
||||||
@@ -112,13 +283,67 @@ export function setupTriggerWordsEditMode() {
|
|||||||
// Disable click-to-copy and show delete buttons
|
// Disable click-to-copy and show delete buttons
|
||||||
triggerWordTags.forEach(tag => {
|
triggerWordTags.forEach(tag => {
|
||||||
tag.onclick = null;
|
tag.onclick = null;
|
||||||
tag.querySelector('.trigger-word-copy').style.display = 'none';
|
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
tag.querySelector('.delete-trigger-word-btn').style.display = 'block';
|
const deleteBtn = tag.querySelector('.delete-trigger-word-btn');
|
||||||
|
|
||||||
|
if (copyIcon) copyIcon.style.display = 'none';
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.style.display = 'block';
|
||||||
|
|
||||||
|
// Re-attach event listener to ensure it works every time
|
||||||
|
// First remove any existing listeners to avoid duplication
|
||||||
|
deleteBtn.removeEventListener('click', deleteTriggerWord);
|
||||||
|
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load trained words and display dropdown when entering edit mode
|
||||||
|
// Add loading indicator
|
||||||
|
const loadingIndicator = document.createElement('div');
|
||||||
|
loadingIndicator.className = 'trained-words-loading';
|
||||||
|
loadingIndicator.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading suggestions...';
|
||||||
|
addForm.appendChild(loadingIndicator);
|
||||||
|
|
||||||
|
// Get currently added trigger words
|
||||||
|
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||||
|
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
|
// Asynchronously load trained words if not already loaded
|
||||||
|
if (!isTrainedWordsLoaded) {
|
||||||
|
const result = await fetchTrainedWords(filePath);
|
||||||
|
trainedWordsList = result.trainedWords;
|
||||||
|
classTokensValue = result.classTokens;
|
||||||
|
isTrainedWordsLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove loading indicator
|
||||||
|
loadingIndicator.remove();
|
||||||
|
|
||||||
|
// Create and display suggestion dropdown
|
||||||
|
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
|
||||||
|
addForm.appendChild(dropdown);
|
||||||
|
|
||||||
|
// Focus the input
|
||||||
|
addForm.querySelector('input').focus();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||||
this.title = "Edit trigger words";
|
this.title = "Edit trigger words";
|
||||||
|
|
||||||
|
// Hide edit controls and input form
|
||||||
editControls.style.display = 'none';
|
editControls.style.display = 'none';
|
||||||
|
addForm.style.display = 'none';
|
||||||
|
|
||||||
|
// Check if we're exiting edit mode due to "Save" or "Cancel"
|
||||||
|
if (!this.dataset.skipRestore) {
|
||||||
|
// If canceling, restore original trigger words
|
||||||
|
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
|
||||||
|
} else {
|
||||||
|
// If saving, reset UI state on current trigger words
|
||||||
|
resetTriggerWordsUIState(triggerWordsSection);
|
||||||
|
// Reset the skip restore flag
|
||||||
|
delete this.dataset.skipRestore;
|
||||||
|
}
|
||||||
|
|
||||||
// If we have no trigger words, show the "No trigger word needed" text
|
// If we have no trigger words, show the "No trigger word needed" text
|
||||||
// and hide the empty tags container
|
// and hide the empty tags container
|
||||||
@@ -128,57 +353,26 @@ export function setupTriggerWordsEditMode() {
|
|||||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original state
|
// Remove dropdown if present
|
||||||
triggerWordTags.forEach(tag => {
|
const dropdown = document.querySelector('.trained-words-dropdown');
|
||||||
const word = tag.dataset.word;
|
if (dropdown) dropdown.remove();
|
||||||
tag.onclick = () => copyTriggerWord(word);
|
|
||||||
tag.querySelector('.trigger-word-copy').style.display = 'flex';
|
|
||||||
tag.querySelector('.delete-trigger-word-btn').style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide add form if open
|
|
||||||
triggerWordsSection.querySelector('.add-trigger-word-form').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up add trigger word button
|
// Set up input for adding trigger words
|
||||||
const addBtn = document.querySelector('.add-trigger-word-btn');
|
|
||||||
if (addBtn) {
|
|
||||||
addBtn.addEventListener('click', function() {
|
|
||||||
const triggerWordsSection = this.closest('.trigger-words');
|
|
||||||
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
|
|
||||||
addForm.style.display = 'flex';
|
|
||||||
addForm.querySelector('input').focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up confirm and cancel add buttons
|
|
||||||
const confirmAddBtn = document.querySelector('.confirm-add-trigger-word-btn');
|
|
||||||
const cancelAddBtn = document.querySelector('.cancel-add-trigger-word-btn');
|
|
||||||
const triggerWordInput = document.querySelector('.new-trigger-word-input');
|
const triggerWordInput = document.querySelector('.new-trigger-word-input');
|
||||||
|
|
||||||
if (confirmAddBtn && triggerWordInput) {
|
if (triggerWordInput) {
|
||||||
confirmAddBtn.addEventListener('click', function() {
|
|
||||||
addNewTriggerWord(triggerWordInput.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add keydown event to input
|
// Add keydown event to input
|
||||||
triggerWordInput.addEventListener('keydown', function(e) {
|
triggerWordInput.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addNewTriggerWord(this.value);
|
addNewTriggerWord(this.value);
|
||||||
|
this.value = ''; // Clear input after adding
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cancelAddBtn) {
|
|
||||||
cancelAddBtn.addEventListener('click', function() {
|
|
||||||
const addForm = this.closest('.add-trigger-word-form');
|
|
||||||
addForm.style.display = 'none';
|
|
||||||
addForm.querySelector('input').value = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up save button
|
// Set up save button
|
||||||
const saveBtn = document.querySelector('.save-trigger-words-btn');
|
const saveBtn = document.querySelector('.save-trigger-words-btn');
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
@@ -187,17 +381,92 @@ export function setupTriggerWordsEditMode() {
|
|||||||
|
|
||||||
// Set up delete buttons
|
// Set up delete buttons
|
||||||
document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => {
|
document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', function(e) {
|
// Remove any existing listeners to avoid duplication
|
||||||
e.stopPropagation();
|
btn.removeEventListener('click', deleteTriggerWord);
|
||||||
const tag = this.closest('.trigger-word-tag');
|
btn.addEventListener('click', deleteTriggerWord);
|
||||||
tag.remove();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加新触发词
|
* Delete trigger word event handler
|
||||||
* @param {string} word - 要添加的触发词
|
* @param {Event} e - Click event
|
||||||
|
*/
|
||||||
|
function deleteTriggerWord(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const tag = this.closest('.trigger-word-tag');
|
||||||
|
tag.remove();
|
||||||
|
|
||||||
|
// Update status of items in the trained words dropdown
|
||||||
|
updateTrainedWordsDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset UI state for trigger words after saving
|
||||||
|
* @param {HTMLElement} section - The trigger words section
|
||||||
|
*/
|
||||||
|
function resetTriggerWordsUIState(section) {
|
||||||
|
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
|
||||||
|
|
||||||
|
triggerWordTags.forEach(tag => {
|
||||||
|
const word = tag.dataset.word;
|
||||||
|
const copyIcon = tag.querySelector('.trigger-word-copy');
|
||||||
|
const deleteBtn = tag.querySelector('.delete-trigger-word-btn');
|
||||||
|
|
||||||
|
// Restore click-to-copy functionality
|
||||||
|
tag.onclick = () => copyTriggerWord(word);
|
||||||
|
|
||||||
|
// Show copy icon, hide delete button
|
||||||
|
if (copyIcon) copyIcon.style.display = '';
|
||||||
|
if (deleteBtn) deleteBtn.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore original trigger words when canceling edit
|
||||||
|
* @param {HTMLElement} section - The trigger words section
|
||||||
|
* @param {Array} originalWords - Original trigger words
|
||||||
|
*/
|
||||||
|
function restoreOriginalTriggerWords(section, originalWords) {
|
||||||
|
const tagsContainer = section.querySelector('.trigger-words-tags');
|
||||||
|
const noTriggerWords = section.querySelector('.no-trigger-words');
|
||||||
|
|
||||||
|
if (!tagsContainer) return;
|
||||||
|
|
||||||
|
// Clear current tags
|
||||||
|
tagsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (originalWords.length === 0) {
|
||||||
|
if (noTriggerWords) noTriggerWords.style.display = '';
|
||||||
|
tagsContainer.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide "no trigger words" message
|
||||||
|
if (noTriggerWords) noTriggerWords.style.display = 'none';
|
||||||
|
tagsContainer.style.display = 'flex';
|
||||||
|
|
||||||
|
// Recreate original tags
|
||||||
|
originalWords.forEach(word => {
|
||||||
|
const tag = document.createElement('div');
|
||||||
|
tag.className = 'trigger-word-tag';
|
||||||
|
tag.dataset.word = word;
|
||||||
|
tag.onclick = () => copyTriggerWord(word);
|
||||||
|
tag.innerHTML = `
|
||||||
|
<span class="trigger-word-content">${word}</span>
|
||||||
|
<span class="trigger-word-copy">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</span>
|
||||||
|
<button class="delete-trigger-word-btn" style="display:none;" onclick="event.stopPropagation();">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
tagsContainer.appendChild(tag);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new trigger word
|
||||||
|
* @param {string} word - Trigger word to add
|
||||||
*/
|
*/
|
||||||
function addNewTriggerWord(word) {
|
function addNewTriggerWord(word) {
|
||||||
word = word.trim();
|
word = word.trim();
|
||||||
@@ -263,24 +532,79 @@ function addNewTriggerWord(word) {
|
|||||||
|
|
||||||
// Add event listener to delete button
|
// Add event listener to delete button
|
||||||
const deleteBtn = newTag.querySelector('.delete-trigger-word-btn');
|
const deleteBtn = newTag.querySelector('.delete-trigger-word-btn');
|
||||||
deleteBtn.addEventListener('click', function() {
|
deleteBtn.addEventListener('click', deleteTriggerWord);
|
||||||
newTag.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
tagsContainer.appendChild(newTag);
|
tagsContainer.appendChild(newTag);
|
||||||
|
|
||||||
// Clear and hide the input form
|
// Update status of items in the trained words dropdown
|
||||||
const triggerWordInput = document.querySelector('.new-trigger-word-input');
|
updateTrainedWordsDropdown();
|
||||||
triggerWordInput.value = '';
|
|
||||||
document.querySelector('.add-trigger-word-form').style.display = 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存触发词
|
* Update status of items in the trained words dropdown
|
||||||
|
*/
|
||||||
|
function updateTrainedWordsDropdown() {
|
||||||
|
const dropdown = document.querySelector('.trained-words-dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
|
||||||
|
// Get all current trigger words
|
||||||
|
const currentTags = document.querySelectorAll('.trigger-word-tag');
|
||||||
|
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
|
// Update status of each item in dropdown
|
||||||
|
dropdown.querySelectorAll('.trained-word-item').forEach(item => {
|
||||||
|
const wordText = item.querySelector('.trained-word-text').textContent;
|
||||||
|
const isAdded = existingWords.includes(wordText);
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
item.classList.add('already-added');
|
||||||
|
|
||||||
|
// Add indicator if it doesn't exist
|
||||||
|
let indicator = item.querySelector('.added-indicator');
|
||||||
|
if (!indicator) {
|
||||||
|
const meta = item.querySelector('.trained-word-meta');
|
||||||
|
indicator = document.createElement('span');
|
||||||
|
indicator.className = 'added-indicator';
|
||||||
|
indicator.innerHTML = '<i class="fas fa-check"></i>';
|
||||||
|
meta.appendChild(indicator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove click event
|
||||||
|
item.onclick = null;
|
||||||
|
} else {
|
||||||
|
// Re-enable items that are no longer in the list
|
||||||
|
item.classList.remove('already-added');
|
||||||
|
|
||||||
|
// Remove indicator if it exists
|
||||||
|
const indicator = item.querySelector('.added-indicator');
|
||||||
|
if (indicator) indicator.remove();
|
||||||
|
|
||||||
|
// Restore click event if not already set
|
||||||
|
if (!item.onclick) {
|
||||||
|
item.onclick = () => {
|
||||||
|
const word = item.querySelector('.trained-word-text').textContent;
|
||||||
|
addNewTriggerWord(word);
|
||||||
|
|
||||||
|
// Also populate the input field
|
||||||
|
const input = document.querySelector('.new-trigger-word-input');
|
||||||
|
if (input) input.value = word;
|
||||||
|
|
||||||
|
// Focus the input
|
||||||
|
if (input) input.focus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save trigger words
|
||||||
*/
|
*/
|
||||||
async function saveTriggerWords() {
|
async function saveTriggerWords() {
|
||||||
const filePath = document.querySelector('.edit-trigger-words-btn').dataset.filePath;
|
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||||
const triggerWordTags = document.querySelectorAll('.trigger-word-tag');
|
const filePath = editBtn.dataset.filePath;
|
||||||
|
const triggerWordsSection = editBtn.closest('.trigger-words');
|
||||||
|
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||||
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -289,9 +613,11 @@ async function saveTriggerWords() {
|
|||||||
civitai: { trainedWords: words }
|
civitai: { trainedWords: words }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update UI
|
// Set flag to skip restoring original words when exiting edit mode
|
||||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
editBtn.dataset.skipRestore = "true";
|
||||||
editBtn.click(); // Exit edit mode
|
|
||||||
|
// Exit edit mode without restoring original trigger words
|
||||||
|
editBtn.click();
|
||||||
|
|
||||||
// Update the LoRA card's dataset
|
// Update the LoRA card's dataset
|
||||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||||
@@ -316,8 +642,8 @@ async function saveTriggerWords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we saved an empty array and there's a no-trigger-words element, show it
|
// If we saved an empty array and there's a no-trigger-words element, show it
|
||||||
const noTriggerWords = document.querySelector('.no-trigger-words');
|
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
||||||
const tagsContainer = document.querySelector('.trigger-words-tags');
|
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
||||||
if (words.length === 0 && noTriggerWords) {
|
if (words.length === 0 && noTriggerWords) {
|
||||||
noTriggerWords.style.display = '';
|
noTriggerWords.style.display = '';
|
||||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||||
@@ -331,8 +657,8 @@ async function saveTriggerWords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复制触发词到剪贴板
|
* Copy a trigger word to clipboard
|
||||||
* @param {string} word - 要复制的触发词
|
* @param {string} word - Word to copy
|
||||||
*/
|
*/
|
||||||
window.copyTriggerWord = async function(word) {
|
window.copyTriggerWord = async function(word) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
|
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
|
||||||
*/
|
*/
|
||||||
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, getExampleImageFiles } from '../../utils/uiHelpers.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
||||||
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
||||||
@@ -18,13 +18,13 @@ import {
|
|||||||
import { saveModelMetadata } from '../../api/loraApi.js';
|
import { saveModelMetadata } from '../../api/loraApi.js';
|
||||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||||
import { updateLoraCard } from '../../utils/cardUpdater.js';
|
import { updateLoraCard } from '../../utils/cardUpdater.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示LoRA模型弹窗
|
* 显示LoRA模型弹窗
|
||||||
* @param {Object} lora - LoRA模型数据
|
* @param {Object} lora - LoRA模型数据
|
||||||
*/
|
*/
|
||||||
export function showLoraModal(lora) {
|
export function showLoraModal(lora) {
|
||||||
console.log('Lora data:', lora);
|
|
||||||
const escapedWords = lora.civitai?.trainedWords?.length ?
|
const escapedWords = lora.civitai?.trainedWords?.length ?
|
||||||
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export function showLoraModal(lora) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-item full-width">
|
<div class="info-item full-width">
|
||||||
<label>About this version</label>
|
<label>About this version</label>
|
||||||
<div class="description-text">${lora.description || 'N/A'}</div>
|
<div class="description-text">${lora.civitai?.description || 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +137,9 @@ export function showLoraModal(lora) {
|
|||||||
|
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<div id="showcase-tab" class="tab-pane active">
|
<div id="showcase-tab" class="tab-pane active">
|
||||||
${renderShowcaseContent(lora.civitai?.images, lora.sha256)}
|
<div class="example-images-loading">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> Loading example images...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="description-tab" class="tab-pane">
|
<div id="description-tab" class="tab-pane">
|
||||||
@@ -183,6 +185,70 @@ export function showLoraModal(lora) {
|
|||||||
|
|
||||||
// Load recipes for this Lora
|
// Load recipes for this Lora
|
||||||
loadRecipesForLora(lora.model_name, lora.sha256);
|
loadRecipesForLora(lora.model_name, lora.sha256);
|
||||||
|
|
||||||
|
// Load example images asynchronously
|
||||||
|
loadExampleImages(lora.civitai?.images, lora.sha256, lora.file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load example images asynchronously
|
||||||
|
* @param {Array} images - Array of image objects
|
||||||
|
* @param {string} modelHash - Model hash for fetching local files
|
||||||
|
* @param {string} filePath - File path for fetching local files
|
||||||
|
*/
|
||||||
|
async function loadExampleImages(images, modelHash, filePath) {
|
||||||
|
try {
|
||||||
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
|
if (!showcaseTab) return;
|
||||||
|
|
||||||
|
// First fetch local example files
|
||||||
|
let localFiles = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Choose endpoint based on centralized examples setting
|
||||||
|
const useCentralized = state.global.settings.useCentralizedExamples !== false;
|
||||||
|
const endpoint = useCentralized ? '/api/example-image-files' : '/api/model-example-files';
|
||||||
|
|
||||||
|
// Use different params based on endpoint
|
||||||
|
const params = useCentralized ?
|
||||||
|
`model_hash=${modelHash}` :
|
||||||
|
`file_path=${encodeURIComponent(filePath)}`;
|
||||||
|
|
||||||
|
const response = await fetch(`${endpoint}?${params}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
localFiles = result.files;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get example files:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then render with both remote images and local files
|
||||||
|
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
|
||||||
|
|
||||||
|
// Re-initialize the showcase event listeners
|
||||||
|
const carousel = showcaseTab.querySelector('.carousel');
|
||||||
|
if (carousel) {
|
||||||
|
// Only initialize if we actually have examples and they're expanded
|
||||||
|
if (!carousel.classList.contains('collapsed')) {
|
||||||
|
initLazyLoading(carousel);
|
||||||
|
initNsfwBlurHandlers(carousel);
|
||||||
|
initMetadataPanelHandlers(carousel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading example images:', error);
|
||||||
|
const showcaseTab = document.getElementById('showcase-tab');
|
||||||
|
if (showcaseTab) {
|
||||||
|
showcaseTab.innerHTML = `
|
||||||
|
<div class="error-message">
|
||||||
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
|
Error loading example images
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy file name function
|
// Copy file name function
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ 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 { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||||
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
import { helpManager } from './managers/HelpManager.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';
|
||||||
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
|
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
|
||||||
@@ -30,6 +31,7 @@ export class AppCore {
|
|||||||
window.modalManager = modalManager;
|
window.modalManager = modalManager;
|
||||||
window.settingsManager = settingsManager;
|
window.settingsManager = settingsManager;
|
||||||
window.exampleImagesManager = exampleImagesManager;
|
window.exampleImagesManager = exampleImagesManager;
|
||||||
|
window.helpManager = helpManager;
|
||||||
|
|
||||||
// Initialize UI components
|
// Initialize UI components
|
||||||
window.headerManager = new HeaderManager();
|
window.headerManager = new HeaderManager();
|
||||||
@@ -38,6 +40,8 @@ export class AppCore {
|
|||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
|
// Initialize the help manager
|
||||||
|
helpManager.initialize();
|
||||||
|
|
||||||
// Mark as initialized
|
// Mark as initialized
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
@@ -61,9 +65,6 @@ export class AppCore {
|
|||||||
initializePageFeatures() {
|
initializePageFeatures() {
|
||||||
const pageType = this.getPageType();
|
const pageType = this.getPageType();
|
||||||
|
|
||||||
// Initialize lazy loading for images on all pages
|
|
||||||
lazyLoadImages();
|
|
||||||
|
|
||||||
// Setup event delegation for lora cards if on the loras page
|
// Setup event delegation for lora cards if on the loras page
|
||||||
if (pageType === 'loras') {
|
if (pageType === 'loras') {
|
||||||
setupLoraCardEventDelegation();
|
setupLoraCardEventDelegation();
|
||||||
@@ -85,6 +86,3 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
// Create and export a singleton instance
|
// Create and export a singleton instance
|
||||||
export const appCore = new AppCore();
|
export const appCore = new AppCore();
|
||||||
|
|
||||||
// Export common utilities for global use
|
|
||||||
export { showToast, lazyLoadImages, initializeInfiniteScroll };
|
|
||||||
@@ -9,6 +9,7 @@ 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';
|
||||||
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
|
|
||||||
// Initialize the LoRA page
|
// Initialize the LoRA page
|
||||||
class LoraPageManager {
|
class LoraPageManager {
|
||||||
@@ -23,6 +24,9 @@ class LoraPageManager {
|
|||||||
// Initialize page controls
|
// Initialize page controls
|
||||||
this.pageControls = createPageControls('loras');
|
this.pageControls = createPageControls('loras');
|
||||||
|
|
||||||
|
// Initialize the ModelDuplicatesManager
|
||||||
|
this.duplicatesManager = new ModelDuplicatesManager(this);
|
||||||
|
|
||||||
// Expose necessary functions to the page that still need global access
|
// Expose necessary functions to the page that still need global access
|
||||||
// These will be refactored in future updates
|
// These will be refactored in future updates
|
||||||
this._exposeRequiredGlobalFunctions();
|
this._exposeRequiredGlobalFunctions();
|
||||||
@@ -49,6 +53,9 @@ class LoraPageManager {
|
|||||||
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
|
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
|
||||||
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
|
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
|
||||||
window.bulkManager = bulkManager;
|
window.bulkManager = bulkManager;
|
||||||
|
|
||||||
|
// Expose duplicates manager
|
||||||
|
window.modelDuplicatesManager = this.duplicatesManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||||
import { updateCardsForBulkMode } from '../components/LoraCard.js';
|
import { updateCardsForBulkMode } from '../components/LoraCard.js';
|
||||||
|
import { modalManager } from './ModalManager.js';
|
||||||
|
|
||||||
export class BulkManager {
|
export class BulkManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -208,6 +209,131 @@ export class BulkManager {
|
|||||||
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
|
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add method to send all selected loras to workflow
|
||||||
|
async sendAllLorasToWorkflow() {
|
||||||
|
if (state.selectedLoras.size === 0) {
|
||||||
|
showToast('No LoRAs selected', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loraSyntaxes = [];
|
||||||
|
const missingLoras = [];
|
||||||
|
|
||||||
|
// Process all selected loras using our metadata cache
|
||||||
|
for (const filepath of state.selectedLoras) {
|
||||||
|
const metadata = state.loraMetadataCache.get(filepath);
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
const usageTips = JSON.parse(metadata.usageTips || '{}');
|
||||||
|
const strength = usageTips.strength || 1;
|
||||||
|
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
|
||||||
|
} else {
|
||||||
|
// If we don't have metadata, this is an error case
|
||||||
|
missingLoras.push(filepath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle any loras with missing metadata
|
||||||
|
if (missingLoras.length > 0) {
|
||||||
|
console.warn('Missing metadata for some selected loras:', missingLoras);
|
||||||
|
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loraSyntaxes.length === 0) {
|
||||||
|
showToast('No valid LoRAs to send', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the loras to the workflow
|
||||||
|
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the bulk delete confirmation modal
|
||||||
|
showBulkDeleteModal() {
|
||||||
|
if (state.selectedLoras.size === 0) {
|
||||||
|
showToast('No LoRAs selected', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the count in the modal
|
||||||
|
const countElement = document.getElementById('bulkDeleteCount');
|
||||||
|
if (countElement) {
|
||||||
|
countElement.textContent = state.selectedLoras.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the modal
|
||||||
|
modalManager.showModal('bulkDeleteModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm bulk delete action
|
||||||
|
async confirmBulkDelete() {
|
||||||
|
if (state.selectedLoras.size === 0) {
|
||||||
|
showToast('No LoRAs selected', 'warning');
|
||||||
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the modal first before showing loading indicator
|
||||||
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading indicator
|
||||||
|
state.loadingManager.showSimpleLoading('Deleting models...');
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
showToast(`Successfully deleted ${result.deleted_count} models`, 'success');
|
||||||
|
|
||||||
|
// If virtual scroller exists, update the UI without page reload
|
||||||
|
if (state.virtualScroller) {
|
||||||
|
// Remove each deleted item from the virtual scroller
|
||||||
|
filePaths.forEach(path => {
|
||||||
|
state.virtualScroller.removeItemByFilePath(path);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear the selection
|
||||||
|
this.clearSelection();
|
||||||
|
} else {
|
||||||
|
// Clear the selection
|
||||||
|
this.clearSelection();
|
||||||
|
|
||||||
|
// Fall back to page reload for non-virtual scroll mode
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.modelDuplicatesManager) {
|
||||||
|
// Update duplicates badge after refresh
|
||||||
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(`Error: ${result.error || 'Failed to delete models'}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during bulk delete:', error);
|
||||||
|
showToast('Failed to delete models', 'error');
|
||||||
|
} finally {
|
||||||
|
// Hide loading indicator
|
||||||
|
state.loadingManager.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create and show the thumbnail strip of selected LoRAs
|
// Create and show the thumbnail strip of selected LoRAs
|
||||||
toggleThumbnailStrip() {
|
toggleThumbnailStrip() {
|
||||||
// If no items are selected, do nothing
|
// If no items are selected, do nothing
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class ExampleImagesManager {
|
|||||||
this.progressPanel = null;
|
this.progressPanel = null;
|
||||||
this.isProgressPanelCollapsed = false;
|
this.isProgressPanelCollapsed = false;
|
||||||
this.pauseButton = null; // Store reference to the pause button
|
this.pauseButton = null; // Store reference to the pause button
|
||||||
|
this.isMigrating = false; // Track migration state separately from downloading
|
||||||
|
this.hasShownCompletionToast = false; // Flag to track if completion toast has been shown
|
||||||
|
|
||||||
// Initialize download path field and check download status
|
// Initialize download path field and check download status
|
||||||
this.initializePathOptions();
|
this.initializePathOptions();
|
||||||
@@ -46,6 +48,12 @@ class ExampleImagesManager {
|
|||||||
if (collapseBtn) {
|
if (collapseBtn) {
|
||||||
collapseBtn.onclick = () => this.toggleProgressPanel();
|
collapseBtn.onclick = () => this.toggleProgressPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize migration button handler
|
||||||
|
const migrateBtn = document.getElementById('exampleImagesMigrateBtn');
|
||||||
|
if (migrateBtn) {
|
||||||
|
migrateBtn.onclick = () => this.handleMigrateButton();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize event listeners for buttons
|
// Initialize event listeners for buttons
|
||||||
@@ -141,6 +149,95 @@ class ExampleImagesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Method to handle migrate button click
|
||||||
|
async handleMigrateButton() {
|
||||||
|
if (this.isDownloading || this.isMigrating) {
|
||||||
|
if (this.isPaused) {
|
||||||
|
// If paused, resume
|
||||||
|
this.resumeDownload();
|
||||||
|
} else {
|
||||||
|
showToast('Migration or download already in progress', 'info');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start migration
|
||||||
|
this.startMigrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async startMigrate() {
|
||||||
|
try {
|
||||||
|
const outputDir = document.getElementById('exampleImagesPath').value || '';
|
||||||
|
|
||||||
|
if (!outputDir) {
|
||||||
|
showToast('Please enter a download location first', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update path in backend settings before starting migration
|
||||||
|
try {
|
||||||
|
const pathUpdateResponse = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
example_images_path: outputDir
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pathUpdateResponse.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${pathUpdateResponse.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update example images path:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = document.getElementById('exampleImagesMigratePattern').value || '{model}.example.{index}.{ext}';
|
||||||
|
const optimize = document.getElementById('optimizeExampleImages').checked;
|
||||||
|
|
||||||
|
const response = await fetch('/api/migrate-example-images', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
output_dir: outputDir,
|
||||||
|
pattern: pattern,
|
||||||
|
optimize: optimize,
|
||||||
|
model_types: ['lora', 'checkpoint']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.isDownloading = true;
|
||||||
|
this.isMigrating = true;
|
||||||
|
this.isPaused = false;
|
||||||
|
this.hasShownCompletionToast = false; // Reset toast flag when starting new migration
|
||||||
|
this.startTime = new Date();
|
||||||
|
this.updateUI(data.status);
|
||||||
|
this.showProgressPanel();
|
||||||
|
this.startProgressUpdates();
|
||||||
|
// Update button text
|
||||||
|
const btnTextElement = document.getElementById('exampleDownloadBtnText');
|
||||||
|
if (btnTextElement) {
|
||||||
|
btnTextElement.textContent = "Resume";
|
||||||
|
}
|
||||||
|
showToast('Example images migration started', 'success');
|
||||||
|
|
||||||
|
// Close settings modal
|
||||||
|
modalManager.closeModal('settingsModal');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Failed to start migration', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start migration:', error);
|
||||||
|
showToast('Failed to start migration', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async checkDownloadStatus() {
|
async checkDownloadStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/example-images-status');
|
const response = await fetch('/api/example-images-status');
|
||||||
@@ -224,6 +321,7 @@ class ExampleImagesManager {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.isDownloading = true;
|
this.isDownloading = true;
|
||||||
this.isPaused = false;
|
this.isPaused = false;
|
||||||
|
this.hasShownCompletionToast = false; // Reset toast flag when starting new download
|
||||||
this.startTime = new Date();
|
this.startTime = new Date();
|
||||||
this.updateUI(data.status);
|
this.updateUI(data.status);
|
||||||
this.showProgressPanel();
|
this.showProgressPanel();
|
||||||
@@ -334,6 +432,7 @@ class ExampleImagesManager {
|
|||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.isDownloading = data.is_downloading;
|
this.isDownloading = data.is_downloading;
|
||||||
this.isPaused = data.status.status === 'paused';
|
this.isPaused = data.status.status === 'paused';
|
||||||
|
this.isMigrating = data.is_migrating || false;
|
||||||
|
|
||||||
// Update download button text
|
// Update download button text
|
||||||
this.updateDownloadButtonText();
|
this.updateDownloadButtonText();
|
||||||
@@ -345,12 +444,19 @@ class ExampleImagesManager {
|
|||||||
clearInterval(this.progressUpdateInterval);
|
clearInterval(this.progressUpdateInterval);
|
||||||
this.progressUpdateInterval = null;
|
this.progressUpdateInterval = null;
|
||||||
|
|
||||||
if (data.status.status === 'completed') {
|
if (data.status.status === 'completed' && !this.hasShownCompletionToast) {
|
||||||
showToast('Example images download completed', 'success');
|
const actionType = this.isMigrating ? 'migration' : 'download';
|
||||||
|
showToast(`Example images ${actionType} completed`, 'success');
|
||||||
|
// Mark as shown to prevent duplicate toasts
|
||||||
|
this.hasShownCompletionToast = true;
|
||||||
|
// Reset migration flag
|
||||||
|
this.isMigrating = false;
|
||||||
// Hide the panel after a delay
|
// Hide the panel after a delay
|
||||||
setTimeout(() => this.hideProgressPanel(), 5000);
|
setTimeout(() => this.hideProgressPanel(), 5000);
|
||||||
} else if (data.status.status === 'error') {
|
} else if (data.status.status === 'error') {
|
||||||
showToast('Example images download failed', 'error');
|
const actionType = this.isMigrating ? 'migration' : 'download';
|
||||||
|
showToast(`Example images ${actionType} failed`, 'error');
|
||||||
|
this.isMigrating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,6 +547,19 @@ class ExampleImagesManager {
|
|||||||
this.updateMiniProgress(progressPercent);
|
this.updateMiniProgress(progressPercent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update title text
|
||||||
|
const titleElement = document.querySelector('.progress-panel-title');
|
||||||
|
if (titleElement) {
|
||||||
|
const titleIcon = titleElement.querySelector('i');
|
||||||
|
if (titleIcon) {
|
||||||
|
titleIcon.className = this.isMigrating ? 'fas fa-file-import' : 'fas fa-images';
|
||||||
|
}
|
||||||
|
|
||||||
|
titleElement.innerHTML =
|
||||||
|
`<i class="${this.isMigrating ? 'fas fa-file-import' : 'fas fa-images'}"></i> ` +
|
||||||
|
`${this.isMigrating ? 'Example Images Migration' : 'Example Images Download'}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the mini progress circle in the pause button
|
// Update the mini progress circle in the pause button
|
||||||
@@ -536,8 +655,10 @@ class ExampleImagesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getStatusText(status) {
|
getStatusText(status) {
|
||||||
|
const prefix = this.isMigrating ? 'Migrating' : 'Downloading';
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'running': return 'Downloading';
|
case 'running': return this.isMigrating ? 'Migrating' : 'Downloading';
|
||||||
case 'paused': return 'Paused';
|
case 'paused': return 'Paused';
|
||||||
case 'completed': return 'Completed';
|
case 'completed': return 'Completed';
|
||||||
case 'error': return 'Error';
|
case 'error': return 'Error';
|
||||||
|
|||||||
155
static/js/managers/HelpManager.js
Normal file
155
static/js/managers/HelpManager.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages help modal functionality and tutorial update notifications
|
||||||
|
*/
|
||||||
|
export class HelpManager {
|
||||||
|
constructor() {
|
||||||
|
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0);
|
||||||
|
this.latestContentTimestamp = 0; // Will be updated from server or config
|
||||||
|
this.isInitialized = false;
|
||||||
|
|
||||||
|
// Default latest content data - could be fetched from server
|
||||||
|
this.latestVideoData = {
|
||||||
|
timestamp: new Date('2024-06-09').getTime(), // Default timestamp
|
||||||
|
walkthrough: {
|
||||||
|
id: 'hvKw31YpE-U',
|
||||||
|
title: 'Getting Started with LoRA Manager'
|
||||||
|
},
|
||||||
|
playlistUpdated: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the help manager
|
||||||
|
*/
|
||||||
|
initialize() {
|
||||||
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
|
console.log('HelpManager: Initializing...');
|
||||||
|
|
||||||
|
// Set up event handlers
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// Check if we need to show the badge
|
||||||
|
this.updateHelpBadge();
|
||||||
|
|
||||||
|
// Fetch latest video data (could be implemented to fetch from remote source)
|
||||||
|
this.fetchLatestVideoData();
|
||||||
|
|
||||||
|
this.isInitialized = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners for help modal
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
// Help toggle button
|
||||||
|
const helpToggleBtn = document.getElementById('helpToggleBtn');
|
||||||
|
if (helpToggleBtn) {
|
||||||
|
helpToggleBtn.addEventListener('click', () => this.openHelpModal());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help modal tab functionality
|
||||||
|
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
// Remove active class from all buttons and panes
|
||||||
|
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
|
||||||
|
pane.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active class to clicked button
|
||||||
|
event.currentTarget.classList.add('active');
|
||||||
|
|
||||||
|
// Show corresponding tab content
|
||||||
|
const tabId = event.currentTarget.getAttribute('data-tab');
|
||||||
|
document.getElementById(tabId).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the help modal
|
||||||
|
*/
|
||||||
|
openHelpModal() {
|
||||||
|
// Use modalManager to open the help modal
|
||||||
|
if (window.modalManager) {
|
||||||
|
window.modalManager.toggleModal('helpModal');
|
||||||
|
|
||||||
|
// Update the last viewed timestamp
|
||||||
|
this.markContentAsViewed();
|
||||||
|
|
||||||
|
// Hide the badge
|
||||||
|
this.hideHelpBadge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark content as viewed by saving current timestamp
|
||||||
|
*/
|
||||||
|
markContentAsViewed() {
|
||||||
|
this.lastViewedTimestamp = Date.now();
|
||||||
|
setStorageItem('help_last_viewed', this.lastViewedTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest video data (could be implemented to actually fetch from a remote source)
|
||||||
|
*/
|
||||||
|
fetchLatestVideoData() {
|
||||||
|
// In a real implementation, you'd fetch this from your server
|
||||||
|
// For now, we'll just use the hardcoded data from constructor
|
||||||
|
|
||||||
|
// Update the timestamp with the latest data
|
||||||
|
this.latestContentTimestamp = this.latestVideoData.timestamp;
|
||||||
|
|
||||||
|
// Check again if we need to show the badge with this new data
|
||||||
|
this.updateHelpBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update help badge visibility based on timestamps
|
||||||
|
*/
|
||||||
|
updateHelpBadge() {
|
||||||
|
if (this.hasNewContent()) {
|
||||||
|
this.showHelpBadge();
|
||||||
|
} else {
|
||||||
|
this.hideHelpBadge();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's new content the user hasn't seen
|
||||||
|
*/
|
||||||
|
hasNewContent() {
|
||||||
|
// If user has never viewed the help, or the content is newer than last viewed
|
||||||
|
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the help badge
|
||||||
|
*/
|
||||||
|
showHelpBadge() {
|
||||||
|
const helpBadge = document.querySelector('#helpToggleBtn .update-badge');
|
||||||
|
if (helpBadge) {
|
||||||
|
helpBadge.classList.add('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the help badge
|
||||||
|
*/
|
||||||
|
hideHelpBadge() {
|
||||||
|
const helpBadge = document.querySelector('#helpToggleBtn .update-badge');
|
||||||
|
if (helpBadge) {
|
||||||
|
helpBadge.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
export const helpManager = new HelpManager();
|
||||||
@@ -58,8 +58,17 @@ export class ImportManager {
|
|||||||
this.stepManager.removeInjectedStyles();
|
this.stepManager.removeInjectedStyles();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify visibility
|
// Verify visibility and focus on URL input
|
||||||
setTimeout(() => this.ensureModalVisible(), 50);
|
setTimeout(() => {
|
||||||
|
this.ensureModalVisible();
|
||||||
|
|
||||||
|
// Ensure URL option is selected and focus on the input
|
||||||
|
this.toggleImportMode('url');
|
||||||
|
const urlInput = document.getElementById('imageUrlInput');
|
||||||
|
if (urlInput) {
|
||||||
|
urlInput.focus();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetSteps() {
|
resetSteps() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export class ModalManager {
|
|||||||
this.modals = new Map();
|
this.modals = new Map();
|
||||||
this.scrollPosition = 0;
|
this.scrollPosition = 0;
|
||||||
this.currentOpenModal = null; // Track currently open modal
|
this.currentOpenModal = null; // Track currently open modal
|
||||||
|
this.mouseDownOnBackground = false; // Track if mousedown happened on modal background
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
@@ -44,8 +45,7 @@ export class ModalManager {
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.getModal('checkpointDownloadModal').element.style.display = 'none';
|
this.getModal('checkpointDownloadModal').element.style.display = 'none';
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
},
|
}
|
||||||
closeOnOutsideClick: true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,8 +68,7 @@ export class ModalManager {
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.getModal('excludeModal').element.classList.remove('show');
|
this.getModal('excludeModal').element.classList.remove('show');
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
},
|
}
|
||||||
closeOnOutsideClick: true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +92,8 @@ export class ModalManager {
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.getModal('settingsModal').element.style.display = 'none';
|
this.getModal('settingsModal').element.style.display = 'none';
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
}
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,8 @@ export class ModalManager {
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.getModal('supportModal').element.style.display = 'none';
|
this.getModal('supportModal').element.style.display = 'none';
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
}
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,8 @@ export class ModalManager {
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.getModal('updateModal').element.style.display = 'none';
|
this.getModal('updateModal').element.style.display = 'none';
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
}
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +172,18 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add modelDuplicateDeleteModal registration
|
||||||
|
const modelDuplicateDeleteModal = document.getElementById('modelDuplicateDeleteModal');
|
||||||
|
if (modelDuplicateDeleteModal) {
|
||||||
|
this.registerModal('modelDuplicateDeleteModal', {
|
||||||
|
element: modelDuplicateDeleteModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('modelDuplicateDeleteModal').element.classList.remove('show');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add clearCacheModal registration
|
// Add clearCacheModal registration
|
||||||
const clearCacheModal = document.getElementById('clearCacheModal');
|
const clearCacheModal = document.getElementById('clearCacheModal');
|
||||||
if (clearCacheModal) {
|
if (clearCacheModal) {
|
||||||
@@ -182,10 +196,42 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up event listeners for modal toggles
|
// Add bulkDeleteModal registration
|
||||||
const supportToggle = document.getElementById('supportToggleBtn');
|
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
|
||||||
if (supportToggle) {
|
if (bulkDeleteModal) {
|
||||||
supportToggle.addEventListener('click', () => this.toggleModal('supportModal'));
|
this.registerModal('bulkDeleteModal', {
|
||||||
|
element: bulkDeleteModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('bulkDeleteModal').element.classList.remove('show');
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add helpModal registration
|
||||||
|
const helpModal = document.getElementById('helpModal');
|
||||||
|
if (helpModal) {
|
||||||
|
this.registerModal('helpModal', {
|
||||||
|
element: helpModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('helpModal').element.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add relinkCivitaiModal registration
|
||||||
|
const relinkCivitaiModal = document.getElementById('relinkCivitaiModal');
|
||||||
|
if (relinkCivitaiModal) {
|
||||||
|
this.registerModal('relinkCivitaiModal', {
|
||||||
|
element: relinkCivitaiModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('relinkCivitaiModal').element.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', this.boundHandleEscape);
|
document.addEventListener('keydown', this.boundHandleEscape);
|
||||||
@@ -201,10 +247,27 @@ export class ModalManager {
|
|||||||
|
|
||||||
// Add click outside handler if specified in config
|
// Add click outside handler if specified in config
|
||||||
if (config.closeOnOutsideClick) {
|
if (config.closeOnOutsideClick) {
|
||||||
config.element.addEventListener('click', (e) => {
|
// Track mousedown on modal background
|
||||||
|
config.element.addEventListener('mousedown', (e) => {
|
||||||
if (e.target === config.element) {
|
if (e.target === config.element) {
|
||||||
|
this.mouseDownOnBackground = true;
|
||||||
|
} else {
|
||||||
|
this.mouseDownOnBackground = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only close if mouseup is also on the background
|
||||||
|
config.element.addEventListener('mouseup', (e) => {
|
||||||
|
if (e.target === config.element && this.mouseDownOnBackground) {
|
||||||
this.closeModal(id);
|
this.closeModal(id);
|
||||||
}
|
}
|
||||||
|
// Reset flag regardless of target
|
||||||
|
this.mouseDownOnBackground = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel the flag if mouse leaves the document entirely
|
||||||
|
document.addEventListener('mouseleave', () => {
|
||||||
|
this.mouseDownOnBackground = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,10 +308,17 @@ export class ModalManager {
|
|||||||
// Store current scroll position before showing modal
|
// Store current scroll position before showing modal
|
||||||
this.scrollPosition = window.scrollY;
|
this.scrollPosition = window.scrollY;
|
||||||
|
|
||||||
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal' || id === 'clearCacheModal') {
|
if (
|
||||||
modal.element.classList.add('show');
|
id === "deleteModal" ||
|
||||||
|
id === "excludeModal" ||
|
||||||
|
id === "duplicateDeleteModal" ||
|
||||||
|
id === "modelDuplicateDeleteModal" ||
|
||||||
|
id === "clearCacheModal" ||
|
||||||
|
id === "bulkDeleteModal"
|
||||||
|
) {
|
||||||
|
modal.element.classList.add("show");
|
||||||
} else {
|
} else {
|
||||||
modal.element.style.display = 'block';
|
modal.element.style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.isOpen = true;
|
modal.isOpen = true;
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ export class SettingsManager {
|
|||||||
state.global.settings.compactMode = false;
|
state.global.settings.compactMode = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set default for optimizeExampleImages if undefined
|
||||||
|
if (state.global.settings.optimizeExampleImages === undefined) {
|
||||||
|
state.global.settings.optimizeExampleImages = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default for useCentralizedExamples if undefined
|
||||||
|
if (state.global.settings.useCentralizedExamples === undefined) {
|
||||||
|
state.global.settings.useCentralizedExamples = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert old boolean compactMode to new displayDensity string
|
// Convert old boolean compactMode to new displayDensity string
|
||||||
if (typeof state.global.settings.displayDensity === 'undefined') {
|
if (typeof state.global.settings.displayDensity === 'undefined') {
|
||||||
if (state.global.settings.compactMode === true) {
|
if (state.global.settings.compactMode === true) {
|
||||||
@@ -98,6 +108,20 @@ export class SettingsManager {
|
|||||||
displayDensitySelect.value = state.global.settings.displayDensity || 'default';
|
displayDensitySelect.value = state.global.settings.displayDensity || 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set optimize example images setting
|
||||||
|
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
|
||||||
|
if (optimizeExampleImagesCheckbox) {
|
||||||
|
optimizeExampleImagesCheckbox.checked = state.global.settings.optimizeExampleImages || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set centralized examples setting
|
||||||
|
const useCentralizedExamplesCheckbox = document.getElementById('useCentralizedExamples');
|
||||||
|
if (useCentralizedExamplesCheckbox) {
|
||||||
|
useCentralizedExamplesCheckbox.checked = state.global.settings.useCentralizedExamples !== false;
|
||||||
|
// Update dependent controls
|
||||||
|
this.updateExamplesControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
// Load default lora root
|
// Load default lora root
|
||||||
await this.loadLoraRoots();
|
await this.loadLoraRoots();
|
||||||
|
|
||||||
@@ -172,6 +196,10 @@ export class SettingsManager {
|
|||||||
state.global.settings.optimizeExampleImages = value;
|
state.global.settings.optimizeExampleImages = value;
|
||||||
} else if (settingKey === 'compact_mode') {
|
} else if (settingKey === 'compact_mode') {
|
||||||
state.global.settings.compactMode = value;
|
state.global.settings.compactMode = value;
|
||||||
|
} else if (settingKey === 'use_centralized_examples') {
|
||||||
|
state.global.settings.useCentralizedExamples = value;
|
||||||
|
// Update dependent controls state
|
||||||
|
this.updateExamplesControlsState();
|
||||||
} 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;
|
||||||
@@ -182,7 +210,7 @@ export class SettingsManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// For backend settings, make API call
|
// For backend settings, make API call
|
||||||
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover'].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;
|
||||||
|
|
||||||
@@ -391,6 +419,7 @@ export class SettingsManager {
|
|||||||
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
||||||
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
||||||
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
|
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
|
||||||
|
const optimizeExampleImages = document.getElementById('optimizeExampleImages').checked;
|
||||||
|
|
||||||
// Get backend settings
|
// Get backend settings
|
||||||
const apiKey = document.getElementById('civitaiApiKey').value;
|
const apiKey = document.getElementById('civitaiApiKey').value;
|
||||||
@@ -400,6 +429,7 @@ export class SettingsManager {
|
|||||||
state.global.settings.show_only_sfw = showOnlySFW;
|
state.global.settings.show_only_sfw = showOnlySFW;
|
||||||
state.global.settings.default_loras_root = defaultLoraRoot;
|
state.global.settings.default_loras_root = defaultLoraRoot;
|
||||||
state.global.settings.autoplayOnHover = autoplayOnHover;
|
state.global.settings.autoplayOnHover = autoplayOnHover;
|
||||||
|
state.global.settings.optimizeExampleImages = optimizeExampleImages;
|
||||||
|
|
||||||
// Save settings to localStorage
|
// Save settings to localStorage
|
||||||
setStorageItem('settings', state.global.settings);
|
setStorageItem('settings', state.global.settings);
|
||||||
@@ -413,7 +443,8 @@ export class SettingsManager {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
civitai_api_key: apiKey,
|
civitai_api_key: apiKey,
|
||||||
show_only_sfw: showOnlySFW
|
show_only_sfw: showOnlySFW,
|
||||||
|
optimize_example_images: optimizeExampleImages
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,6 +523,42 @@ export class SettingsManager {
|
|||||||
// Add the appropriate density class
|
// Add the appropriate density class
|
||||||
grid.classList.add(`${density}-density`);
|
grid.classList.add(`${density}-density`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply centralized examples toggle state
|
||||||
|
this.updateExamplesControlsState();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new method to update example control states
|
||||||
|
updateExamplesControlsState() {
|
||||||
|
const useCentralized = state.global.settings.useCentralizedExamples !== false;
|
||||||
|
|
||||||
|
// Find all controls that require centralized mode
|
||||||
|
const exampleSections = document.querySelectorAll('[data-requires-centralized="true"]');
|
||||||
|
exampleSections.forEach(section => {
|
||||||
|
// Enable/disable all inputs and buttons in the section
|
||||||
|
const controls = section.querySelectorAll('input, button, select');
|
||||||
|
controls.forEach(control => {
|
||||||
|
control.disabled = !useCentralized;
|
||||||
|
|
||||||
|
// Add/remove disabled class for styling
|
||||||
|
if (control.classList.contains('primary-btn') || control.classList.contains('secondary-btn')) {
|
||||||
|
if (!useCentralized) {
|
||||||
|
control.classList.add('disabled');
|
||||||
|
} else {
|
||||||
|
control.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Visually show the section as disabled
|
||||||
|
if (!useCentralized) {
|
||||||
|
section.style.opacity = '0.6';
|
||||||
|
section.style.pointerEvents = 'none';
|
||||||
|
} else {
|
||||||
|
section.style.opacity = '';
|
||||||
|
section.style.pointerEvents = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export class UpdateService {
|
|||||||
updateBadgeVisibility() {
|
updateBadgeVisibility() {
|
||||||
const updateToggle = document.querySelector('.update-toggle');
|
const updateToggle = document.querySelector('.update-toggle');
|
||||||
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
||||||
const cornerBadge = document.querySelector('.corner-badge');
|
|
||||||
|
|
||||||
if (updateToggle) {
|
if (updateToggle) {
|
||||||
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
|
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
|
||||||
@@ -134,11 +133,6 @@ export class UpdateService {
|
|||||||
updateBadge.classList.toggle('hidden', !shouldShow);
|
updateBadge.classList.toggle('hidden', !shouldShow);
|
||||||
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
|
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cornerBadge) {
|
|
||||||
cornerBadge.classList.toggle('hidden', !shouldShow);
|
|
||||||
console.log("Corner badge visibility:", !shouldShow ? "hidden" : "visible");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateModalContent() {
|
updateModalContent() {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { getCurrentPageState, state } from './state/index.js';
|
|||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { initializeInfiniteScroll, refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { resetAndReload, refreshRecipes } from './api/recipeApi.js';
|
import { refreshRecipes } from './api/recipeApi.js';
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -228,11 +228,6 @@ class RecipeManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.duplicatesManager.exitDuplicateMode();
|
this.duplicatesManager.exitDuplicateMode();
|
||||||
|
|
||||||
// Use a small delay before initializing to ensure DOM is ready
|
|
||||||
setTimeout(() => {
|
|
||||||
initializeInfiniteScroll('recipes');
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const state = {
|
|||||||
selectedLoras: new Set(),
|
selectedLoras: new Set(),
|
||||||
loraMetadataCache: new Map(),
|
loraMetadataCache: new Map(),
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
|
duplicatesMode: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
recipes: {
|
recipes: {
|
||||||
@@ -86,6 +87,7 @@ export const state = {
|
|||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
showFavoritesOnly: false,
|
showFavoritesOnly: false,
|
||||||
|
duplicatesMode: false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ export class VirtualScroller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
calculateLayout() {
|
calculateLayout() {
|
||||||
|
const pageState = getCurrentPageState();
|
||||||
|
if (pageState.duplicatesMode) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Get container width and style information
|
// Get container width and style information
|
||||||
const containerWidth = this.containerElement.clientWidth;
|
const containerWidth = this.containerElement.clientWidth;
|
||||||
const containerStyle = getComputedStyle(this.containerElement);
|
const containerStyle = getComputedStyle(this.containerElement);
|
||||||
@@ -761,8 +766,24 @@ export class VirtualScroller {
|
|||||||
// Reattach scroll event listener
|
// Reattach scroll event listener
|
||||||
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||||
|
|
||||||
// Show the spacer element
|
// Check if spacer element exists in the DOM, if not, recreate it
|
||||||
if (this.spacerElement) {
|
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
|
||||||
|
console.log('Spacer element not found in DOM, recreating it');
|
||||||
|
|
||||||
|
// Create a new spacer element
|
||||||
|
this.spacerElement = document.createElement('div');
|
||||||
|
this.spacerElement.className = 'virtual-scroll-spacer';
|
||||||
|
this.spacerElement.style.width = '100%';
|
||||||
|
this.spacerElement.style.height = '0px';
|
||||||
|
this.spacerElement.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
// Append it to the grid
|
||||||
|
this.gridElement.appendChild(this.spacerElement);
|
||||||
|
|
||||||
|
// Update the spacer height
|
||||||
|
this.updateSpacerHeight();
|
||||||
|
} else {
|
||||||
|
// Show the spacer element if it exists
|
||||||
this.spacerElement.style.display = 'block';
|
this.spacerElement.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -875,34 +896,6 @@ export class VirtualScroller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a more contained transition indicator - commented out as it's no longer needed
|
|
||||||
/*
|
|
||||||
showTransitionIndicator() {
|
|
||||||
const container = this.containerElement;
|
|
||||||
const indicator = document.createElement('div');
|
|
||||||
indicator.className = 'page-transition-indicator';
|
|
||||||
|
|
||||||
// Get container position to properly position the indicator
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
|
|
||||||
// Style the indicator to match just the container area
|
|
||||||
indicator.style.position = 'fixed';
|
|
||||||
indicator.style.top = `${containerRect.top}px`;
|
|
||||||
indicator.style.left = `${containerRect.left}px`;
|
|
||||||
indicator.style.width = `${containerRect.width}px`;
|
|
||||||
indicator.style.height = `${containerRect.height}px`;
|
|
||||||
|
|
||||||
document.body.appendChild(indicator);
|
|
||||||
|
|
||||||
// Remove after animation completes
|
|
||||||
setTimeout(() => {
|
|
||||||
if (indicator.parentNode) {
|
|
||||||
indicator.remove();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
scrollToTop() {
|
scrollToTop() {
|
||||||
this.removeExistingTransitionIndicator();
|
this.removeExistingTransitionIndicator();
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,14 @@ export function formatFileSize(bytes) {
|
|||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert timestamp to human readable date string
|
||||||
|
* @param {number} modified - Timestamp in seconds
|
||||||
|
* @returns {string} Formatted date string
|
||||||
|
*/
|
||||||
|
export function formatDate(modified) {
|
||||||
|
if (!modified) return '';
|
||||||
|
const date = new Date(modified * 1000);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
|
|
||||||
|
if (window.modelDuplicatesManager) {
|
||||||
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting model:', error);
|
console.error('Error deleting model:', error);
|
||||||
alert(`Error deleting model: ${error}`);
|
alert(`Error deleting model: ${error}`);
|
||||||
@@ -86,6 +90,10 @@ export async function confirmExclude() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeExcludeModal();
|
closeExcludeModal();
|
||||||
|
|
||||||
|
if (window.modelDuplicatesManager) {
|
||||||
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error excluding model:', error);
|
console.error('Error excluding model:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { resetAndReload } from '../api/loraApi.js';
|
import { resetAndReload } from '../api/loraApi.js';
|
||||||
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
import { getStorageItem, setStorageItem } from './storageHelpers.js';
|
||||||
|
import { NSFW_LEVELS } from './constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility function to copy text to clipboard with fallback for older browsers
|
* Utility function to copy text to clipboard with fallback for older browsers
|
||||||
@@ -91,19 +92,6 @@ export function showToast(message, type = 'info') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lazyLoadImages() {
|
|
||||||
const observer = new IntersectionObserver(entries => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting && entry.target.dataset.src) {
|
|
||||||
entry.target.src = entry.target.dataset.src;
|
|
||||||
observer.unobserve(entry.target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreFolderFilter() {
|
export function restoreFolderFilter() {
|
||||||
const activeFolder = getStorageItem('activeFolder');
|
const activeFolder = getStorageItem('activeFolder');
|
||||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||||||
@@ -454,3 +442,520 @@ export async function openExampleImagesFolder(modelHash) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets local URLs for example images with primary and fallback options
|
||||||
|
* @param {Object} img - Image object
|
||||||
|
* @param {number} index - Image index
|
||||||
|
* @param {string} modelHash - Model hash
|
||||||
|
* @returns {Object} - Object with primary and fallback URLs
|
||||||
|
*/
|
||||||
|
export function getLocalExampleImageUrl(img, index, modelHash) {
|
||||||
|
if (!modelHash) return { primary: null, fallback: null };
|
||||||
|
|
||||||
|
// Get remote extension
|
||||||
|
const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
// If it's a video (mp4), use that extension with no fallback
|
||||||
|
if (remoteExt === 'mp4') {
|
||||||
|
const videoUrl = `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
|
||||||
|
return { primary: videoUrl, fallback: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// For images, prepare both possible formats
|
||||||
|
const basePath = `/example_images_static/${modelHash}/image_${index + 1}`;
|
||||||
|
const webpUrl = `${basePath}.webp`;
|
||||||
|
const originalExtUrl = remoteExt ? `${basePath}.${remoteExt}` : `${basePath}.jpg`;
|
||||||
|
|
||||||
|
// Check if optimization is enabled (defaults to true)
|
||||||
|
const optimizeImages = state.settings.optimizeExampleImages !== false;
|
||||||
|
|
||||||
|
// Return primary and fallback URLs based on current settings
|
||||||
|
return {
|
||||||
|
primary: optimizeImages ? webpUrl : originalExtUrl,
|
||||||
|
fallback: optimizeImages ? originalExtUrl : webpUrl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to load local image first, fall back to remote if local fails
|
||||||
|
* @param {HTMLImageElement} imgElement - The image element to update
|
||||||
|
* @param {Object} urls - Object with local URLs {primary, fallback} and remote URL
|
||||||
|
*/
|
||||||
|
export function tryLocalImageOrFallbackToRemote(imgElement, urls) {
|
||||||
|
const { primary: localUrl, fallback: fallbackUrl } = urls.local || {};
|
||||||
|
const remoteUrl = urls.remote;
|
||||||
|
|
||||||
|
// If no local options, use remote directly
|
||||||
|
if (!localUrl) {
|
||||||
|
imgElement.src = remoteUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try primary local URL
|
||||||
|
const testImg = new Image();
|
||||||
|
testImg.onload = () => {
|
||||||
|
// Primary local image loaded successfully
|
||||||
|
imgElement.src = localUrl;
|
||||||
|
};
|
||||||
|
testImg.onerror = () => {
|
||||||
|
// Try fallback URL if available
|
||||||
|
if (fallbackUrl) {
|
||||||
|
const fallbackImg = new Image();
|
||||||
|
fallbackImg.onload = () => {
|
||||||
|
imgElement.src = fallbackUrl;
|
||||||
|
};
|
||||||
|
fallbackImg.onerror = () => {
|
||||||
|
// Both local options failed, use remote
|
||||||
|
imgElement.src = remoteUrl;
|
||||||
|
};
|
||||||
|
fallbackImg.src = fallbackUrl;
|
||||||
|
} else {
|
||||||
|
// No fallback, use remote
|
||||||
|
imgElement.src = remoteUrl;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
testImg.src = localUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to load local video first, fall back to remote if local fails
|
||||||
|
* @param {HTMLVideoElement} videoElement - The video element to update
|
||||||
|
* @param {Object} urls - Object with local URLs {primary} and remote URL
|
||||||
|
*/
|
||||||
|
export function tryLocalVideoOrFallbackToRemote(videoElement, urls) {
|
||||||
|
const { primary: localUrl } = urls.local || {};
|
||||||
|
const remoteUrl = urls.remote;
|
||||||
|
|
||||||
|
// Only try local if we have a local path
|
||||||
|
if (localUrl) {
|
||||||
|
// Try to fetch local file headers to see if it exists
|
||||||
|
fetch(localUrl, { method: 'HEAD' })
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
// Local video exists, use it
|
||||||
|
videoElement.src = localUrl;
|
||||||
|
const source = videoElement.querySelector('source');
|
||||||
|
if (source) source.src = localUrl;
|
||||||
|
} else {
|
||||||
|
// Local video doesn't exist, use remote
|
||||||
|
videoElement.src = remoteUrl;
|
||||||
|
const source = videoElement.querySelector('source');
|
||||||
|
if (source) source.src = remoteUrl;
|
||||||
|
}
|
||||||
|
videoElement.load();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Error fetching, use remote
|
||||||
|
videoElement.src = remoteUrl;
|
||||||
|
const source = videoElement.querySelector('source');
|
||||||
|
if (source) source.src = remoteUrl;
|
||||||
|
videoElement.load();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No local path, use remote directly
|
||||||
|
videoElement.src = remoteUrl;
|
||||||
|
const source = videoElement.querySelector('source');
|
||||||
|
if (source) source.src = remoteUrl;
|
||||||
|
videoElement.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize lazy loading for images and videos in a container
|
||||||
|
* @param {HTMLElement} container - The container with lazy-loadable elements
|
||||||
|
*/
|
||||||
|
export function initLazyLoading(container) {
|
||||||
|
const lazyElements = container.querySelectorAll('.lazy');
|
||||||
|
|
||||||
|
const lazyLoad = (element) => {
|
||||||
|
// Get URLs from data attributes
|
||||||
|
const localUrls = {
|
||||||
|
primary: element.dataset.localSrc || null,
|
||||||
|
fallback: element.dataset.localFallbackSrc || null
|
||||||
|
};
|
||||||
|
const remoteUrl = element.dataset.remoteSrc;
|
||||||
|
|
||||||
|
const urls = {
|
||||||
|
local: localUrls,
|
||||||
|
remote: remoteUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if element is a video or image
|
||||||
|
if (element.tagName.toLowerCase() === 'video') {
|
||||||
|
tryLocalVideoOrFallbackToRemote(element, urls);
|
||||||
|
} else {
|
||||||
|
tryLocalImageOrFallbackToRemote(element, urls);
|
||||||
|
}
|
||||||
|
|
||||||
|
element.classList.remove('lazy');
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
lazyLoad(entry.target);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
lazyElements.forEach(element => observer.observe(element));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the actual rendered rectangle of a media element with object-fit: contain
|
||||||
|
* @param {HTMLElement} mediaElement - The img or video element
|
||||||
|
* @param {number} containerWidth - Width of the container
|
||||||
|
* @param {number} containerHeight - Height of the container
|
||||||
|
* @returns {Object} - Rect with left, top, right, bottom coordinates
|
||||||
|
*/
|
||||||
|
export function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
|
||||||
|
// Get natural dimensions of the media
|
||||||
|
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
|
||||||
|
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
|
||||||
|
|
||||||
|
if (!naturalWidth || !naturalHeight) {
|
||||||
|
// Fallback if dimensions cannot be determined
|
||||||
|
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate aspect ratios
|
||||||
|
const containerRatio = containerWidth / containerHeight;
|
||||||
|
const mediaRatio = naturalWidth / naturalHeight;
|
||||||
|
|
||||||
|
let renderedWidth, renderedHeight, left = 0, top = 0;
|
||||||
|
|
||||||
|
// Apply object-fit: contain logic
|
||||||
|
if (containerRatio > mediaRatio) {
|
||||||
|
// Container is wider than media - will have empty space on sides
|
||||||
|
renderedHeight = containerHeight;
|
||||||
|
renderedWidth = renderedHeight * mediaRatio;
|
||||||
|
left = (containerWidth - renderedWidth) / 2;
|
||||||
|
} else {
|
||||||
|
// Container is taller than media - will have empty space top/bottom
|
||||||
|
renderedWidth = containerWidth;
|
||||||
|
renderedHeight = renderedWidth / mediaRatio;
|
||||||
|
top = (containerHeight - renderedHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right: left + renderedWidth,
|
||||||
|
bottom: top + renderedHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize metadata panel interaction handlers
|
||||||
|
* @param {HTMLElement} container - Container element with media wrappers
|
||||||
|
*/
|
||||||
|
export function initMetadataPanelHandlers(container) {
|
||||||
|
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||||
|
|
||||||
|
mediaWrappers.forEach(wrapper => {
|
||||||
|
// Get the metadata panel and media element (img or video)
|
||||||
|
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||||
|
const mediaElement = wrapper.querySelector('img, video');
|
||||||
|
|
||||||
|
if (!metadataPanel || !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 when over media content or metadata panel itself
|
||||||
|
if (isOverMedia || isOverMetadataPanel) {
|
||||||
|
metadataPanel.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
metadataPanel.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wrapper.addEventListener('mouseleave', () => {
|
||||||
|
if (!isOverMetadataPanel) {
|
||||||
|
metadataPanel.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add mouse enter/leave events for the metadata panel itself
|
||||||
|
metadataPanel.addEventListener('mouseenter', () => {
|
||||||
|
isOverMetadataPanel = true;
|
||||||
|
metadataPanel.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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent events from bubbling
|
||||||
|
metadataPanel.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle copy prompt buttons
|
||||||
|
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||||
|
copyBtns.forEach(copyBtn => {
|
||||||
|
const promptIndex = copyBtn.dataset.promptIndex;
|
||||||
|
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||||
|
|
||||||
|
copyBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!promptElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy failed:', err);
|
||||||
|
showToast('Copy failed', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent panel scroll from causing modal scroll
|
||||||
|
metadataPanel.addEventListener('wheel', (e) => {
|
||||||
|
const isAtTop = metadataPanel.scrollTop === 0;
|
||||||
|
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
||||||
|
|
||||||
|
// Only prevent default if scrolling would cause the panel to scroll
|
||||||
|
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize NSFW content blur toggle handlers
|
||||||
|
* @param {HTMLElement} container - Container element with media wrappers
|
||||||
|
*/
|
||||||
|
export function initNsfwBlurHandlers(container) {
|
||||||
|
// Handle toggle blur buttons
|
||||||
|
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
|
||||||
|
toggleButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const wrapper = btn.closest('.media-wrapper');
|
||||||
|
const media = wrapper.querySelector('img, video');
|
||||||
|
const isBlurred = media.classList.toggle('blurred');
|
||||||
|
const icon = btn.querySelector('i');
|
||||||
|
|
||||||
|
// Update the icon based on blur state
|
||||||
|
if (isBlurred) {
|
||||||
|
icon.className = 'fas fa-eye';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fas fa-eye-slash';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the overlay visibility
|
||||||
|
const overlay = wrapper.querySelector('.nsfw-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle "Show" buttons in overlays
|
||||||
|
const showButtons = container.querySelectorAll('.show-content-btn');
|
||||||
|
showButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const wrapper = btn.closest('.media-wrapper');
|
||||||
|
const media = wrapper.querySelector('img, video');
|
||||||
|
media.classList.remove('blurred');
|
||||||
|
|
||||||
|
// Update the toggle button icon
|
||||||
|
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the overlay
|
||||||
|
const overlay = wrapper.querySelector('.nsfw-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
initLazyLoading(carousel);
|
||||||
|
|
||||||
|
// Initialize NSFW content blur toggle handlers
|
||||||
|
initNsfwBlurHandlers(carousel);
|
||||||
|
|
||||||
|
// Initialize metadata panel interaction handlers
|
||||||
|
initMetadataPanelHandlers(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up showcase scroll functionality
|
||||||
|
* @param {string} modalId - ID of the modal element
|
||||||
|
*/
|
||||||
|
export function setupShowcaseScroll(modalId) {
|
||||||
|
// Listen for wheel events
|
||||||
|
document.addEventListener('wheel', (event) => {
|
||||||
|
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||||
|
if (!modalContent) return;
|
||||||
|
|
||||||
|
const showcase = modalContent.querySelector('.showcase-section');
|
||||||
|
if (!showcase) return;
|
||||||
|
|
||||||
|
const carousel = showcase.querySelector('.carousel');
|
||||||
|
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
||||||
|
|
||||||
|
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
||||||
|
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
||||||
|
|
||||||
|
if (isNearBottom) {
|
||||||
|
toggleShowcase(scrollIndicator);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Use MutationObserver to set up back-to-top button when modal content is added
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
if (mutation.type === 'childList' && mutation.addedNodes.length) {
|
||||||
|
const modal = document.getElementById(modalId);
|
||||||
|
if (modal && modal.querySelector('.modal-content')) {
|
||||||
|
setupBackToTopButton(modal.querySelector('.modal-content'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the document body for changes
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Also try to set up the button immediately in case the modal is already open
|
||||||
|
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||||
|
if (modalContent) {
|
||||||
|
setupBackToTopButton(modalContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up back-to-top button
|
||||||
|
* @param {HTMLElement} modalContent - Modal content element
|
||||||
|
*/
|
||||||
|
export function setupBackToTopButton(modalContent) {
|
||||||
|
// Remove any existing scroll listeners to avoid duplicates
|
||||||
|
modalContent.onscroll = null;
|
||||||
|
|
||||||
|
// Add new scroll listener
|
||||||
|
modalContent.addEventListener('scroll', () => {
|
||||||
|
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
||||||
|
if (backToTopBtn) {
|
||||||
|
if (modalContent.scrollTop > 300) {
|
||||||
|
backToTopBtn.classList.add('visible');
|
||||||
|
} else {
|
||||||
|
backToTopBtn.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger a scroll event to check initial position
|
||||||
|
modalContent.dispatchEvent(new Event('scroll'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll to top of modal content
|
||||||
|
* @param {HTMLElement} button - Back to top button element
|
||||||
|
*/
|
||||||
|
export function scrollToTop(button) {
|
||||||
|
const modalContent = button.closest('.modal-content');
|
||||||
|
if (modalContent) {
|
||||||
|
modalContent.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example image files for a specific model from the backend
|
||||||
|
* @param {string} modelHash - The model's hash
|
||||||
|
* @returns {Promise<Array>} Array of file objects with path and metadata
|
||||||
|
*/
|
||||||
|
export async function getExampleImageFiles(modelHash) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result.files;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to get example image files:', result.error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching example image files:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
||||||
<div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div>
|
<div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div>
|
||||||
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div>
|
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div>
|
||||||
|
<div class="context-menu-item" data-action="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</div>
|
||||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
||||||
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
|
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> Open Examples Folder</div>
|
||||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
||||||
@@ -31,6 +32,28 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
|
|
||||||
|
<!-- Duplicates banner (hidden by default) -->
|
||||||
|
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||||
|
<div class="banner-content">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span id="duplicatesCount">Found 0 duplicate groups</span>
|
||||||
|
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
|
||||||
|
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
|
||||||
|
</button>
|
||||||
|
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
|
||||||
|
<i class="fas fa-times"></i> Exit Mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-tooltip" id="duplicatesHelpTooltip">
|
||||||
|
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
|
||||||
|
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Checkpoint cards container -->
|
<!-- Checkpoint cards container -->
|
||||||
<div class="card-grid" id="checkpointGrid">
|
<div class="card-grid" id="checkpointGrid">
|
||||||
<!-- Cards will be dynamically inserted here -->
|
<!-- Cards will be dynamically inserted here -->
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="context-menu-item" data-action="refresh-metadata">
|
<div class="context-menu-item" data-action="refresh-metadata">
|
||||||
<i class="fas fa-sync"></i> Refresh Civitai Data
|
<i class="fas fa-sync"></i> Refresh Civitai Data
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="relink-civitai">
|
||||||
|
<i class="fas fa-link"></i> Re-link to Civitai
|
||||||
|
</div>
|
||||||
<div class="context-menu-item" data-action="copyname">
|
<div class="context-menu-item" data-action="copyname">
|
||||||
<i class="fas fa-copy"></i> Copy LoRA Syntax
|
<i class="fas fa-copy"></i> Copy LoRA Syntax
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="control-group">
|
||||||
|
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
|
||||||
|
<i class="fas fa-clone"></i> Duplicates
|
||||||
|
<span id="duplicatesBadge" class="badge"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter" title="Show favorites only">
|
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter" title="Show favorites only">
|
||||||
<i class="fas fa-star"></i> Favorites
|
<i class="fas fa-star"></i> Favorites
|
||||||
@@ -101,12 +107,18 @@
|
|||||||
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
|
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
|
||||||
</span>
|
</span>
|
||||||
<div class="bulk-operations-actions">
|
<div class="bulk-operations-actions">
|
||||||
|
<button onclick="bulkManager.sendAllLorasToWorkflow()" title="Send all selected LoRAs to workflow">
|
||||||
|
<i class="fas fa-arrow-right"></i> Send to Workflow
|
||||||
|
</button>
|
||||||
<button onclick="bulkManager.copyAllLorasSyntax()" 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 onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs 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 onclick="bulkManager.showBulkDeleteModal()" title="Delete selected LoRAs" class="danger-btn">
|
||||||
|
<i class="fas fa-trash"></i> Delete All
|
||||||
|
</button>
|
||||||
<button onclick="bulkManager.clearSelection()" 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>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
<div class="settings-toggle" title="Settings">
|
<div class="settings-toggle" title="Settings">
|
||||||
<i class="fas fa-cog"></i>
|
<i class="fas fa-cog"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="help-toggle" id="helpToggleBtn" title="Help & Tutorials">
|
||||||
|
<i class="fas fa-question-circle"></i>
|
||||||
|
<span class="update-badge"></span>
|
||||||
|
</div>
|
||||||
<div class="update-toggle" id="updateToggleBtn" title="Check Updates">
|
<div class="update-toggle" id="updateToggleBtn" title="Check Updates">
|
||||||
<i class="fas fa-bell"></i>
|
<i class="fas fa-bell"></i>
|
||||||
<span class="update-badge hidden"></span>
|
<span class="update-badge hidden"></span>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Duplicate Delete Confirmation Modal -->
|
<!-- Recipes Duplicate Delete Confirmation Modal -->
|
||||||
<div id="duplicateDeleteModal" class="modal delete-modal">
|
<div id="duplicateDeleteModal" class="modal delete-modal">
|
||||||
<div class="modal-content delete-modal-content">
|
<div class="modal-content delete-modal-content">
|
||||||
<h2>Delete Duplicate Recipes</h2>
|
<h2>Delete Duplicate Recipes</h2>
|
||||||
@@ -39,6 +39,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Models Duplicate Delete Confirmation Modal -->
|
||||||
|
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<h2>Delete Duplicate Models</h2>
|
||||||
|
<p class="delete-message">Are you sure you want to delete the selected duplicate models?</p>
|
||||||
|
<div class="delete-model-info">
|
||||||
|
<p><span id="modelDuplicateDeleteCount">0</span> models will be permanently deleted.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" onclick="modalManager.closeModal('modelDuplicateDeleteModal')">Cancel</button>
|
||||||
|
<button class="delete-btn" onclick="modelDuplicatesManager.confirmDeleteDuplicates()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cache Clear Confirmation Modal -->
|
<!-- Cache Clear Confirmation Modal -->
|
||||||
<div id="clearCacheModal" class="modal delete-modal">
|
<div id="clearCacheModal" class="modal delete-modal">
|
||||||
<div class="modal-content delete-modal-content">
|
<div class="modal-content delete-modal-content">
|
||||||
@@ -54,6 +69,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bulk Delete Confirmation Modal -->
|
||||||
|
<div id="bulkDeleteModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<h2>Delete Multiple Models</h2>
|
||||||
|
<p class="delete-message">Are you sure you want to delete all selected models and their associated files?</p>
|
||||||
|
<div class="delete-model-info">
|
||||||
|
<p><span id="bulkDeleteCount">0</span> models will be permanently deleted.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" onclick="modalManager.closeModal('bulkDeleteModal')">Cancel</button>
|
||||||
|
<button class="delete-btn" onclick="bulkManager.confirmBulkDelete()">Delete All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div id="settingsModal" class="modal">
|
<div id="settingsModal" class="modal">
|
||||||
<div class="modal-content settings-modal">
|
<div class="modal-content settings-modal">
|
||||||
@@ -228,7 +258,27 @@
|
|||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="exampleImagesPath">Download Location</label>
|
<label for="useCentralizedExamples">Use Centralized Example Storage</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" id="useCentralizedExamples" checked
|
||||||
|
onchange="settingsManager.saveToggleSetting('useCentralizedExamples', 'use_centralized_examples')">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-help">
|
||||||
|
When enabled (recommended), example images are stored in a central folder for better organization and performance.
|
||||||
|
When disabled, only example images stored alongside models (e.g., model-name.example.0.jpg) will be shown, but download
|
||||||
|
and management features will be unavailable.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item" data-requires-centralized="true">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="exampleImagesPath">Download Location <i class="fas fa-sync-alt restart-required-icon" title="Requires restart"></i></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control path-control">
|
<div class="setting-control path-control">
|
||||||
<input type="text" id="exampleImagesPath" placeholder="Enter folder path for example images" />
|
<input type="text" id="exampleImagesPath" placeholder="Enter folder path for example images" />
|
||||||
@@ -242,7 +292,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<!-- New migrate section -->
|
||||||
|
<div class="setting-item" data-requires-centralized="true">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="exampleImagesMigratePattern">Migrate Existing Example Images</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control migrate-control">
|
||||||
|
<input type="text" id="exampleImagesMigratePattern"
|
||||||
|
placeholder="{model}.example.{index}.{ext}"
|
||||||
|
value="{model}.example.{index}.{ext}" />
|
||||||
|
<button id="exampleImagesMigrateBtn" class="secondary-btn">
|
||||||
|
<i class="fas fa-file-import"></i> <span>Migrate</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-help">
|
||||||
|
Pattern to find existing example images. Use {model} for model filename, {index} for numbering, and {ext} for file extension.<br>
|
||||||
|
Example patterns: "{model}.example.{index}.{ext}", "{model}_{index}.{ext}", "{model}/{model}.example.{index}.{ext}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item" data-requires-centralized="true">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="optimizeExampleImages">Optimize Downloaded Images</label>
|
<label for="optimizeExampleImages">Optimize Downloaded Images</label>
|
||||||
@@ -392,3 +463,142 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Modal -->
|
||||||
|
<div id="helpModal" class="modal">
|
||||||
|
<div class="modal-content help-modal">
|
||||||
|
<button class="close" onclick="modalManager.closeModal('helpModal')">×</button>
|
||||||
|
<div class="help-header">
|
||||||
|
<h2>Help & Tutorials</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="getting-started">Getting Started</button>
|
||||||
|
<button class="tab-btn" data-tab="update-vlogs">Update Vlogs</button>
|
||||||
|
<button class="tab-btn" data-tab="documentation">Documentation</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-content">
|
||||||
|
<!-- Getting Started Tab -->
|
||||||
|
<div class="tab-pane active" id="getting-started">
|
||||||
|
<h3>Getting Started with LoRA Manager</h3>
|
||||||
|
<div class="video-embed">
|
||||||
|
<iframe src="https://www.youtube.com/embed/hvKw31YpE-U"
|
||||||
|
title="Getting Started with LoRA Manager"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="help-text">
|
||||||
|
<h4>Key Features:</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Seamless Integration:</strong> One-click workflow integration with ComfyUI</li>
|
||||||
|
<li><strong>Visual Management:</strong> Organize and manage all your LoRA models with an intuitive interface</li>
|
||||||
|
<li><strong>Automatic Metadata:</strong> Fetch previews, trigger words, and details automatically</li>
|
||||||
|
<li><strong>Civitai Integration:</strong> Direct downloads with full API support</li>
|
||||||
|
<li><strong>Offline Preview Storage:</strong> Store and manage model examples locally</li>
|
||||||
|
<li><strong>Advanced Controls:</strong> Trigger word toggles and customizable loader node</li>
|
||||||
|
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Update Vlogs Tab -->
|
||||||
|
<div class="tab-pane" id="update-vlogs">
|
||||||
|
<h3>
|
||||||
|
Latest Updates
|
||||||
|
<span class="update-date-badge">
|
||||||
|
<i class="fas fa-calendar-alt"></i>
|
||||||
|
Apr 28, 2025
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div class="video-list">
|
||||||
|
<div class="video-item">
|
||||||
|
<div class="video-embed small">
|
||||||
|
<iframe src="https://www.youtube.com/embed/videoseries?list=PLU2fMdHNl8ohz1u7Ke3ooOuMbU5Y4sgoj"
|
||||||
|
title="LoRA Manager Updates"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="video-info">
|
||||||
|
<h4>LoRA Manager Updates Playlist</h4>
|
||||||
|
<p>Watch all update videos showcasing the latest features and improvements.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documentation Tab -->
|
||||||
|
<div class="tab-pane" id="documentation">
|
||||||
|
<h3>Documentation</h3>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h4><i class="fas fa-book"></i> General</h4>
|
||||||
|
<ul class="docs-links">
|
||||||
|
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki" target="_blank">Wiki Home</a></li>
|
||||||
|
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/README.md" target="_blank">README</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h4><i class="fas fa-tools"></i> Troubleshooting</h4>
|
||||||
|
<ul class="docs-links">
|
||||||
|
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/FAQ-(Frequently-Asked-Questions)" target="_blank">FAQ (Frequently Asked Questions)</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h4><i class="fas fa-layer-group"></i> Model Management</h4>
|
||||||
|
<ul class="docs-links">
|
||||||
|
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Example-Images" target="_blank">Example Images (WIP)</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h4><i class="fas fa-book-open"></i> Recipes</h4>
|
||||||
|
<ul class="docs-links">
|
||||||
|
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/%F0%9F%93%96-Recipes-Feature-Tutorial-%E2%80%93-ComfyUI-LoRA-Manager" target="_blank">Recipes Tutorial</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-section">
|
||||||
|
<h4><i class="fas fa-cog"></i> Settings & Configuration</h4>
|
||||||
|
<ul class="docs-links">
|
||||||
|
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Configuration" target="_blank">Configuration Options (WIP)</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Re-link to Civitai Modal -->
|
||||||
|
<div id="relinkCivitaiModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="close" onclick="modalManager.closeModal('relinkCivitaiModal')">×</button>
|
||||||
|
<h2>Re-link to Civitai</h2>
|
||||||
|
<div class="warning-box">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<p><strong>Warning:</strong> This is a potentially destructive operation. Re-linking will:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Override existing metadata</li>
|
||||||
|
<li>Potentially modify the model hash</li>
|
||||||
|
<li>May have other unintended consequences</li>
|
||||||
|
</ul>
|
||||||
|
<p>Only proceed if you're sure this is what you want.</p>
|
||||||
|
</div>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="civitaiModelUrl">Civitai Model URL:</label>
|
||||||
|
<input type="text" id="civitaiModelUrl" placeholder="https://civitai.com/models/1098030?modelVersionId=1233411" />
|
||||||
|
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||||
|
<div class="input-help">
|
||||||
|
The URL must include the modelVersionId parameter.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel-btn" onclick="modalManager.closeModal('relinkCivitaiModal')">Cancel</button>
|
||||||
|
<button class="confirm-btn" id="confirmRelinkBtn">Confirm Re-link</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -20,6 +20,28 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
{% include 'components/alphabet_bar.html' %}
|
{% include 'components/alphabet_bar.html' %}
|
||||||
|
|
||||||
|
<!-- Duplicates banner (hidden by default) -->
|
||||||
|
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||||
|
<div class="banner-content">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
<span id="duplicatesCount">Found 0 duplicate groups</span>
|
||||||
|
<i class="fas fa-question-circle help-icon" id="duplicatesHelp" aria-label="Help information"></i>
|
||||||
|
<div class="banner-actions">
|
||||||
|
<button class="btn-delete-selected disabled" onclick="modelDuplicatesManager.deleteSelectedDuplicates()">
|
||||||
|
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
|
||||||
|
</button>
|
||||||
|
<button class="btn-exit-mode" onclick="modelDuplicatesManager.exitDuplicateMode()">
|
||||||
|
<i class="fas fa-times"></i> Exit Mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-tooltip" id="duplicatesHelpTooltip">
|
||||||
|
<p>Identical hashes mean identical model files, even if they have different names or previews.</p>
|
||||||
|
<p>Keep only one version (preferably with better metadata/previews) and safely delete the others.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Lora卡片容器 -->
|
<!-- Lora卡片容器 -->
|
||||||
<div class="card-grid" id="loraGrid">
|
<div class="card-grid" id="loraGrid">
|
||||||
<!-- Cards will be dynamically inserted here -->
|
<!-- Cards will be dynamically inserted here -->
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Add duplicate detection button -->
|
<!-- Add duplicate detection button -->
|
||||||
<div title="Find duplicate recipes" class="control-group">
|
<div title="Find duplicate recipes" class="control-group">
|
||||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> Find Duplicates</button>
|
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> Duplicates</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Custom filter indicator button (hidden by default) -->
|
<!-- Custom filter indicator button (hidden by default) -->
|
||||||
<div id="customFilterIndicator" class="control-group hidden">
|
<div id="customFilterIndicator" class="control-group hidden">
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
Keep Latest Versions
|
Keep Latest Versions
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
|
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
|
||||||
Delete Selected (<span id="selectedCount">0</span>)
|
Delete Selected (<span id="duplicatesSelectedCount">0</span>)
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
|
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ app.registerExtension({
|
|||||||
|
|
||||||
// Restore saved value if exists
|
// Restore saved value if exists
|
||||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||||
// 0 is group mode, 1 is input, 2 is tag widget, 3 is original message
|
// 0 is group mode, 1 is default_active, 2 is input, 3 is tag widget, 4 is original message
|
||||||
const savedValue = node.widgets_values[1];
|
const savedValue = node.widgets_values[2];
|
||||||
if (savedValue) {
|
if (savedValue) {
|
||||||
result.widget.value = savedValue;
|
result.widget.value = Array.isArray(savedValue) ? savedValue : [];
|
||||||
}
|
}
|
||||||
const originalMessage = node.widgets_values[2];
|
const originalMessage = node.widgets_values[3];
|
||||||
if (originalMessage) {
|
if (originalMessage) {
|
||||||
hiddenWidget.value = originalMessage;
|
hiddenWidget.value = originalMessage;
|
||||||
}
|
}
|
||||||
@@ -62,8 +62,16 @@ app.registerExtension({
|
|||||||
|
|
||||||
const groupModeWidget = node.widgets[0];
|
const groupModeWidget = node.widgets[0];
|
||||||
groupModeWidget.callback = (value) => {
|
groupModeWidget.callback = (value) => {
|
||||||
if (node.widgets[2].value) {
|
if (node.widgets[3].value) {
|
||||||
this.updateTagsBasedOnMode(node, node.widgets[2].value, value);
|
this.updateTagsBasedOnMode(node, node.widgets[3].value, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add callback for default_active widget
|
||||||
|
const defaultActiveWidget = node.widgets[1];
|
||||||
|
defaultActiveWidget.callback = (value) => {
|
||||||
|
if (node.widgets[3].value) {
|
||||||
|
this.updateTagsBasedOnMode(node, node.widgets[3].value, groupModeWidget.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -79,7 +87,7 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the original message for mode switching
|
// Store the original message for mode switching
|
||||||
node.widgets[2].value = message;
|
node.widgets[3].value = message;
|
||||||
|
|
||||||
if (node.tagWidget) {
|
if (node.tagWidget) {
|
||||||
// Parse tags based on current group mode
|
// Parse tags based on current group mode
|
||||||
@@ -100,6 +108,9 @@ app.registerExtension({
|
|||||||
existingTagMap[tag.text] = tag.active;
|
existingTagMap[tag.text] = tag.active;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get default active state from the widget
|
||||||
|
const defaultActive = node.widgets[1] ? node.widgets[1].value : true;
|
||||||
|
|
||||||
let tagArray = [];
|
let tagArray = [];
|
||||||
|
|
||||||
if (groupMode) {
|
if (groupMode) {
|
||||||
@@ -114,13 +125,15 @@ app.registerExtension({
|
|||||||
.filter(group => group)
|
.filter(group => group)
|
||||||
.map(group => ({
|
.map(group => ({
|
||||||
text: group,
|
text: group,
|
||||||
active: existingTagMap[group] !== undefined ? existingTagMap[group] : true
|
// Use defaultActive only for new tags
|
||||||
|
active: existingTagMap[group] !== undefined ? existingTagMap[group] : defaultActive
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// If no ',,' delimiter, treat the entire message as one group
|
// If no ',,' delimiter, treat the entire message as one group
|
||||||
tagArray = [{
|
tagArray = [{
|
||||||
text: message.trim(),
|
text: message.trim(),
|
||||||
active: existingTagMap[message.trim()] !== undefined ? existingTagMap[message.trim()] : true
|
// Use defaultActive only for new tags
|
||||||
|
active: existingTagMap[message.trim()] !== undefined ? existingTagMap[message.trim()] : defaultActive
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -131,7 +144,8 @@ app.registerExtension({
|
|||||||
.filter(word => word)
|
.filter(word => word)
|
||||||
.map(word => ({
|
.map(word => ({
|
||||||
text: word,
|
text: word,
|
||||||
active: existingTagMap[word] !== undefined ? existingTagMap[word] : true
|
// Use defaultActive only for new tags
|
||||||
|
active: existingTagMap[word] !== undefined ? existingTagMap[word] : defaultActive
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const extension = {
|
|||||||
app.registerExtension(extension);
|
app.registerExtension(extension);
|
||||||
const config = {
|
const config = {
|
||||||
newTab: true,
|
newTab: true,
|
||||||
|
newWindow: {
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => {
|
const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => {
|
||||||
@@ -32,12 +36,18 @@ const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => {
|
|||||||
return button;
|
return button;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = (e) => {
|
||||||
const loraManagerUrl = `${window.location.origin}/loras`;
|
const loraManagerUrl = `${window.location.origin}/loras`;
|
||||||
if (config.newTab) {
|
|
||||||
window.open(loraManagerUrl, '_blank');
|
// Check if Shift key is pressed to determine how to open
|
||||||
|
if (e.shiftKey) {
|
||||||
|
// Open in new window
|
||||||
|
const { width, height } = config.newWindow;
|
||||||
|
const windowFeatures = `width=${width},height=${height},resizable=yes,scrollbars=yes,status=yes`;
|
||||||
|
window.open(loraManagerUrl, '_blank', windowFeatures);
|
||||||
} else {
|
} else {
|
||||||
window.location.href = loraManagerUrl;
|
// Default behavior: open in new tab
|
||||||
|
window.open(loraManagerUrl, '_blank');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +63,7 @@ const addWidgetMenuRight = (menuRight) => {
|
|||||||
const loraManagerButton = createWidget({
|
const loraManagerButton = createWidget({
|
||||||
className: 'comfyui-button comfyui-menu-mobile-collapse primary',
|
className: 'comfyui-button comfyui-menu-mobile-collapse primary',
|
||||||
text: '',
|
text: '',
|
||||||
tooltip: 'Launch Lora Manager',
|
tooltip: 'Launch Lora Manager (Shift+Click to open in new window)',
|
||||||
includeIcon: true,
|
includeIcon: true,
|
||||||
svgMarkup: getLoraManagerIcon(),
|
svgMarkup: getLoraManagerIcon(),
|
||||||
});
|
});
|
||||||
@@ -70,7 +80,7 @@ const addWidgetMenu = (menu) => {
|
|||||||
const loraManagerButton = createWidget({
|
const loraManagerButton = createWidget({
|
||||||
className: 'comfy-lora-manager-button',
|
className: 'comfy-lora-manager-button',
|
||||||
text: 'Lora Manager',
|
text: 'Lora Manager',
|
||||||
tooltip: 'Launch Lora Manager',
|
tooltip: 'Launch Lora Manager (Shift+Click to open in new window)',
|
||||||
includeIcon: false,
|
includeIcon: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user