Compare commits

..

41 Commits

Author SHA1 Message Date
Will Miao
18cdaabf5e Update release notes and version to v0.8.17, adding new features including duplicate model detection, enhanced URL recipe imports, and improved trigger word control. 2025-06-09 19:07:53 +08:00
Will Miao
787e37b7c6 Add CivitAI re-linking functionality and related UI components. Fixes #216
- Implemented new API endpoints for re-linking models to CivitAI.
- Added context menu options for re-linking in both Lora and Checkpoint context menus.
- Created a modal for user confirmation and input for CivitAI model URL.
- Updated styles for the new modal and context menu items.
- Enhanced error handling and user feedback during the re-linking process.
2025-06-09 17:23:03 +08:00
Will Miao
4e5c8b2dd0 Add help modal functionality and update related UI components 2025-06-09 14:55:18 +08:00
Will Miao
d8ddacde38 Remove 'folder' field from model metadata before saving to file. See #211 2025-06-09 11:26:24 +08:00
Will Miao
bb1e42f0d3 Add restart required icon to example images download location label. See #212 2025-06-08 20:43:10 +08:00
pixelpaws
923669c495 Merge pull request #213 from willmiao/migrate-images
Migrate images
2025-06-08 20:11:37 +08:00
Will Miao
7a4139544c Add method to update model metadata from local example images. Fixes #211 2025-06-08 20:10:36 +08:00
Will Miao
4d6ea0236b Add centralized example images setting and update related UI components 2025-06-08 17:38:46 +08:00
Will Miao
e872a06f22 Refactor MiscRoutes and move example images related api to ExampleImagesRoutes 2025-06-08 14:40:30 +08:00
Will Miao
647bda2160 Add API endpoint and frontend integration for fetching example image files 2025-06-07 22:31:57 +08:00
Will Miao
c1e93d23f3 Merge branch 'migrate-images' of https://github.com/willmiao/ComfyUI-Lora-Manager into migrate-images 2025-06-07 11:32:55 +08:00
Will Miao
c96550cc68 Enhance migration and download processes: add backend path update and prevent duplicate completion toasts 2025-06-07 11:29:53 +08:00
Will Miao
b1015ecdc5 Add migration functionality for example images: implement API endpoint and UI controls 2025-06-07 11:27:25 +08:00
Will Miao
f1b928a037 Add migration functionality for example images: implement API endpoint and UI controls 2025-06-07 09:34:07 +08:00
Will Miao
16c312c90b Fix version description not showing. Fixes #210 2025-06-07 01:29:38 +08:00
Will Miao
110ffd0118 Refactor modal close behavior: ensure consistent handling of closeOnOutsideClick option across multiple modals. 2025-06-06 10:32:18 +08:00
Will Miao
35ad872419 Enhance duplicates management: add help tooltip for duplicate groups and improve responsive styling for banners and groups. 2025-06-05 15:06:53 +08:00
Will Miao
9b943cf2b8 Update custom node icon 2025-06-05 06:48:48 +08:00
Will Miao
9d1b357e64 Enhance cache validation logic: add logging for version and model type mismatches, and relax directory structure checks to improve cache validity. 2025-06-04 20:47:14 +08:00
Will Miao
9fc2fb4d17 Enhance model caching and exclusion functionality: update cache version, add excluded models to cache data, and ensure cache is saved to disk after model exclusion and deletion. 2025-06-04 18:38:45 +08:00
Will Miao
641fa8a3d9 Enhance duplicates mode functionality: add toggle for entering/exiting mode, improve exit button styling, and manage control button states during duplicates mode. 2025-06-04 16:46:57 +08:00
Will Miao
add9269706 Enhance duplicate mode exit logic: hide duplicates banner, clear model grid, and re-enable virtual scrolling. Improve spacer element handling in VirtualScroller by recreating it if not found in the DOM. 2025-06-04 16:05:57 +08:00
Will Miao
1a01c4a344 Refactor trigger words UI handling: improve event listener management, restore original words on cancel, and enhance dropdown update logic. See #147 2025-06-04 15:02:13 +08:00
Will Miao
b4e7feed06 Enhance trained words extraction and display: include class tokens in response and update UI accordingly. See #147 2025-06-04 12:04:38 +08:00
Will Miao
4b96c650eb Enhance example image handling: improve filename extraction and fallback for local images 2025-06-04 11:30:56 +08:00
Will Miao
107aef3785 Enhance SaveImage and TriggerWordToggle: add tooltips for parameters to improve user guidance 2025-06-03 19:40:01 +08:00
Will Miao
b49807824f Fix optimizeExampleImages setting in SettingsManager 2025-06-03 18:10:43 +08:00
Will Miao
e5ef2ef8b5 Add default_active parameter to TriggerWordToggle for controlling default state 2025-06-03 17:45:52 +08:00
Will Miao
88779ed56c Enhance Lora Manager widget: add configurable window size for Shift+Click behavior 2025-06-03 16:25:31 +08:00
Will Miao
8b59fb6adc Refactor ShowcaseView and uiHelpers for improved image/video handling
- Moved getLocalExampleImageUrl function to uiHelpers.js for better modularity.
- Updated ShowcaseView.js to utilize the new structure for local and fallback URLs.
- Enhanced lazy loading functions to support both primary and fallback URLs for images and videos.
- Simplified metadata panel generation in ShowcaseView.js.
- Improved showcase toggle functionality and added initialization for lazy loading and metadata handlers.
2025-06-03 16:06:54 +08:00
Will Miao
7945647b0b Refactor core application and recipe manager: remove lazy loading functionality and clean up imports in uiHelpers. 2025-06-03 15:40:51 +08:00
Will Miao
2d39b84806 Add CivitaiApiMetadataParser and improve recipe parsing logic for Civitai images. Also fixes #197
Additional info: Now prioritizes using the Civitai Images API to fetch image and generation metadata. Even NSFW images can now be imported via URL.
2025-06-03 14:58:43 +08:00
Will Miao
e151a19fcf Implement bulk operations for LoRAs: add send to workflow and bulk delete functionality with modal confirmation. 2025-06-03 07:44:52 +08:00
Will Miao
99d2ba26b9 Add API endpoint for fetching trained words and implement dropdown suggestions in the trigger words editor. See #147 2025-06-02 17:04:33 +08:00
Will Miao
396924f4cc Add badge for duplicate count and update logic in ModelDuplicatesManager and PageControls 2025-06-02 09:42:28 +08:00
Will Miao
7545312229 Add bulk delete endpoint for checkpoints and enhance ModelDuplicatesManager for better handling of model types 2025-06-02 08:54:31 +08:00
Will Miao
26f9779fbf Add bulk delete functionality for loras and implement model duplicates management. See #198
- Introduced a new API endpoint for bulk deleting loras.
- Added ModelDuplicatesManager to handle duplicate models for loras and checkpoints.
- Implemented UI components for displaying duplicates and managing selections.
- Enhanced controls with a button for finding duplicates.
- Updated templates to include a duplicates banner and associated actions.
2025-06-02 08:08:45 +08:00
Will Miao
0bd62eef3a Add endpoints for finding duplicate loras and filename conflicts; implement tracking for duplicates in ModelHashIndex and update ModelScanner to handle new data structures. 2025-05-31 20:50:51 +08:00
Will Miao
e06d15f508 Remove LoraHashIndex class and related functionality to streamline codebase. 2025-05-31 20:25:12 +08:00
Will Miao
aa1ee96bc9 Add versioning and history tracking to usage statistics. Implement backup and conversion for old stats format, enhancing data structure for checkpoints and loras. 2025-05-31 16:38:18 +08:00
Will Miao
355c73512d Enhance modal close behavior by tracking mouse events on the background. Implement logic to close modals only if mouseup occurs on the background after mousedown, improving user experience. 2025-05-31 08:53:20 +08:00
69 changed files with 6727 additions and 2520 deletions

View File

@@ -22,6 +22,14 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## 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
* **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

View File

@@ -7,6 +7,7 @@ from .routes.recipe_routes import RecipeRoutes
from .routes.checkpoints_routes import CheckpointsRoutes
from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes
from .routes.example_images_routes import ExampleImagesRoutes
from .services.service_registry import ServiceRegistry
from .services.settings_manager import settings
import logging
@@ -112,6 +113,7 @@ class LoraManager:
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app) # Register miscellaneous routes
ExampleImagesRoutes.setup_routes(app) # Register example images routes
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -31,14 +31,33 @@ class SaveImage:
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
"file_format": (["png", "jpeg", "webp"],),
"filename_prefix": ("STRING", {
"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": {
"lossless_webp": ("BOOLEAN", {"default": False}),
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
"embed_workflow": ("BOOLEAN", {"default": False}),
"add_counter_to_filename": ("BOOLEAN", {"default": True}),
"lossless_webp": ("BOOLEAN", {
"default": False,
"tooltip": "When enabled, saves WebP images with lossless compression. Results in larger files but no quality loss."
}),
"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": {
"id": "UNIQUE_ID",

View File

@@ -16,11 +16,18 @@ class TriggerWordToggle:
def INPUT_TYPES(cls):
return {
"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),
"hidden": {
"id": "UNIQUE_ID", # 会被 ComfyUI 自动替换为唯一ID
"id": "UNIQUE_ID",
},
}
@@ -41,7 +48,7 @@ class TriggerWordToggle:
else:
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
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""

View File

@@ -7,7 +7,8 @@ from .parsers import (
RecipeFormatParser,
ComfyMetadataParser,
MetaFormatParser,
AutomaticMetadataParser
AutomaticMetadataParser,
CivitaiApiMetadataParser
)
__all__ = [
@@ -18,5 +19,6 @@ __all__ = [
'RecipeFormatParser',
'ComfyMetadataParser',
'MetaFormatParser',
'AutomaticMetadataParser'
'AutomaticMetadataParser',
'CivitaiApiMetadataParser'
]

View File

@@ -5,7 +5,8 @@ from .parsers import (
RecipeFormatParser,
ComfyMetadataParser,
MetaFormatParser,
AutomaticMetadataParser
AutomaticMetadataParser,
CivitaiApiMetadataParser
)
from .base import RecipeMetadataParser
@@ -15,29 +16,49 @@ class RecipeParserFactory:
"""Factory for creating recipe metadata parsers"""
@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:
user_comment: The EXIF UserComment string from the image
metadata: The metadata from the image (dict or str)
Returns:
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:
if ComfyMetadataParser().is_metadata_matching(user_comment):
if ComfyMetadataParser().is_metadata_matching(metadata_str):
return ComfyMetadataParser()
except Exception:
# If JSON parsing fails, move on to other parsers
pass
if RecipeFormatParser().is_metadata_matching(user_comment):
# Check other parsers that expect string input
if RecipeFormatParser().is_metadata_matching(metadata_str):
return RecipeFormatParser()
elif AutomaticMetadataParser().is_metadata_matching(user_comment):
elif AutomaticMetadataParser().is_metadata_matching(metadata_str):
return AutomaticMetadataParser()
elif MetaFormatParser().is_metadata_matching(user_comment):
elif MetaFormatParser().is_metadata_matching(metadata_str):
return MetaFormatParser()
else:
return None

View File

@@ -4,10 +4,12 @@ from .recipe_format import RecipeFormatParser
from .comfy import ComfyMetadataParser
from .meta_format import MetaFormatParser
from .automatic import AutomaticMetadataParser
from .civitai_image import CivitaiApiMetadataParser
__all__ = [
'RecipeFormatParser',
'ComfyMetadataParser',
'MetaFormatParser',
'AutomaticMetadataParser',
'CivitaiApiMetadataParser',
]

View 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": []}

View File

@@ -45,6 +45,7 @@ class ApiRoutes:
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/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_get('/api/loras', routes.get_loras)
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
@@ -80,6 +81,13 @@ class ApiRoutes:
# Add update check routes
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:
"""Handle model deletion request"""
if self.scanner is None:
@@ -1169,3 +1177,118 @@ class ApiRoutes:
'success': False,
'error': str(e)
}, 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)

View File

@@ -51,6 +51,7 @@ class CheckpointsRoutes:
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/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/download', self.download_checkpoint)
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
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):
"""Get paginated checkpoint data"""
try:
@@ -695,3 +703,116 @@ class CheckpointsRoutes:
except Exception as e:
logger.error(f"Error fetching checkpoint model versions: {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)

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,13 @@
import logging
import os
import asyncio
import json
import time
import aiohttp
import re
import subprocess
import sys
from server import PromptServer # type: ignore
from aiohttp import web
from ..services.settings_manager import settings
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 ..services.civitai_client import CivitaiClient
from ..utils.routes_common import ModelRouteUtils
import re
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_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
app.router.add_post('/api/update-lora-code', MiscRoutes.update_lora_code)
# Add new route for getting trained words
app.router.add_get('/api/trained-words', MiscRoutes.get_trained_words)
# Add new route for opening example images folder
app.router.add_post('/api/open-example-images-folder', MiscRoutes.open_example_images_folder)
# Add new route for getting model example files
app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
@staticmethod
async def clear_cache(request):
@@ -182,10 +172,14 @@ class MiscRoutes:
usage_stats = UsageStats()
stats = await usage_stats.get_stats()
return web.json_response({
# Add version information to help clients handle format changes
stats_response = {
'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:
logger.error(f"Failed to get usage stats: {e}", exc_info=True)
@@ -194,623 +188,6 @@ class MiscRoutes:
'error': str(e)
}, 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
async def update_lora_code(request):
"""
@@ -893,60 +270,135 @@ class MiscRoutes:
}, status=500)
@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:
{
"model_hash": "sha256_hash" # SHA256 hash of the model
}
Expects a query parameter:
file_path: Path to the safetensors file
"""
try:
# Parse the request body
data = await request.json()
model_hash = data.get('model_hash')
# Get file path from query parameters
file_path = request.query.get('file_path')
if not model_hash:
if not file_path:
return web.json_response({
'success': False,
'error': 'Missing model_hash parameter'
'error': 'Missing file_path parameter'
}, status=400)
# Get the example images path from settings
example_images_path = settings.get('example_images_path')
if not example_images_path:
# Check if file exists and is a safetensors file
if not os.path.exists(file_path):
return web.json_response({
'success': False,
'error': 'No example images path configured. Please set it in the settings panel first.'
}, 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.'
'error': f"File not found: {file_path}"
}, status=404)
if not file_path.lower().endswith('.safetensors'):
return web.json_response({
'success': False,
'error': 'File is not a safetensors file'
}, status=400)
# Open the folder in the file explorer
if os.name == 'nt': # Windows
os.startfile(model_folder)
elif os.name == 'posix': # macOS and Linux
if sys.platform == 'darwin': # macOS
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({
'success': True,
'message': f'Opened example images folder for model {model_hash}'
'trained_words': trained_words,
'class_tokens': class_tokens
})
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({
'success': False,
'error': str(e)

View File

@@ -254,6 +254,7 @@ class RecipeRoutes:
content_type = request.headers.get('Content-Type', '')
is_url_mode = False
metadata = None # Initialize metadata variable
if 'multipart/form-data' in content_type:
# Handle image upload
@@ -287,17 +288,63 @@ class RecipeRoutes:
"loras": []
}, status=400)
# Download image from URL
temp_path = download_civitai_image(url)
# Check if this is a Civitai image URL
import re
civitai_image_match = re.match(r'https://civitai\.com/images/(\d+)', url)
if not temp_path:
return web.json_response({
"error": "Failed to download image from URL",
"loras": []
}, status=400)
if civitai_image_match:
# Extract image ID and fetch image info using get_image_info
image_id = civitai_image_match.group(1)
image_info = await self.civitai_client.get_image_info(image_id)
if not image_info:
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)
# Extract metadata from the image using ExifUtils
metadata = ExifUtils.extract_image_metadata(temp_path)
# 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 not metadata:

View File

@@ -346,3 +346,34 @@ class CivitaiClient:
except Exception as e:
logger.error(f"Error getting hash from Civitai: {e}")
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

View File

@@ -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()

View File

@@ -1,12 +1,15 @@
from typing import Dict, Optional, Set
from typing import Dict, Optional, Set, List
import os
class ModelHashIndex:
"""Index for looking up models by hash or path"""
"""Index for looking up models by hash or filename"""
def __init__(self):
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:
"""Add or update hash index entry"""
@@ -19,6 +22,26 @@ class ModelHashIndex:
# Extract filename without extension
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
if sha256 in self._hash_to_path:
old_path = self._hash_to_path[sha256]
@@ -43,21 +66,123 @@ class ModelHashIndex:
def remove_by_path(self, file_path: str) -> None:
"""Remove entry by file path"""
filename = self._get_filename_from_path(file_path)
if filename in self._filename_to_hash:
hash_val = self._filename_to_hash[filename]
if hash_val in self._hash_to_path:
hash_val = None
# 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._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:
"""Remove entry by hash"""
sha256 = sha256.lower()
if sha256 in self._hash_to_path:
path = self._hash_to_path[sha256]
filename = self._get_filename_from_path(path)
if filename in self._filename_to_hash:
del self._filename_to_hash[filename]
del self._hash_to_path[sha256]
if sha256 not in self._hash_to_path:
return
# Get the path and filename
path = 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:
"""Check if hash exists in index"""
@@ -82,6 +207,8 @@ class ModelHashIndex:
"""Clear all entries"""
self._hash_to_path.clear()
self._filename_to_hash.clear()
self._duplicate_hashes.clear()
self._duplicate_filenames.clear()
def get_all_hashes(self) -> Set[str]:
"""Get all hashes in the index"""
@@ -91,6 +218,14 @@ class ModelHashIndex:
"""Get all filenames in the index"""
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:
"""Get number of entries"""
return len(self._hash_to_path)

View File

@@ -19,7 +19,11 @@ from .websocket_manager import ws_manager
logger = logging.getLogger(__name__)
# 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:
"""Base service for scanning and managing model files"""
@@ -107,10 +111,13 @@ class ModelScanner:
"raw_data": self._cache.raw_data,
"hash_index": {
"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,
"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
@@ -128,6 +135,7 @@ class ModelScanner:
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.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
except Exception as 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:
"""Validate if the loaded cache is still valid"""
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
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
# Check if directories have changed
stored_dirs = cache_data.get("dirs_last_modified", {})
current_dirs = self._get_dirs_last_modified()
# stored_dirs = cache_data.get("dirs_last_modified", {})
# current_dirs = self._get_dirs_last_modified()
# If directory structure has changed, cache is invalid
if set(stored_dirs.keys()) != set(current_dirs.keys()):
return False
# if set(stored_dirs.keys()) != set(current_dirs.keys()):
# 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
# 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", {})
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._duplicate_hashes = hash_index_data.get("duplicate_hashes", {})
self._hash_index._duplicate_filenames = hash_index_data.get("duplicate_filenames", {})
# Load tags count
self._tags_count = cache_data.get("tags_count", {})
# Load excluded models
self._excluded_models = cache_data.get("excluded_models", [])
# Resort the cache
await self._cache.resort()
@@ -1212,3 +1228,166 @@ class ModelScanner:
# Save updated cache to disk
await self._save_cache_to_disk()
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]

View File

@@ -1,8 +1,9 @@
from safetensors import safe_open
from typing import Dict
from typing import Dict, List, Tuple
from .model_utils import determine_base_model
import os
import logging
import json
logger = logging.getLogger(__name__)
@@ -80,4 +81,53 @@ async def extract_checkpoint_metadata(file_path: str) -> dict:
except Exception as e:
logger.error(f"Error extracting checkpoint metadata for {file_path}: {e}")
# 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

View File

@@ -62,7 +62,7 @@ class ModelRouteUtils:
# Update preview if needed
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)
if first_preview:
if (first_preview):
# Determine if content is video or image
is_video = first_preview['type'] == 'video'
@@ -303,6 +303,8 @@ class ModelRouteUtils:
# Update hash index if available
if hasattr(scanner, '_hash_index') and scanner._hash_index:
scanner._hash_index.remove_by_path(file_path)
await scanner._save_cache_to_disk()
return web.json_response({
'success': True,
@@ -484,6 +486,8 @@ class ModelRouteUtils:
# Add to excluded models list
scanner._excluded_models.append(file_path)
await scanner._save_cache_to_disk()
return web.json_response({
'success': True,
@@ -571,3 +575,106 @@ class ModelRouteUtils:
logger.error(f"Error downloading {model_type}: {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)

View File

@@ -4,6 +4,8 @@ import sys
import time
import asyncio
import logging
import datetime
import shutil
from typing import Dict, Set
from ..config import config
@@ -26,6 +28,7 @@ class UsageStats:
# Default stats file name
STATS_FILENAME = "lora_manager_stats.json"
BACKUP_SUFFIX = ".backup"
def __new__(cls):
if cls._instance is None:
@@ -39,8 +42,8 @@ class UsageStats:
# Initialize stats storage
self.stats = {
"checkpoints": {}, # sha256 -> count
"loras": {}, # sha256 -> count
"checkpoints": {}, # sha256 -> { total: count, history: { date: count } }
"loras": {}, # sha256 -> { total: count, history: { date: count } }
"total_executions": 0,
"last_save_time": 0
}
@@ -70,6 +73,68 @@ class UsageStats:
# Use the first lora root
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):
"""Load existing statistics from file"""
try:
@@ -77,18 +142,27 @@ class UsageStats:
with open(self._stats_file_path, 'r', encoding='utf-8') as f:
loaded_stats = json.load(f)
# Update our stats with loaded data
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):
self.stats["loras"] = loaded_stats["loras"]
if "total_executions" in loaded_stats:
self.stats["total_executions"] = loaded_stats["total_executions"]
# Check if old format and needs conversion
if self._is_old_format(loaded_stats):
logger.info("Detected old stats format, performing conversion")
self._backup_old_stats()
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):
self.stats["loras"] = loaded_stats["loras"]
if "total_executions" in loaded_stats:
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}")
except Exception as e:
logger.error(f"Error loading usage statistics: {e}")
@@ -174,15 +248,18 @@ class UsageStats:
# Increment total executions count
self.stats["total_executions"] += 1
# Get today's date in YYYY-MM-DD format
today = datetime.datetime.now().strftime("%Y-%m-%d")
# Process checkpoints
if MODELS in metadata and isinstance(metadata[MODELS], dict):
await self._process_checkpoints(metadata[MODELS])
await self._process_checkpoints(metadata[MODELS], today)
# Process loras
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"""
try:
# Get checkpoint scanner service
@@ -208,12 +285,24 @@ class UsageStats:
# Get hash for this checkpoint
model_hash = checkpoint_scanner.get_hash_by_filename(model_filename)
if model_hash:
# Update stats for this checkpoint
self.stats["checkpoints"][model_hash] = self.stats["checkpoints"].get(model_hash, 0) + 1
# Update stats for this checkpoint with date tracking
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:
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"""
try:
# Get LoRA scanner service
@@ -239,8 +328,20 @@ class UsageStats:
# Get hash for this LoRA
lora_hash = lora_scanner.get_hash_by_filename(lora_name)
if lora_hash:
# Update stats for this LoRA
self.stats["loras"][lora_hash] = self.stats["loras"].get(lora_hash, 0) + 1
# Update stats for this LoRA with date tracking
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:
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):
"""Get usage count for a specific model by hash"""
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":
return self.stats["loras"].get(sha256, 0)
if sha256 in self.stats["loras"]:
return self.stats["loras"][sha256]["total"]
return 0
async def process_execution(self, prompt_id):

View File

@@ -1,7 +1,7 @@
[project]
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."
version = "0.8.16"
version = "0.8.17"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
@@ -25,4 +25,4 @@ Repository = "https://github.com/willmiao/ComfyUI-Lora-Manager"
[tool.comfy]
PublisherId = "willmiao"
DisplayName = "ComfyUI-Lora-Manager"
Icon = ""
Icon = "https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/static/images/android-chrome-512x512.png?raw=true"

View File

@@ -295,6 +295,7 @@ class StandaloneLoraManager(LoraManager):
from py.routes.checkpoints_routes import CheckpointsRoutes
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
from py.routes.example_images_routes import ExampleImagesRoutes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
@@ -306,6 +307,7 @@ class StandaloneLoraManager(LoraManager):
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)
MiscRoutes.setup_routes(app)
ExampleImagesRoutes.setup_routes(app)
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())

View File

@@ -32,13 +32,21 @@ html, body {
--card-bg: #ffffff;
--border-color: #e0e0e0;
/* Color System */
--lora-accent: oklch(68% 0.28 256);
/* Color Components */
--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-border: oklch(90% 0.02 256 / 0.15);
--lora-text: oklch(95% 0.02 256);
--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 */
--space-1: calc(8px * 1);

View File

@@ -60,6 +60,18 @@
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 */
.lora-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
@@ -262,83 +274,6 @@
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 */
@media (max-width: 768px) {
.selected-thumbnails-strip {

View File

@@ -2,25 +2,28 @@
/* Duplicates banner */
.duplicates-banner {
position: relative; /* Changed from sticky to relative */
position: sticky; /* Keep the sticky position */
top: var(--space-1);
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);
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);
padding: 12px 0; /* Removed horizontal padding */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 12px 0;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); /* Stronger shadow */
transition: all 0.3s ease;
margin-bottom: 20px; /* Add margin to create space below the banner */
margin-bottom: 20px;
}
.duplicates-banner .banner-content {
max-width: 1400px; /* Match the container max-width */
position: relative;
max-width: 1400px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px; /* Move horizontal padding to the content */
padding: 0 16px;
}
/* Responsive container for larger screens - match container in layout.css */
@@ -38,7 +41,7 @@
.duplicates-banner i.fa-exclamation-triangle {
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 {
@@ -48,6 +51,29 @@
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 {
min-width: 100px;
display: flex;
@@ -66,7 +92,7 @@
}
.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);
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
@@ -91,23 +117,42 @@
/* Duplicate groups */
.duplicate-group {
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);
padding: 16px;
margin-bottom: 24px;
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 {
background-color: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 8px 16px;
padding: 10px 16px; /* Slightly increased padding */
border-radius: var(--border-radius-xs);
margin-bottom: 16px;
display: flex;
justify-content: space-between;
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 {
@@ -135,7 +180,7 @@
}
.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);
transform: translateY(-1px);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
@@ -190,7 +235,7 @@
}
.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);
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
}
@@ -202,16 +247,16 @@
}
.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 {
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 {
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);
}
@@ -231,7 +276,7 @@
position: absolute;
top: 10px;
left: 10px;
background: oklch(var(--lora-accent));
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h));
color: white;
font-size: 12px;
padding: 2px 6px;
@@ -239,6 +284,128 @@
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 */
@media (max-width: 768px) {
.duplicates-banner .banner-content {
@@ -269,4 +436,50 @@
margin-left: 0;
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));
}

View File

@@ -136,6 +136,30 @@
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 */
@media (max-width: 768px) {
.app-title {

View File

@@ -132,7 +132,7 @@
}
.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);
}
@@ -241,7 +241,7 @@
/* Keep the hover effect using accent color */
.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);
}
@@ -301,7 +301,7 @@
}
.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);
}
@@ -324,6 +324,7 @@
margin-top: var(--space-2);
display: flex;
gap: var(--space-1);
position: relative; /* Added for dropdown positioning */
}
.new-trigger-word-input {
@@ -346,7 +347,7 @@
padding: 4px 8px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background: var(--bg-color);
background: var (--bg-color);
color: var(--text-color);
font-size: 0.85em;
cursor: pointer;
@@ -371,6 +372,146 @@
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-field {
position: relative;
@@ -515,7 +656,7 @@
}
.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);
}
@@ -549,10 +690,6 @@
position: relative;
}
.file-name-wrapper:hover {
background: oklch(var(--lora-accent) / 0.1);
}
.file-name-content {
padding: 2px 4px;
border-radius: var(--border-radius-xs);
@@ -749,7 +886,7 @@
.tab-btn:hover {
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 {
@@ -931,7 +1068,7 @@
.model-description-content pre {
background: rgba(0, 0, 0, 0.05);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
padding: var (--space-1);
white-space: pre-wrap;
margin: 1em 0;
overflow-x: auto;
@@ -1373,6 +1510,34 @@
/* Optional: add hover effect for creator info */
.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);
}
/* 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;
}

View File

@@ -39,4 +39,81 @@
.context-menu-item i {
width: 16px;
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);
}

View File

@@ -110,7 +110,7 @@ body.modal-open {
margin-top: var(--space-3);
}
.cancel-btn, .delete-btn, .exclude-btn {
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
padding: 8px var(--space-2);
border-radius: 6px;
border: none;
@@ -131,7 +131,7 @@ body.modal-open {
}
/* Style for exclude button - different from delete button */
.exclude-btn {
.exclude-btn, .confirm-btn {
background: var(--lora-accent, #4f46e5);
color: white;
}
@@ -144,7 +144,7 @@ body.modal-open {
opacity: 0.9;
}
.exclude-btn:hover {
.exclude-btn:hover, .confirm-btn:hover {
opacity: 0.9;
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
}
@@ -306,6 +306,18 @@ body.modal-open {
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 的样式 */
.support-section,
.changelog-section,
@@ -363,6 +375,12 @@ body.modal-open {
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 */
.setting-row {
display: flex;
@@ -526,7 +544,7 @@ input:checked + .toggle-slider:before {
gap: 8px;
padding: 8px 16px;
background-color: var(--card-bg);
color: var(--text-color);
color: var (--text-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
@@ -554,6 +572,13 @@ input:checked + .toggle-slider:before {
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 */
[data-theme="dark"] .primary-btn:hover {
background-color: oklch(from var(--lora-accent) l c h / 75%);
@@ -693,4 +718,258 @@ input:checked + .toggle-slider:before {
.density-description li {
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);
}

View File

@@ -434,6 +434,8 @@ export function replaceModelPreview(filePath, modelType = 'lora') {
// Delete a model (generic)
export async function deleteModel(filePath, modelType = 'lora') {
try {
state.loadingManager.showSimpleLoading(`Deleting ${modelType}...`);
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/delete'
: '/api/delete_model';
@@ -475,6 +477,8 @@ export async function deleteModel(filePath, modelType = 'lora') {
console.error(`Error deleting ${modelType}:`, error);
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
return false;
} finally {
state.loadingManager.hide();
}
}
@@ -662,6 +666,8 @@ export async function refreshSingleModelMetadata(filePath, modelType = 'lora') {
// Generic function to exclude a model
export async function excludeModel(filePath, modelType = 'lora') {
try {
state.loadingManager.showSimpleLoading(`Excluding ${modelType}...`);
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/exclude'
: '/api/loras/exclude';
@@ -703,6 +709,8 @@ export async function excludeModel(filePath, modelType = 'lora') {
console.error(`Error excluding ${modelType}:`, error);
showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error');
return false;
} finally {
state.loadingManager.hide();
}
}

View File

@@ -4,6 +4,7 @@ import { createPageControls } from './components/controls/index.js';
import { loadMoreCheckpoints } from './api/checkpointApi.js';
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
// Initialize the Checkpoints page
class CheckpointsPageManager {
@@ -14,6 +15,9 @@ class CheckpointsPageManager {
// Initialize checkpoint download manager
window.checkpointDownloadManager = new CheckpointDownloadManager();
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this, 'checkpoints');
// Expose only necessary functions to global scope
this._exposeRequiredGlobalFunctions();
}
@@ -29,6 +33,9 @@ class CheckpointsPageManager {
window.checkpointManager = {
loadCheckpoints: (reset) => loadMoreCheckpoints(reset)
};
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
async initialize() {

View File

@@ -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';

View File

@@ -4,6 +4,9 @@ import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../util
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.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 {
constructor() {
@@ -56,6 +59,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Refresh metadata from CivitAI
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath);
break;
case 'relink-civitai':
// Handle re-link to Civitai
this.showRelinkCivitaiModal();
break;
case 'set-nsfw':
// Set NSFW level
this.showNSFWLevelSelector(null, null, this.currentCard);
@@ -319,4 +326,87 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Show selector
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;
}
}
}

View File

@@ -1,9 +1,11 @@
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 { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.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 {
constructor() {
@@ -64,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata':
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
break;
case 'relink-civitai':
this.showRelinkCivitaiModal();
break;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
@@ -93,6 +98,90 @@ export class LoraContextMenu extends BaseContextMenu {
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
initNSFWSelector() {
// Close button

View File

@@ -2,7 +2,6 @@
import { showToast } from '../utils/uiHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
export class DuplicatesManager {
constructor(recipeManager) {
@@ -14,8 +13,6 @@ export class DuplicatesManager {
async findDuplicates() {
try {
document.body.classList.add('loading');
const response = await fetch('/api/recipes/find-duplicates');
if (!response.ok) {
throw new Error('Failed to find duplicates');
@@ -39,8 +36,6 @@ export class DuplicatesManager {
console.error('Error finding duplicates:', error);
showToast('Failed to find duplicates: ' + error.message, 'error');
return false;
} finally {
document.body.classList.remove('loading');
}
}
@@ -100,14 +95,7 @@ export class DuplicatesManager {
}
// Re-enable virtual scrolling
if (state.virtualScroller) {
state.virtualScroller.enable();
} else {
// If virtual scroller doesn't exist, reinitialize it
setTimeout(() => {
initializeInfiniteScroll('recipes');
}, 100);
}
state.virtualScroller.enable();
}
renderDuplicateGroups() {
@@ -234,7 +222,7 @@ export class DuplicatesManager {
}
updateSelectedCount() {
const selectedCountEl = document.getElementById('selectedCount');
const selectedCountEl = document.getElementById('duplicatesSelectedCount');
if (selectedCountEl) {
selectedCountEl.textContent = this.selectedForDeletion.size;
}
@@ -358,9 +346,7 @@ export class DuplicatesManager {
// Add new method to execute deletion after confirmation
async confirmDeleteDuplicates() {
try {
document.body.classList.add('loading');
try {
// Close the modal
modalManager.closeModal('duplicateDeleteModal');
@@ -395,8 +381,6 @@ export class DuplicatesManager {
} catch (error) {
console.error('Error deleting recipes:', error);
showToast('Failed to delete recipes: ' + error.message, 'error');
} finally {
document.body.classList.remove('loading');
}
}
}

View File

@@ -75,7 +75,9 @@ export class HeaderManager {
const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) {
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');
// }
// });
// }
}
}

View File

@@ -1,5 +1,5 @@
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 { bulkManager } from '../managers/BulkManager.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)
const pageState = getCurrentPageState();
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
} else if (pageState && pageState.duplicatesMode) {
// In duplicates mode, don't open modal when clicking cards
return;
} else {
// Normal behavior - show modal
const loraMeta = {

View 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';
}
});
}
}

View File

@@ -2,44 +2,22 @@
* ShowcaseView.js
* 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 { 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
* @param {Array} images - Array of images/videos to show
* @param {string} modelHash - Model hash for identifying local files
* @param {Array} exampleFiles - Local example files already fetched
* @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>';
// Filter images based on SFW setting
@@ -82,9 +60,85 @@ export function renderShowcaseContent(images, modelHash) {
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => {
// Try to get local URL for the example image
const localUrl = getLocalExampleImageUrl(img, index, modelHash);
return generateMediaWrapper(img, localUrl);
// Find matching file in our list of actual files
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;
});
// 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('')}
</div>
</div>
@@ -96,11 +150,8 @@ export function renderShowcaseContent(images, modelHash) {
* @param {Object} media - Media object with image or video data
* @returns {string} HTML content
*/
function generateMediaWrapper(media, localUrl = null) {
// 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
function generateMediaWrapper(media, urls) {
// Calculate appropriate aspect ratio
const aspectRatio = (media.height / media.width) * 100;
const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
@@ -149,10 +200,10 @@ function generateMediaWrapper(media, localUrl = null) {
// Check if this is a video or image
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
*/
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) {
function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -236,9 +287,9 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer"
data-local-src="${localUrl || ''}"
data-remote-src="${media.url}"
data-remote-src="${remoteUrl}"
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
</video>
${shouldBlur ? `
@@ -257,7 +308,7 @@ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metada
/**
* Generate image wrapper HTML
*/
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) {
function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
${shouldBlur ? `
@@ -265,8 +316,8 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
<i class="fas fa-eye"></i>
</button>
` : ''}
<img data-local-src="${localUrl || ''}"
data-remote-src="${media.url}"
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
@@ -286,410 +337,10 @@ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metada
`;
}
/**
* Toggle showcase expansion
*/
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');
}
}
// Use the shared setupShowcaseScroll function with the correct modal ID
export { setupShowcaseScroll, scrollToTop, toggleShowcase };
/**
* 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'
});
}
}
// Initialize the showcase scroll when this module is imported
document.addEventListener('DOMContentLoaded', () => {
setupShowcaseScroll('checkpointModal');
});

View File

@@ -3,8 +3,7 @@
*
* Modularized checkpoint modal component that handles checkpoint model details display
*/
import { showToast } from '../../utils/uiHelpers.js';
import { state } from '../../state/index.js';
import { showToast, getExampleImageFiles, initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
@@ -16,6 +15,7 @@ import {
import { saveModelMetadata } from '../../api/checkpointApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/**
* Display the checkpoint modal with the given checkpoint data
@@ -97,7 +97,7 @@ export function showCheckpointModal(checkpoint) {
</div>
<div class="info-item full-width">
<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>
@@ -110,7 +110,9 @@ export function showCheckpointModal(checkpoint) {
<div class="tab-content">
<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 id="description-tab" class="tab-pane">
@@ -146,6 +148,69 @@ export function showCheckpointModal(checkpoint) {
if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) {
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>
`;
}
}
}
/**

View File

@@ -175,6 +175,12 @@ export class PageControls {
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') {
// Bulk operations button - LoRAs only
const bulkButton = document.querySelector('[data-action="bulk"]');
@@ -399,6 +405,11 @@ export class PageControls {
console.error(`Error ${fullRebuild ? 'rebuilding' : 'refreshing'} ${this.pageType}:`, 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
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');
}
}
}

View File

@@ -2,7 +2,6 @@
import { PageControls } from './PageControls.js';
import { LorasControls } from './LorasControls.js';
import { CheckpointsControls } from './CheckpointsControls.js';
import { refreshVirtualScroll } from '../../utils/infiniteScroll.js';
// Export the classes
export { PageControls, LorasControls, CheckpointsControls };
@@ -21,17 +20,4 @@ export function createPageControls(pageType) {
console.error(`Unknown page type: ${pageType}`);
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);
}
}

View File

@@ -2,44 +2,21 @@
* ShowcaseView.js
* 处理LoRA模型展示内容图片、视频的功能模块
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop
} from '../../utils/uiHelpers.js';
import { state } from '../../state/index.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 {string} modelHash - Model hash for identifying local files
* @returns {string} HTML内容
* @param {Array} exampleFiles - Local example files already fetched
* @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>';
// Filter images based on SFW setting
@@ -82,21 +59,30 @@ export function renderShowcaseContent(images, modelHash) {
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => {
// Try to get local URL for the example image
const localUrl = getLocalExampleImageUrl(img, index, modelHash);
// Find matching file in our list of actual files
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;
});
// If not found by index, just use the same position in the array if available
if (!localFile && index < exampleFiles.length) {
localFile = exampleFiles[index];
}
}
// Create data attributes for both remote and local URLs
const remoteUrl = img.url;
const dataRemoteSrc = remoteUrl;
const dataLocalSrc = localUrl;
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// 计算适当的展示高度
// 1. 保持原始宽高比
// 2. 限制最大高度为视窗高度的60%
// 3. 确保最小高度为容器宽度的40%
// 计算适当的展示高度
const aspectRatio = (img.height / img.width) * 100;
const containerWidth = 800; // modal content的最大宽度
const minHeightPercent = 40; // 最小高度为容器宽度的40%
const containerWidth = 800;
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
const heightPercent = Math.max(
minHeightPercent,
@@ -129,96 +115,98 @@ export function renderShowcaseContent(images, modelHash) {
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;
// If no metadata available, show a message
if (!hasParams && !hasPrompts) {
const metadataPanel = `
<div class="image-metadata-panel">
<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') {
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
}
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, dataLocalSrc, dataRemoteSrc);
const metadataPanel = generateMetadataPanel(
hasParams, hasPrompts,
prompt, negativePrompt,
size, seed, model, steps, sampler, cfgScale, clipSkip
);
if (isVideo) {
return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}
// Create a data attribute with the prompt for copying instead of trying to handle it in the onclick
// This avoids issues with quotes and special characters
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);
return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl
);
}).join('')}
</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
*/
@@ -283,422 +271,10 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
`;
}
/**
* 切换展示区域的显示状态
*/
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);
}
}
}
// Use the shared setupShowcaseScroll function with the correct modal ID
export { setupShowcaseScroll, scrollToTop, toggleShowcase };
/**
* 初始化元数据面板交互处理
*/
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'
});
}
}
// Initialize the showcase scroll when this module is imported
document.addEventListener('DOMContentLoaded', () => {
setupShowcaseScroll('loraModal');
});

View File

@@ -1,15 +1,181 @@
/**
* TriggerWords.js
* 处理LoRA模型触发词相关的功能模块
* Module that handles trigger word functionality for LoRA models
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js';
/**
* 渲染触发词
* @param {Array} words - 触发词数组
* @param {string} filePath - 文件路径
* @returns {string} HTML内容
* Fetch trained words for a model
* @param {string} filePath - Path to the model file
* @returns {Promise<Object>} - Object with trained words and class tokens
*/
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) {
if (!words.length) return `
@@ -25,17 +191,12 @@ export function renderTriggerWords(words, filePath) {
<div class="trigger-words-tags" style="display:none;"></div>
</div>
<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">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="add-trigger-word-form" style="display:none;">
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
<button class="confirm-add-trigger-word-btn">Add</button>
<button class="cancel-add-trigger-word-btn">Cancel</button>
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
@@ -64,43 +225,53 @@ export function renderTriggerWords(words, filePath) {
</div>
</div>
<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">
<i class="fas fa-save"></i> Save
</button>
</div>
<div class="add-trigger-word-form" style="display:none;">
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
<button class="confirm-add-trigger-word-btn">Add</button>
<button class="cancel-add-trigger-word-btn">Cancel</button>
<input type="text" class="new-trigger-word-input" placeholder="Type to add or click suggestions below">
</div>
</div>
`;
}
/**
* 设置触发词编辑模式
* Set up trigger words edit mode
*/
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');
if (!editBtn) return;
editBtn.addEventListener('click', function() {
editBtn.addEventListener('click', async function() {
const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls');
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (isEditMode) {
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
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';
addForm.style.display = 'flex';
// If we have no trigger words yet, hide the "No trigger word needed" text
// and show the empty tags container
@@ -112,13 +283,67 @@ export function setupTriggerWordsEditMode() {
// Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => {
tag.onclick = null;
tag.querySelector('.trigger-word-copy').style.display = 'none';
tag.querySelector('.delete-trigger-word-btn').style.display = 'block';
const copyIcon = tag.querySelector('.trigger-word-copy');
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 {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = "Edit trigger words";
// Hide edit controls and input form
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
// and hide the empty tags container
@@ -128,57 +353,26 @@ export function setupTriggerWordsEditMode() {
if (tagsContainer) tagsContainer.style.display = 'none';
}
// Restore original state
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
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';
// Remove dropdown if present
const dropdown = document.querySelector('.trained-words-dropdown');
if (dropdown) dropdown.remove();
}
});
// Set up add trigger word button
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');
// Set up input for adding trigger words
const triggerWordInput = document.querySelector('.new-trigger-word-input');
if (confirmAddBtn && triggerWordInput) {
confirmAddBtn.addEventListener('click', function() {
addNewTriggerWord(triggerWordInput.value);
});
if (triggerWordInput) {
// Add keydown event to input
triggerWordInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
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
const saveBtn = document.querySelector('.save-trigger-words-btn');
if (saveBtn) {
@@ -187,17 +381,92 @@ export function setupTriggerWordsEditMode() {
// Set up delete buttons
document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
tag.remove();
});
// Remove any existing listeners to avoid duplication
btn.removeEventListener('click', deleteTriggerWord);
btn.addEventListener('click', deleteTriggerWord);
});
}
/**
* 添加新触发词
* @param {string} word - 要添加的触发词
* Delete trigger word event handler
* @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) {
word = word.trim();
@@ -263,24 +532,79 @@ function addNewTriggerWord(word) {
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.delete-trigger-word-btn');
deleteBtn.addEventListener('click', function() {
newTag.remove();
});
deleteBtn.addEventListener('click', deleteTriggerWord);
tagsContainer.appendChild(newTag);
// Clear and hide the input form
const triggerWordInput = document.querySelector('.new-trigger-word-input');
triggerWordInput.value = '';
document.querySelector('.add-trigger-word-form').style.display = 'none';
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
/**
* 保存触发词
* 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() {
const filePath = document.querySelector('.edit-trigger-words-btn').dataset.filePath;
const triggerWordTags = document.querySelectorAll('.trigger-word-tag');
const editBtn = document.querySelector('.edit-trigger-words-btn');
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);
try {
@@ -289,9 +613,11 @@ async function saveTriggerWords() {
civitai: { trainedWords: words }
});
// Update UI
const editBtn = document.querySelector('.edit-trigger-words-btn');
editBtn.click(); // Exit edit mode
// Set flag to skip restoring original words when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Exit edit mode without restoring original trigger words
editBtn.click();
// Update the LoRA card's dataset
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
const noTriggerWords = document.querySelector('.no-trigger-words');
const tagsContainer = document.querySelector('.trigger-words-tags');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (words.length === 0 && noTriggerWords) {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
@@ -331,8 +657,8 @@ async function saveTriggerWords() {
}
/**
* 复制触发词到剪贴板
* @param {string} word - 要复制的触发词
* Copy a trigger word to clipboard
* @param {string} word - Word to copy
*/
window.copyTriggerWord = async function(word) {
try {

View File

@@ -3,7 +3,7 @@
*
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { showToast, copyToClipboard, getExampleImageFiles } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
@@ -18,13 +18,13 @@ import {
import { saveModelMetadata } from '../../api/loraApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateLoraCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
/**
* 显示LoRA模型弹窗
* @param {Object} lora - LoRA模型数据
*/
export function showLoraModal(lora) {
console.log('Lora data:', lora);
const escapedWords = lora.civitai?.trainedWords?.length ?
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
@@ -123,7 +123,7 @@ export function showLoraModal(lora) {
</div>
<div class="info-item full-width">
<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>
@@ -137,7 +137,9 @@ export function showLoraModal(lora) {
<div class="tab-content">
<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 id="description-tab" class="tab-pane">
@@ -183,6 +185,70 @@ export function showLoraModal(lora) {
// Load recipes for this Lora
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

View File

@@ -6,7 +6,8 @@ import { updateService } from './managers/UpdateService.js';
import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.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 { migrateStorageItems } from './utils/storageHelpers.js';
import { setupLoraCardEventDelegation } from './components/LoraCard.js';
@@ -30,6 +31,7 @@ export class AppCore {
window.modalManager = modalManager;
window.settingsManager = settingsManager;
window.exampleImagesManager = exampleImagesManager;
window.helpManager = helpManager;
// Initialize UI components
window.headerManager = new HeaderManager();
@@ -38,6 +40,8 @@ export class AppCore {
// Initialize the example images manager
exampleImagesManager.initialize();
// Initialize the help manager
helpManager.initialize();
// Mark as initialized
this.initialized = true;
@@ -61,9 +65,6 @@ export class AppCore {
initializePageFeatures() {
const pageType = this.getPageType();
// Initialize lazy loading for images on all pages
lazyLoadImages();
// Setup event delegation for lora cards if on the loras page
if (pageType === 'loras') {
setupLoraCardEventDelegation();
@@ -84,7 +85,4 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Create and export a singleton instance
export const appCore = new AppCore();
// Export common utilities for global use
export { showToast, lazyLoadImages, initializeInfiniteScroll };
export const appCore = new AppCore();

View File

@@ -9,6 +9,7 @@ import { moveManager } from './managers/MoveManager.js';
import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
// Initialize the LoRA page
class LoraPageManager {
@@ -23,6 +24,9 @@ class LoraPageManager {
// Initialize page controls
this.pageControls = createPageControls('loras');
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this);
// Expose necessary functions to the page that still need global access
// These will be refactored in future updates
this._exposeRequiredGlobalFunctions();
@@ -49,6 +53,9 @@ class LoraPageManager {
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager;
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
async initialize() {

View File

@@ -1,6 +1,7 @@
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 { modalManager } from './ModalManager.js';
export class BulkManager {
constructor() {
@@ -208,6 +209,131 @@ export class BulkManager {
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
}
// Add method to send all selected loras to workflow
async sendAllLorasToWorkflow() {
if (state.selectedLoras.size === 0) {
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
toggleThumbnailStrip() {
// If no items are selected, do nothing

View File

@@ -11,6 +11,8 @@ class ExampleImagesManager {
this.progressPanel = null;
this.isProgressPanelCollapsed = false;
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
this.initializePathOptions();
@@ -46,6 +48,12 @@ class ExampleImagesManager {
if (collapseBtn) {
collapseBtn.onclick = () => this.toggleProgressPanel();
}
// Initialize migration button handler
const migrateBtn = document.getElementById('exampleImagesMigrateBtn');
if (migrateBtn) {
migrateBtn.onclick = () => this.handleMigrateButton();
}
}
// 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() {
try {
const response = await fetch('/api/example-images-status');
@@ -224,6 +321,7 @@ class ExampleImagesManager {
if (data.success) {
this.isDownloading = true;
this.isPaused = false;
this.hasShownCompletionToast = false; // Reset toast flag when starting new download
this.startTime = new Date();
this.updateUI(data.status);
this.showProgressPanel();
@@ -334,6 +432,7 @@ class ExampleImagesManager {
if (data.success) {
this.isDownloading = data.is_downloading;
this.isPaused = data.status.status === 'paused';
this.isMigrating = data.is_migrating || false;
// Update download button text
this.updateDownloadButtonText();
@@ -345,12 +444,19 @@ class ExampleImagesManager {
clearInterval(this.progressUpdateInterval);
this.progressUpdateInterval = null;
if (data.status.status === 'completed') {
showToast('Example images download completed', 'success');
if (data.status.status === 'completed' && !this.hasShownCompletionToast) {
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
setTimeout(() => this.hideProgressPanel(), 5000);
} 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);
}
}
// 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
@@ -536,8 +655,10 @@ class ExampleImagesManager {
}
getStatusText(status) {
const prefix = this.isMigrating ? 'Migrating' : 'Downloading';
switch (status) {
case 'running': return 'Downloading';
case 'running': return this.isMigrating ? 'Migrating' : 'Downloading';
case 'paused': return 'Paused';
case 'completed': return 'Completed';
case 'error': return 'Error';

View 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();

View File

@@ -58,8 +58,17 @@ export class ImportManager {
this.stepManager.removeInjectedStyles();
});
// Verify visibility
setTimeout(() => this.ensureModalVisible(), 50);
// Verify visibility and focus on URL input
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() {

View File

@@ -3,6 +3,7 @@ export class ModalManager {
this.modals = new Map();
this.scrollPosition = 0;
this.currentOpenModal = null; // Track currently open modal
this.mouseDownOnBackground = false; // Track if mousedown happened on modal background
}
initialize() {
@@ -44,8 +45,7 @@ export class ModalManager {
onClose: () => {
this.getModal('checkpointDownloadModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
}
});
}
@@ -68,8 +68,7 @@ export class ModalManager {
onClose: () => {
this.getModal('excludeModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
}
});
}
@@ -93,7 +92,8 @@ export class ModalManager {
onClose: () => {
this.getModal('settingsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
},
closeOnOutsideClick: true
});
}
@@ -117,7 +117,8 @@ export class ModalManager {
onClose: () => {
this.getModal('supportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
},
closeOnOutsideClick: true
});
}
@@ -129,7 +130,8 @@ export class ModalManager {
onClose: () => {
this.getModal('updateModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
},
closeOnOutsideClick: true
});
}
@@ -169,6 +171,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
const clearCacheModal = document.getElementById('clearCacheModal');
@@ -182,10 +196,42 @@ export class ModalManager {
});
}
// Set up event listeners for modal toggles
const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) {
supportToggle.addEventListener('click', () => this.toggleModal('supportModal'));
// Add bulkDeleteModal registration
const bulkDeleteModal = document.getElementById('bulkDeleteModal');
if (bulkDeleteModal) {
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);
@@ -201,10 +247,27 @@ export class ModalManager {
// Add click outside handler if specified in config
if (config.closeOnOutsideClick) {
config.element.addEventListener('click', (e) => {
// Track mousedown on modal background
config.element.addEventListener('mousedown', (e) => {
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);
}
// 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
this.scrollPosition = window.scrollY;
if (id === 'deleteModal' || id === 'excludeModal' || id === 'duplicateDeleteModal' || id === 'clearCacheModal') {
modal.element.classList.add('show');
if (
id === "deleteModal" ||
id === "excludeModal" ||
id === "duplicateDeleteModal" ||
id === "modelDuplicateDeleteModal" ||
id === "clearCacheModal" ||
id === "bulkDeleteModal"
) {
modal.element.classList.add("show");
} else {
modal.element.style.display = 'block';
modal.element.style.display = "block";
}
modal.isOpen = true;

View File

@@ -32,6 +32,16 @@ export class SettingsManager {
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
if (typeof state.global.settings.displayDensity === 'undefined') {
if (state.global.settings.compactMode === true) {
@@ -98,6 +108,20 @@ export class SettingsManager {
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
await this.loadLoraRoots();
@@ -172,6 +196,10 @@ export class SettingsManager {
state.global.settings.optimizeExampleImages = value;
} else if (settingKey === 'compact_mode') {
state.global.settings.compactMode = value;
} else if (settingKey === 'use_centralized_examples') {
state.global.settings.useCentralizedExamples = value;
// Update dependent controls state
this.updateExamplesControlsState();
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;
@@ -182,7 +210,7 @@ export class SettingsManager {
try {
// 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 = {};
payload[settingKey] = value;
@@ -391,6 +419,7 @@ export class SettingsManager {
const showOnlySFW = document.getElementById('showOnlySFW').checked;
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
const optimizeExampleImages = document.getElementById('optimizeExampleImages').checked;
// Get backend settings
const apiKey = document.getElementById('civitaiApiKey').value;
@@ -400,6 +429,7 @@ export class SettingsManager {
state.global.settings.show_only_sfw = showOnlySFW;
state.global.settings.default_loras_root = defaultLoraRoot;
state.global.settings.autoplayOnHover = autoplayOnHover;
state.global.settings.optimizeExampleImages = optimizeExampleImages;
// Save settings to localStorage
setStorageItem('settings', state.global.settings);
@@ -413,7 +443,8 @@ export class SettingsManager {
},
body: JSON.stringify({
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
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 = '';
}
});
}
}

View File

@@ -119,7 +119,6 @@ export class UpdateService {
updateBadgeVisibility() {
const updateToggle = document.querySelector('.update-toggle');
const updateBadge = document.querySelector('.update-toggle .update-badge');
const cornerBadge = document.querySelector('.corner-badge');
if (updateToggle) {
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
@@ -134,11 +133,6 @@ export class UpdateService {
updateBadge.classList.toggle('hidden', !shouldShow);
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
}
if (cornerBadge) {
cornerBadge.classList.toggle('hidden', !shouldShow);
console.log("Corner badge visibility:", !shouldShow ? "hidden" : "visible");
}
}
updateModalContent() {

View File

@@ -6,8 +6,8 @@ import { getCurrentPageState, state } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { initializeInfiniteScroll, refreshVirtualScroll } from './utils/infiniteScroll.js';
import { resetAndReload, refreshRecipes } from './api/recipeApi.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes } from './api/recipeApi.js';
class RecipeManager {
constructor() {
@@ -228,11 +228,6 @@ class RecipeManager {
}
this.duplicatesManager.exitDuplicateMode();
// Use a small delay before initializing to ensure DOM is ready
setTimeout(() => {
initializeInfiniteScroll('recipes');
}, 100);
}
}

View File

@@ -44,6 +44,7 @@ export const state = {
selectedLoras: new Set(),
loraMetadataCache: new Map(),
showFavoritesOnly: false,
duplicatesMode: false,
},
recipes: {
@@ -86,6 +87,7 @@ export const state = {
tags: []
},
showFavoritesOnly: false,
duplicatesMode: false,
}
},

View File

@@ -87,6 +87,11 @@ export class VirtualScroller {
}
calculateLayout() {
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
return false
}
// Get container width and style information
const containerWidth = this.containerElement.clientWidth;
const containerStyle = getComputedStyle(this.containerElement);
@@ -761,8 +766,24 @@ export class VirtualScroller {
// Reattach scroll event listener
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Show the spacer element
if (this.spacerElement) {
// Check if spacer element exists in the DOM, if not, recreate it
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';
}
@@ -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() {
this.removeExistingTransitionIndicator();

View File

@@ -10,3 +10,14 @@ export function formatFileSize(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(1024));
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();
}

View File

@@ -37,6 +37,10 @@ export async function confirmDelete() {
}
closeDeleteModal();
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
} catch (error) {
console.error('Error deleting model:', error);
alert(`Error deleting model: ${error}`);
@@ -86,6 +90,10 @@ export async function confirmExclude() {
}
closeExcludeModal();
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
} catch (error) {
console.error('Error excluding model:', error);
}

View File

@@ -1,6 +1,7 @@
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.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
@@ -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() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
@@ -453,4 +441,521 @@ export async function openExampleImagesFolder(modelHash) {
showToast('Failed to open example images folder', 'error');
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 [];
}
}

View File

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

View File

@@ -8,6 +8,9 @@
<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 LoRA Syntax
</div>

View File

@@ -46,6 +46,12 @@
</button>
</div>
{% endif %}
<div class="control-group">
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
<i class="fas fa-clone"></i> Duplicates
<span id="duplicatesBadge" class="badge"></span>
</button>
</div>
<div class="control-group">
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter" title="Show favorites only">
<i class="fas fa-star"></i> Favorites
@@ -101,12 +107,18 @@
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
</span>
<div class="bulk-operations-actions">
<button onclick="bulkManager.sendAllLorasToWorkflow()" title="Send all selected LoRAs to workflow">
<i class="fas fa-arrow-right"></i> Send to Workflow
</button>
<button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax">
<i class="fas fa-copy"></i> Copy All
</button>
<button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
<i class="fas fa-folder-open"></i> Move All
</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">
<i class="fas fa-times"></i> Clear
</button>

View File

@@ -43,6 +43,10 @@
<div class="settings-toggle" title="Settings">
<i class="fas fa-cog"></i>
</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">
<i class="fas fa-bell"></i>
<span class="update-badge hidden"></span>

View File

@@ -24,7 +24,7 @@
</div>
</div>
<!-- Duplicate Delete Confirmation Modal -->
<!-- Recipes Duplicate Delete Confirmation Modal -->
<div id="duplicateDeleteModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2>Delete Duplicate Recipes</h2>
@@ -39,6 +39,21 @@
</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 -->
<div id="clearCacheModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
@@ -54,6 +69,21 @@
</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 -->
<div id="settingsModal" class="modal">
<div class="modal-content settings-modal">
@@ -228,7 +258,27 @@
<div class="setting-item">
<div class="setting-row">
<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 class="setting-control path-control">
<input type="text" id="exampleImagesPath" placeholder="Enter folder path for example images" />
@@ -241,8 +291,29 @@
Enter the folder path where example images from Civitai will be saved
</div>
</div>
<!-- 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">
<div class="setting-item" data-requires-centralized="true">
<div class="setting-row">
<div class="setting-info">
<label for="optimizeExampleImages">Optimize Downloaded Images</label>
@@ -391,4 +462,143 @@
</div>
</div>
</div>
</div>
<!-- Help Modal -->
<div id="helpModal" class="modal">
<div class="modal-content help-modal">
<button class="close" onclick="modalManager.closeModal('helpModal')">&times;</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')">&times;</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>

View File

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

View File

@@ -46,7 +46,7 @@
</div>
<!-- Add duplicate detection button -->
<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>
<!-- Custom filter indicator button (hidden by default) -->
<div id="customFilterIndicator" class="control-group hidden">
@@ -68,7 +68,7 @@
Keep Latest Versions
</button>
<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 class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
<i class="fas fa-times"></i>

View File

@@ -49,12 +49,12 @@ app.registerExtension({
// Restore saved value if exists
if (node.widgets_values && node.widgets_values.length > 0) {
// 0 is group mode, 1 is input, 2 is tag widget, 3 is original message
const savedValue = node.widgets_values[1];
// 0 is group mode, 1 is default_active, 2 is input, 3 is tag widget, 4 is original message
const savedValue = node.widgets_values[2];
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) {
hiddenWidget.value = originalMessage;
}
@@ -62,8 +62,16 @@ app.registerExtension({
const groupModeWidget = node.widgets[0];
groupModeWidget.callback = (value) => {
if (node.widgets[2].value) {
this.updateTagsBasedOnMode(node, node.widgets[2].value, value);
if (node.widgets[3].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
node.widgets[2].value = message;
node.widgets[3].value = message;
if (node.tagWidget) {
// Parse tags based on current group mode
@@ -100,6 +108,9 @@ app.registerExtension({
existingTagMap[tag.text] = tag.active;
});
// Get default active state from the widget
const defaultActive = node.widgets[1] ? node.widgets[1].value : true;
let tagArray = [];
if (groupMode) {
@@ -114,13 +125,15 @@ app.registerExtension({
.filter(group => group)
.map(group => ({
text: group,
active: existingTagMap[group] !== undefined ? existingTagMap[group] : true
// Use defaultActive only for new tags
active: existingTagMap[group] !== undefined ? existingTagMap[group] : defaultActive
}));
} else {
// If no ',,' delimiter, treat the entire message as one group
tagArray = [{
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 {
@@ -131,7 +144,8 @@ app.registerExtension({
.filter(word => word)
.map(word => ({
text: word,
active: existingTagMap[word] !== undefined ? existingTagMap[word] : true
// Use defaultActive only for new tags
active: existingTagMap[word] !== undefined ? existingTagMap[word] : defaultActive
}));
}

View File

@@ -6,6 +6,10 @@ const extension = {
app.registerExtension(extension);
const config = {
newTab: true,
newWindow: {
width: 1200,
height: 800,
}
};
const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => {
@@ -32,12 +36,18 @@ const createWidget = ({ className, text, tooltip, includeIcon, svgMarkup }) => {
return button;
};
const onClick = () => {
const onClick = (e) => {
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 {
window.location.href = loraManagerUrl;
// Default behavior: open in new tab
window.open(loraManagerUrl, '_blank');
}
};
@@ -53,7 +63,7 @@ const addWidgetMenuRight = (menuRight) => {
const loraManagerButton = createWidget({
className: 'comfyui-button comfyui-menu-mobile-collapse primary',
text: '',
tooltip: 'Launch Lora Manager',
tooltip: 'Launch Lora Manager (Shift+Click to open in new window)',
includeIcon: true,
svgMarkup: getLoraManagerIcon(),
});
@@ -70,7 +80,7 @@ const addWidgetMenu = (menu) => {
const loraManagerButton = createWidget({
className: 'comfy-lora-manager-button',
text: 'Lora Manager',
tooltip: 'Launch Lora Manager',
tooltip: 'Launch Lora Manager (Shift+Click to open in new window)',
includeIcon: false,
});