Compare commits

..

19 Commits

Author SHA1 Message Date
Will Miao
28fe3e7b7a chore: update version to 0.8.24 in pyproject.toml 2025-08-02 16:23:19 +08:00
Will Miao
c0eff2bb5e feat: enhance async metadata collection by updating function signature and preserving all parameters. Fixes #328 #327 2025-08-01 21:47:52 +08:00
Will Miao
848c1741fe feat: add parsing for 'air' field in Civitai resources to enhance metadata extraction. Fixes #322 2025-07-31 14:15:22 +08:00
Will Miao
1370b8e8c1 feat: implement drag-and-drop reordering for LoRA entries and enhance keyboard navigation. Fixes #302 2025-07-30 15:32:31 +08:00
Will Miao
82a068e610 feat: auto set default root paths for loras, checkpoints, and embeddings in settings 2025-07-30 10:08:21 +08:00
Will Miao
32f42bafaa chore: update version to 0.8.23 in pyproject.toml 2025-07-29 20:30:45 +08:00
Will Miao
4081b7f022 feat: implement settings synchronization with backend and migrate legacy settings 2025-07-29 20:29:19 +08:00
Will Miao
a5808193a6 fix: rename URL error element ID to 'importUrlError' for consistency across components 2025-07-29 16:13:27 +08:00
Will Miao
854ca322c1 fix: update short_hash in git_info to 'stable' in update_routes.py 2025-07-29 08:34:41 +08:00
Will Miao
c1d9b5137a feat: add version name display to model cards in ModelCard.js and style it in card.css. Fixes #287 2025-07-28 16:36:23 +08:00
Will Miao
f33d5745b3 feat: enhance model description editing functionality in ModelDescription.js and integrate with ModelModal.js. Fixes #292 2025-07-28 11:52:04 +08:00
Will Miao
d89c2ca128 chore: Update version to 0.8.22 in pyproject.toml 2025-07-27 21:20:35 +08:00
Will Miao
835584cc85 fix: update restart message for ComfyUI and LoRA Manager after successful update 2025-07-27 21:20:09 +08:00
Will Miao
b2ffbe3a68 feat: implement fallback ZIP download for plugin updates when .git is missing 2025-07-27 20:56:51 +08:00
Will Miao
defcc79e6c feat: add release notes for v0.8.22 2025-07-27 20:34:46 +08:00
Will Miao
c06d9f84f0 fix: disable pointer events on video element in model card preview 2025-07-27 20:02:21 +08:00
Will Miao
fe57a8e156 feat: implement banner service for managing notification banners, including UI integration and storage handling 2025-07-27 18:07:43 +08:00
Will Miao
b77105795a feat: add embedding support in statistics page, including data handling and UI updates 2025-07-27 16:36:14 +08:00
Will Miao
e2df5fcf27 feat: add default embedding root setting and load functionality in settings manager 2025-07-27 15:58:15 +08:00
46 changed files with 2467 additions and 1033 deletions

View File

@@ -34,6 +34,14 @@ Enhance your Civitai browsing experience with our companion browser extension! S
## Release Notes ## Release Notes
### v0.8.22
* **Embeddings Management** - Added Embeddings page for comprehensive embedding model management.
* **Advanced Sorting Options** - Introduced flexible sorting controls, allowing sorting by name, added date, or file size in both ascending and descending order.
* **Custom Download Path Templates & Base Model Mapping** - Implemented UI settings for configuring download path templates and base model path mappings, allowing customized model organization and storage location when downloading models via LM Civitai Extension.
* **LM Civitai Extension Enhancements** - Improved concurrent download performance and stability, with new support for canceling active downloads directly from the extension interface.
* **Update Feature** - Added update functionality, allowing users to update LoRA Manager to the latest release version directly from the LoRA Manager UI.
* **Bulk Operations: Refresh All** - Added bulk refresh functionality, allowing users to update Civitai metadata across multiple LoRAs.
### v0.8.20 ### v0.8.20
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support * **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding * **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding

View File

@@ -59,6 +59,9 @@ class Config:
if self.checkpoints_roots and len(self.checkpoints_roots) == 1 and "default_checkpoint_root" not in settings: if self.checkpoints_roots and len(self.checkpoints_roots) == 1 and "default_checkpoint_root" not in settings:
settings["default_checkpoint_root"] = self.checkpoints_roots[0] settings["default_checkpoint_root"] = self.checkpoints_roots[0]
if self.embeddings_roots and len(self.embeddings_roots) == 1 and "default_embedding_root" not in settings:
settings["default_embedding_root"] = self.embeddings_roots[0]
# Save settings # Save settings
with open(settings_path, 'w', encoding='utf-8') as f: with open(settings_path, 'w', encoding='utf-8') as f:

View File

@@ -146,52 +146,40 @@ class MetadataHook:
# Store the original _async_map_node_over_list function # Store the original _async_map_node_over_list function
original_map_node_over_list = getattr(execution, map_node_func_name) original_map_node_over_list = getattr(execution, map_node_func_name)
# Define the wrapped async function - NOTE: Updated signature with prompt_id and unique_id! # Wrapped async function, compatible with both stable and nightly
async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None): async def async_map_node_over_list_with_metadata(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt=False, execution_block_cb=None, pre_execute_cb=None, *args, **kwargs):
hidden_inputs = kwargs.get('hidden_inputs', None)
# Only collect metadata when calling the main function of nodes # Only collect metadata when calling the main function of nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):
try: try:
# Get the current prompt_id from the registry
registry = MetadataRegistry() registry = MetadataRegistry()
# We now have prompt_id directly from the function parameters
if prompt_id is not None: if prompt_id is not None:
# Get node class type
class_type = obj.__class__.__name__ class_type = obj.__class__.__name__
# Use the passed unique_id parameter instead of trying to extract it
node_id = unique_id node_id = unique_id
# Record inputs before execution
if node_id is not None: if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None) registry.record_node_execution(node_id, class_type, input_data_all, None)
except Exception as e: except Exception as e:
print(f"Error collecting metadata (pre-execution): {str(e)}") print(f"Error collecting metadata (pre-execution): {str(e)}")
# Execute the original async function with ALL parameters in the correct order # Call original function with all args/kwargs
results = await original_map_node_over_list(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb) results = await original_map_node_over_list(
prompt_id, unique_id, obj, input_data_all, func,
allow_interrupt, execution_block_cb, pre_execute_cb, *args, **kwargs
)
# After execution, collect outputs for relevant nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'): if func == obj.FUNCTION and hasattr(obj, '__class__'):
try: try:
# Get the current prompt_id from the registry
registry = MetadataRegistry() registry = MetadataRegistry()
if prompt_id is not None: if prompt_id is not None:
# Get node class type
class_type = obj.__class__.__name__ class_type = obj.__class__.__name__
# Use the passed unique_id parameter
node_id = unique_id node_id = unique_id
# Record outputs after execution
if node_id is not None: if node_id is not None:
registry.update_node_execution(node_id, class_type, results) registry.update_node_execution(node_id, class_type, results)
except Exception as e: except Exception as e:
print(f"Error collecting metadata (post-execution): {str(e)}") print(f"Error collecting metadata (post-execution): {str(e)}")
return results return results
# Also hook the execute function to track the current prompt_id # Also hook the execute function to track the current prompt_id
original_execute = execution.execute original_execute = execution.execute

View File

@@ -119,10 +119,10 @@ class RecipeMetadataParser(ABC):
# Check if exists locally # Check if exists locally
if recipe_scanner and lora_entry['hash']: if recipe_scanner and lora_entry['hash']:
lora_scanner = recipe_scanner._lora_scanner lora_scanner = recipe_scanner._lora_scanner
exists_locally = lora_scanner.has_lora_hash(lora_entry['hash']) exists_locally = lora_scanner.has_hash(lora_entry['hash'])
if exists_locally: if exists_locally:
try: try:
local_path = lora_scanner.get_lora_path_by_hash(lora_entry['hash']) local_path = lora_scanner.get_path_by_hash(lora_entry['hash'])
lora_entry['existsLocally'] = True lora_entry['existsLocally'] = True
lora_entry['localPath'] = local_path lora_entry['localPath'] = local_path
lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0] lora_entry['file_name'] = os.path.splitext(os.path.basename(local_path))[0]

View File

@@ -181,13 +181,30 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# First use Civitai resources if available (more reliable source) # First use Civitai resources if available (more reliable source)
if metadata.get("civitai_resources"): if metadata.get("civitai_resources"):
for resource in metadata.get("civitai_resources", []): for resource in metadata.get("civitai_resources", []):
# --- Added: Parse 'air' field if present ---
air = resource.get("air")
if air:
# Format: urn:air:sdxl:lora:civitai:1221007@1375651
# Or: urn:air:sdxl:checkpoint:civitai:623891@2019115
air_pattern = r"urn:air:[^:]+:(?P<type>[^:]+):civitai:(?P<modelId>\d+)@(?P<modelVersionId>\d+)"
air_match = re.match(air_pattern, air)
if air_match:
air_type = air_match.group("type")
air_modelId = int(air_match.group("modelId"))
air_modelVersionId = int(air_match.group("modelVersionId"))
# checkpoint/lycoris/lora/hypernet
resource["type"] = air_type
resource["modelId"] = air_modelId
resource["modelVersionId"] = air_modelVersionId
# --- End added ---
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"): if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
# Initialize lora entry # Initialize lora entry
lora_entry = { lora_entry = {
'id': resource.get("modelVersionId", 0), 'id': resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0), 'modelId': resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown LoRA"), 'name': resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""), 'version': resource.get("modelVersionName", resource.get("versionName", "")),
'type': resource.get("type", "lora"), 'type': resource.get("type", "lora"),
'weight': round(float(resource.get("weight", 1.0)), 2), 'weight': round(float(resource.get("weight", 1.0)), 2),
'existsLocally': False, 'existsLocally': False,

View File

@@ -55,7 +55,7 @@ class RecipeFormatParser(RecipeMetadataParser):
# Check if this LoRA exists locally by SHA256 hash # Check if this LoRA exists locally by SHA256 hash
if lora.get('hash') and recipe_scanner: if lora.get('hash') and recipe_scanner:
lora_scanner = recipe_scanner._lora_scanner lora_scanner = recipe_scanner._lora_scanner
exists_locally = lora_scanner.has_lora_hash(lora['hash']) exists_locally = lora_scanner.has_hash(lora['hash'])
if exists_locally: if exists_locally:
lora_cache = await lora_scanner.get_cached_data() lora_cache = await lora_scanner.get_cached_data()
lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None) lora_item = next((item for item in lora_cache.raw_data if item['sha256'].lower() == lora['hash'].lower()), None)

View File

@@ -408,7 +408,7 @@ class BaseModelRoutes(ABC):
group["models"].append(await self.service.format_response(model)) group["models"].append(await self.service.format_response(model))
# Find the model from the main index too # Find the model from the main index too
hash_val = self.service.scanner._hash_index.get_hash_by_filename(filename) hash_val = self.service.scanner.get_hash_by_filename(filename)
if hash_val: if hash_val:
main_path = self.service.get_path_by_hash(hash_val) main_path = self.service.get_path_by_hash(hash_val)
if main_path and main_path not in paths: if main_path and main_path not in paths:

View File

@@ -167,6 +167,9 @@ class MiscRoutes:
# Validate and update settings # Validate and update settings
for key, value in data.items(): for key, value in data.items():
if value == settings.get(key):
# No change, skip
continue
# Special handling for example_images_path - verify path exists # Special handling for example_images_path - verify path exists
if key == 'example_images_path' and value: if key == 'example_images_path' and value:
if not os.path.exists(value): if not os.path.exists(value):

View File

@@ -20,6 +20,7 @@ class StatsRoutes:
def __init__(self): def __init__(self):
self.lora_scanner = None self.lora_scanner = None
self.checkpoint_scanner = None self.checkpoint_scanner = None
self.embedding_scanner = None
self.usage_stats = None self.usage_stats = None
self.template_env = jinja2.Environment( self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path), loader=jinja2.FileSystemLoader(config.templates_path),
@@ -30,6 +31,7 @@ class StatsRoutes:
"""Initialize services from ServiceRegistry""" """Initialize services from ServiceRegistry"""
self.lora_scanner = await ServiceRegistry.get_lora_scanner() self.lora_scanner = await ServiceRegistry.get_lora_scanner()
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
self.embedding_scanner = await ServiceRegistry.get_embedding_scanner()
self.usage_stats = UsageStats() self.usage_stats = UsageStats()
async def handle_stats_page(self, request: web.Request) -> web.Response: async def handle_stats_page(self, request: web.Request) -> web.Response:
@@ -49,7 +51,12 @@ class StatsRoutes:
(hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing) (hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing)
) )
is_initializing = lora_initializing or checkpoint_initializing embedding_initializing = (
self.embedding_scanner._cache is None or
(hasattr(self.embedding_scanner, 'is_initializing') and self.embedding_scanner.is_initializing())
)
is_initializing = lora_initializing or checkpoint_initializing or embedding_initializing
template = self.template_env.get_template('statistics.html') template = self.template_env.get_template('statistics.html')
rendered = template.render( rendered = template.render(
@@ -85,21 +92,29 @@ class StatsRoutes:
checkpoint_count = len(checkpoint_cache.raw_data) checkpoint_count = len(checkpoint_cache.raw_data)
checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data) checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
# Get Embedding statistics
embedding_cache = await self.embedding_scanner.get_cached_data()
embedding_count = len(embedding_cache.raw_data)
embedding_size = sum(emb.get('size', 0) for emb in embedding_cache.raw_data)
# Get usage statistics # Get usage statistics
usage_data = await self.usage_stats.get_stats() usage_data = await self.usage_stats.get_stats()
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
'total_models': lora_count + checkpoint_count, 'total_models': lora_count + checkpoint_count + embedding_count,
'lora_count': lora_count, 'lora_count': lora_count,
'checkpoint_count': checkpoint_count, 'checkpoint_count': checkpoint_count,
'total_size': lora_size + checkpoint_size, 'embedding_count': embedding_count,
'total_size': lora_size + checkpoint_size + embedding_size,
'lora_size': lora_size, 'lora_size': lora_size,
'checkpoint_size': checkpoint_size, 'checkpoint_size': checkpoint_size,
'embedding_size': embedding_size,
'total_generations': usage_data.get('total_executions', 0), 'total_generations': usage_data.get('total_executions', 0),
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})), 'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})) 'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})),
'unused_embeddings': self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
} }
}) })
@@ -121,14 +136,17 @@ class StatsRoutes:
# Get model data for enrichment # Get model data for enrichment
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Create hash to model mapping # Create hash to model mapping
lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data} lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data}
checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data} checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data}
embedding_map = {emb['sha256']: emb for emb in embedding_cache.raw_data}
# Prepare top used models # Prepare top used models
top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10) top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10)
top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10) top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10)
top_embeddings = self._get_top_used_models(usage_data.get('embeddings', {}), embedding_map, 10)
# Prepare usage timeline (last 30 days) # Prepare usage timeline (last 30 days)
timeline = self._get_usage_timeline(usage_data, 30) timeline = self._get_usage_timeline(usage_data, 30)
@@ -138,6 +156,7 @@ class StatsRoutes:
'data': { 'data': {
'top_loras': top_loras, 'top_loras': top_loras,
'top_checkpoints': top_checkpoints, 'top_checkpoints': top_checkpoints,
'top_embeddings': top_embeddings,
'usage_timeline': timeline, 'usage_timeline': timeline,
'total_executions': usage_data.get('total_executions', 0) 'total_executions': usage_data.get('total_executions', 0)
} }
@@ -158,16 +177,19 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Count by base model # Count by base model
lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data) lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data)
checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data) checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data)
embedding_base_models = Counter(emb.get('base_model', 'Unknown') for emb in embedding_cache.raw_data)
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
'loras': dict(lora_base_models), 'loras': dict(lora_base_models),
'checkpoints': dict(checkpoint_base_models) 'checkpoints': dict(checkpoint_base_models),
'embeddings': dict(embedding_base_models)
} }
}) })
@@ -186,6 +208,7 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Count tag frequencies # Count tag frequencies
all_tags = [] all_tags = []
@@ -193,6 +216,8 @@ class StatsRoutes:
all_tags.extend(lora.get('tags', [])) all_tags.extend(lora.get('tags', []))
for cp in checkpoint_cache.raw_data: for cp in checkpoint_cache.raw_data:
all_tags.extend(cp.get('tags', [])) all_tags.extend(cp.get('tags', []))
for emb in embedding_cache.raw_data:
all_tags.extend(emb.get('tags', []))
tag_counts = Counter(all_tags) tag_counts = Counter(all_tags)
@@ -225,6 +250,7 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
# Create models with usage data # Create models with usage data
lora_storage = [] lora_storage = []
@@ -255,15 +281,31 @@ class StatsRoutes:
'base_model': cp.get('base_model', 'Unknown') 'base_model': cp.get('base_model', 'Unknown')
}) })
embedding_storage = []
for emb in embedding_cache.raw_data:
usage_count = 0
if emb['sha256'] in usage_data.get('embeddings', {}):
usage_count = usage_data['embeddings'][emb['sha256']].get('total', 0)
embedding_storage.append({
'name': emb['model_name'],
'size': emb.get('size', 0),
'usage_count': usage_count,
'folder': emb.get('folder', ''),
'base_model': emb.get('base_model', 'Unknown')
})
# Sort by size # Sort by size
lora_storage.sort(key=lambda x: x['size'], reverse=True) lora_storage.sort(key=lambda x: x['size'], reverse=True)
checkpoint_storage.sort(key=lambda x: x['size'], reverse=True) checkpoint_storage.sort(key=lambda x: x['size'], reverse=True)
embedding_storage.sort(key=lambda x: x['size'], reverse=True)
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'data': { 'data': {
'loras': lora_storage[:20], # Top 20 by size 'loras': lora_storage[:20], # Top 20 by size
'checkpoints': checkpoint_storage[:20] 'checkpoints': checkpoint_storage[:20],
'embeddings': embedding_storage[:20]
} }
}) })
@@ -285,15 +327,18 @@ class StatsRoutes:
# Get model data # Get model data
lora_cache = await self.lora_scanner.get_cached_data() lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data() checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
insights = [] insights = []
# Calculate unused models # Calculate unused models
unused_loras = self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})) unused_loras = self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {}))
unused_checkpoints = self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {})) unused_checkpoints = self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
unused_embeddings = self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
total_loras = len(lora_cache.raw_data) total_loras = len(lora_cache.raw_data)
total_checkpoints = len(checkpoint_cache.raw_data) total_checkpoints = len(checkpoint_cache.raw_data)
total_embeddings = len(embedding_cache.raw_data)
if total_loras > 0: if total_loras > 0:
unused_lora_percent = (unused_loras / total_loras) * 100 unused_lora_percent = (unused_loras / total_loras) * 100
@@ -315,9 +360,20 @@ class StatsRoutes:
'suggestion': 'Review and consider removing checkpoints you no longer need.' 'suggestion': 'Review and consider removing checkpoints you no longer need.'
}) })
if total_embeddings > 0:
unused_embedding_percent = (unused_embeddings / total_embeddings) * 100
if unused_embedding_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused Embeddings',
'description': f'{unused_embedding_percent:.1f}% of your embeddings ({unused_embeddings}/{total_embeddings}) have never been used.',
'suggestion': 'Consider organizing or archiving unused embeddings to optimize your collection.'
})
# Storage insights # Storage insights
total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \ total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \
sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data) sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data) + \
sum(emb.get('size', 0) for emb in embedding_cache.raw_data)
if total_size > 100 * 1024 * 1024 * 1024: # 100GB if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({ insights.append({
@@ -390,6 +446,7 @@ class StatsRoutes:
lora_usage = 0 lora_usage = 0
checkpoint_usage = 0 checkpoint_usage = 0
embedding_usage = 0
# Count usage for this date # Count usage for this date
for model_usage in usage_data.get('loras', {}).values(): for model_usage in usage_data.get('loras', {}).values():
@@ -400,11 +457,16 @@ class StatsRoutes:
if isinstance(model_usage, dict) and 'history' in model_usage: if isinstance(model_usage, dict) and 'history' in model_usage:
checkpoint_usage += model_usage['history'].get(date_str, 0) checkpoint_usage += model_usage['history'].get(date_str, 0)
for model_usage in usage_data.get('embeddings', {}).values():
if isinstance(model_usage, dict) and 'history' in model_usage:
embedding_usage += model_usage['history'].get(date_str, 0)
timeline.append({ timeline.append({
'date': date_str, 'date': date_str,
'lora_usage': lora_usage, 'lora_usage': lora_usage,
'checkpoint_usage': checkpoint_usage, 'checkpoint_usage': checkpoint_usage,
'total_usage': lora_usage + checkpoint_usage 'embedding_usage': embedding_usage,
'total_usage': lora_usage + checkpoint_usage + embedding_usage
}) })
return list(reversed(timeline)) # Oldest to newest return list(reversed(timeline)) # Oldest to newest

View File

@@ -4,6 +4,9 @@ import aiohttp
import logging import logging
import toml import toml
import git import git
import zipfile
import shutil
import tempfile
from datetime import datetime from datetime import datetime
from aiohttp import web from aiohttp import web
from typing import Dict, List from typing import Dict, List
@@ -101,34 +104,36 @@ class UpdateRoutes:
@staticmethod @staticmethod
async def perform_update(request): async def perform_update(request):
""" """
Perform Git-based update to latest release tag or main branch Perform Git-based update to latest release tag or main branch.
If .git is missing, fallback to ZIP download.
""" """
try: try:
# Parse request body
body = await request.json() if request.has_body else {} body = await request.json() if request.has_body else {}
nightly = body.get('nightly', False) nightly = body.get('nightly', False)
# Get current plugin directory
current_dir = os.path.dirname(os.path.abspath(__file__)) current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir)) plugin_root = os.path.dirname(os.path.dirname(current_dir))
# Backup settings.json if it exists
settings_path = os.path.join(plugin_root, 'settings.json') settings_path = os.path.join(plugin_root, 'settings.json')
settings_backup = None settings_backup = None
if os.path.exists(settings_path): if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f: with open(settings_path, 'r', encoding='utf-8') as f:
settings_backup = f.read() settings_backup = f.read()
logger.info("Backed up settings.json") logger.info("Backed up settings.json")
# Perform Git update git_folder = os.path.join(plugin_root, '.git')
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly) if os.path.exists(git_folder):
# Git update
# Restore settings.json if we backed it up success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
else:
# Fallback: Download ZIP and replace files
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
if settings_backup and success: if settings_backup and success:
with open(settings_path, 'w', encoding='utf-8') as f: with open(settings_path, 'w', encoding='utf-8') as f:
f.write(settings_backup) f.write(settings_backup)
logger.info("Restored settings.json") logger.info("Restored settings.json")
if success: if success:
return web.json_response({ return web.json_response({
'success': True, 'success': True,
@@ -138,15 +143,86 @@ class UpdateRoutes:
else: else:
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': 'Failed to complete Git update' 'error': 'Failed to complete update'
}) })
except Exception as e: except Exception as e:
logger.error(f"Failed to perform update: {e}", exc_info=True) logger.error(f"Failed to perform update: {e}", exc_info=True)
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}) })
@staticmethod
async def _download_and_replace_zip(plugin_root: str) -> tuple[bool, str]:
"""
Download latest release ZIP from GitHub and replace plugin files.
Skips settings.json.
"""
repo_owner = "willmiao"
repo_name = "ComfyUI-Lora-Manager"
github_api = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
try:
async with aiohttp.ClientSession() as session:
async with session.get(github_api) as resp:
if resp.status != 200:
logger.error(f"Failed to fetch release info: {resp.status}")
return False, ""
data = await resp.json()
zip_url = data.get("zipball_url")
version = data.get("tag_name", "unknown")
# Download ZIP
async with session.get(zip_url) as zip_resp:
if zip_resp.status != 200:
logger.error(f"Failed to download ZIP: {zip_resp.status}")
return False, ""
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp_zip:
tmp_zip.write(await zip_resp.read())
zip_path = tmp_zip.name
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json'])
# Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(tmp_dir)
# Find extracted folder (GitHub ZIP contains a root folder)
extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping settings.json
for item in os.listdir(extracted_root):
src = os.path.join(extracted_root, item)
dst = os.path.join(plugin_root, item)
if os.path.isdir(src):
# Remove old folder, then copy
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json'))
else:
if item == 'settings.json':
continue
shutil.copy2(src, dst)
os.remove(zip_path)
logger.info(f"Updated plugin via ZIP to {version}")
return True, version
except Exception as e:
logger.error(f"ZIP update failed: {e}", exc_info=True)
return False, ""
def _clean_plugin_folder(plugin_root, skip_files=None):
skip_files = skip_files or []
for item in os.listdir(plugin_root):
if item in skip_files:
continue
path = os.path.join(plugin_root, item)
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
@staticmethod @staticmethod
async def _get_nightly_version() -> tuple[str, List[str]]: async def _get_nightly_version() -> tuple[str, List[str]]:
@@ -291,7 +367,7 @@ class UpdateRoutes:
git_info = { git_info = {
'commit_hash': 'unknown', 'commit_hash': 'unknown',
'short_hash': 'unknown', 'short_hash': 'stable',
'branch': 'unknown', 'branch': 'unknown',
'commit_date': 'unknown' 'commit_date': 'unknown'
} }

View File

@@ -9,6 +9,7 @@ class SettingsManager:
def __init__(self): def __init__(self):
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json') self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
self.settings = self._load_settings() self.settings = self._load_settings()
self._auto_set_default_roots()
self._check_environment_variables() self._check_environment_variables()
def _load_settings(self) -> Dict[str, Any]: def _load_settings(self) -> Dict[str, Any]:
@@ -21,6 +22,28 @@ class SettingsManager:
logger.error(f"Error loading settings: {e}") logger.error(f"Error loading settings: {e}")
return self._get_default_settings() return self._get_default_settings()
def _auto_set_default_roots(self):
"""Auto set default root paths if only one folder is present and default is empty."""
folder_paths = self.settings.get('folder_paths', {})
updated = False
# loras
loras = folder_paths.get('loras', [])
if isinstance(loras, list) and len(loras) == 1 and not self.settings.get('default_lora_root'):
self.settings['default_lora_root'] = loras[0]
updated = True
# checkpoints
checkpoints = folder_paths.get('checkpoints', [])
if isinstance(checkpoints, list) and len(checkpoints) == 1 and not self.settings.get('default_checkpoint_root'):
self.settings['default_checkpoint_root'] = checkpoints[0]
updated = True
# embeddings
embeddings = folder_paths.get('embeddings', [])
if isinstance(embeddings, list) and len(embeddings) == 1 and not self.settings.get('default_embedding_root'):
self.settings['default_embedding_root'] = embeddings[0]
updated = True
if updated:
self._save_settings()
def _check_environment_variables(self) -> None: def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed""" """Check for environment variables and update settings if needed"""
env_api_key = os.environ.get('CIVITAI_API_KEY') env_api_key = os.environ.get('CIVITAI_API_KEY')

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.8.21" version = "0.8.24"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -0,0 +1,245 @@
/* Banner Container */
.banner-container {
position: relative;
width: 100%;
z-index: calc(var(--z-header) - 1);
border-bottom: 1px solid var(--border-color);
background: var(--card-bg);
margin-bottom: var(--space-2);
}
/* Individual Banner */
.banner-item {
position: relative;
padding: var(--space-2) var(--space-3);
background: linear-gradient(135deg,
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.05),
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.02)
);
border-left: 4px solid var(--lora-accent);
animation: banner-slide-down 0.3s ease-in-out;
}
/* Banner Content Layout */
.banner-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
max-width: 1400px;
margin: 0 auto;
}
/* Banner Text Section */
.banner-text {
flex: 1;
min-width: 0;
}
.banner-title {
margin: 0 0 4px 0;
font-size: 1.1em;
font-weight: 600;
color: var(--text-color);
line-height: 1.3;
}
.banner-description {
margin: 0;
font-size: 0.9em;
color: var(--text-muted);
line-height: 1.4;
}
/* Banner Actions */
.banner-actions {
display: flex;
align-items: center;
gap: var(--space-1);
flex-shrink: 0;
}
.banner-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: var(--border-radius-xs);
text-decoration: none;
font-size: 0.85em;
font-weight: 500;
transition: all 0.2s ease;
white-space: nowrap;
border: 1px solid transparent;
}
.banner-action i {
font-size: 0.9em;
}
/* Primary Action Button */
.banner-action-primary {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
.banner-action-primary:hover {
background: oklch(calc(var(--lora-accent-l) - 5%) var(--lora-accent-c) var(--lora-accent-h));
transform: translateY(-1px);
box-shadow: 0 3px 6px oklch(var(--lora-accent) / 0.3);
}
/* Secondary Action Button */
.banner-action-secondary {
background: var(--card-bg);
color: var(--text-color);
border-color: var(--border-color);
}
.banner-action-secondary:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Tertiary Action Button */
.banner-action-tertiary {
background: transparent;
color: var(--lora-accent);
border-color: var(--lora-accent);
}
.banner-action-tertiary:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-1px);
}
/* Dismiss Button */
.banner-dismiss {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
font-size: 0.8em;
}
.banner-dismiss:hover {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
transform: scale(1.1);
}
/* Animations */
@keyframes banner-slide-down {
from {
opacity: 0;
transform: translateY(-100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes banner-slide-up {
from {
opacity: 1;
transform: translateY(0);
max-height: 200px;
}
to {
opacity: 0;
transform: translateY(-20px);
max-height: 0;
padding-top: 0;
padding-bottom: 0;
}
}
/* Responsive Design */
@media (max-width: 768px) {
.banner-content {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
.banner-actions {
width: 100%;
flex-wrap: wrap;
justify-content: flex-start;
}
.banner-action {
flex: 1;
min-width: 0;
justify-content: center;
}
.banner-dismiss {
top: 6px;
right: 6px;
}
.banner-item {
padding: var(--space-2);
}
.banner-title {
font-size: 1em;
}
.banner-description {
font-size: 0.85em;
}
}
@media (max-width: 480px) {
.banner-actions {
flex-direction: column;
width: 100%;
}
.banner-action {
width: 100%;
justify-content: center;
}
.banner-content {
gap: var(--space-1);
}
}
/* Dark theme adjustments */
[data-theme="dark"] .banner-item {
background: linear-gradient(135deg,
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.03)
);
}
/* Prevent text selection */
.banner-item,
.banner-title,
.banner-description,
.banner-action,
.banner-dismiss {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}

View File

@@ -424,6 +424,33 @@
font-size: 0.85em; font-size: 0.85em;
} }
/* Style for version name */
.version-name {
display: inline-block;
color: rgba(255,255,255,0.8); /* Muted white */
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
font-size: 0.85em;
word-break: break-word;
overflow: hidden;
line-height: 1.4;
margin-top: 2px;
opacity: 0.8; /* Slightly transparent for better readability */
border: 1px solid rgba(255,255,255,0.25); /* Subtle border */
border-radius: var(--border-radius-xs);
padding: 1px 6px;
background: rgba(0,0,0,0.18); /* Optional: subtle background for contrast */
}
/* Medium density adjustments for version name */
.medium-density .version-name {
font-size: 0.8em;
}
/* Compact density adjustments for version name */
.compact-density .version-name {
font-size: 0.75em;
}
/* Prevent text selection on cards and interactive elements */ /* Prevent text selection on cards and interactive elements */
.model-card, .model-card,
.model-card *, .model-card *,

View File

@@ -123,6 +123,42 @@
} }
} }
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
.modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */
float: right; /* 使用 float 确保按钮在右侧 */
bottom: 20px; /* 距离底部的距离 */
margin-right: 20px; /* 右侧间距 */
margin-top: -56px; /* 负边距确保不占用额外空间 */
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
}
.modal-content .back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.modal-content .back-to-top:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* File name copy styles */ /* File name copy styles */
.file-name-wrapper { .file-name-wrapper {
display: flex; display: flex;
@@ -147,7 +183,11 @@
outline: none; outline: none;
} }
.edit-file-name-btn { /* 合并编辑按钮样式 */
.edit-model-name-btn,
.edit-file-name-btn,
.edit-base-model-btn,
.edit-model-description-btn {
background: transparent; background: transparent;
border: none; border: none;
color: var(--text-color); color: var(--text-color);
@@ -159,17 +199,28 @@
margin-left: var(--space-1); margin-left: var(--space-1);
} }
.edit-model-name-btn.visible,
.edit-file-name-btn.visible, .edit-file-name-btn.visible,
.file-name-wrapper:hover .edit-file-name-btn { .edit-base-model-btn.visible,
.edit-model-description-btn.visible,
.model-name-header:hover .edit-model-name-btn,
.file-name-wrapper:hover .edit-file-name-btn,
.base-model-display:hover .edit-base-model-btn,
.model-name-header:hover .edit-model-description-btn {
opacity: 0.5; opacity: 0.5;
} }
.edit-file-name-btn:hover { .edit-model-name-btn:hover,
.edit-file-name-btn:hover,
.edit-base-model-btn:hover,
.edit-model-description-btn:hover {
opacity: 0.8 !important; opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
} }
[data-theme="dark"] .edit-file-name-btn:hover { [data-theme="dark"] .edit-model-name-btn:hover,
[data-theme="dark"] .edit-file-name-btn:hover,
[data-theme="dark"] .edit-base-model-btn:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
} }
@@ -198,32 +249,6 @@
flex: 1; flex: 1;
} }
.edit-base-model-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-base-model-btn.visible,
.base-model-display:hover .edit-base-model-btn {
opacity: 0.5;
}
.edit-base-model-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-base-model-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.base-model-selector { .base-model-selector {
width: 100%; width: 100%;
padding: 3px 5px; padding: 3px 5px;
@@ -280,32 +305,6 @@
background: var(--bg-color); background: var(--bg-color);
} }
.edit-model-name-btn {
background: transparent;
border: none;
color: var(--text-color);
opacity: 0;
cursor: pointer;
padding: 2px 5px;
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
margin-left: var(--space-1);
}
.edit-model-name-btn.visible,
.model-name-header:hover .edit-model-name-btn {
opacity: 0.5;
}
.edit-model-name-btn:hover {
opacity: 0.8 !important;
background: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .edit-model-name-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Tab System Styling */ /* Tab System Styling */
.showcase-tabs { .showcase-tabs {
display: flex; display: flex;

View File

@@ -4,254 +4,31 @@
margin-top: var(--space-4); margin-top: var(--space-4);
} }
/* Main showcase container */ .carousel {
.showcase-container { transition: max-height 0.3s ease-in-out;
display: flex;
height: 750px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
overflow: hidden; overflow: hidden;
background: var(--lora-surface);
} }
.showcase-container.empty { .carousel.collapsed {
height: 400px; max-height: 0;
} }
/* Thumbnail Sidebar */ .carousel-container {
.thumbnail-sidebar {
width: 200px;
background: var(--bg-color);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.thumbnail-grid {
flex: 1;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
padding: var(--space-2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
} }
.thumbnail-grid::-webkit-scrollbar {
display: none; /* WebKit */
}
.thumbnail-item {
position: relative;
aspect-ratio: 1;
border-radius: var(--border-radius-xs);
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
background: var(--lora-surface);
}
.thumbnail-item:hover {
border-color: var(--lora-accent);
transform: scale(1.02);
}
.thumbnail-item.active {
border-color: var(--lora-accent);
box-shadow: 0 0 0 1px var(--lora-accent);
}
.thumbnail-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumbnail-media.blurred {
filter: blur(8px);
}
.video-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7em;
pointer-events: none;
}
.thumbnail-nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2em;
}
/* Import Section */
.import-section {
padding: var(--space-2);
border-top: 1px solid var(--border-color);
background: var(--bg-color);
}
.select-files-btn {
width: 100%;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
padding: var(--space-2);
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
margin-bottom: var(--space-2);
}
.select-files-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.import-drop-zone {
border: 2px dashed var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-2);
text-align: center;
transition: all 0.3s ease;
background: var(--lora-surface);
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.import-drop-zone.highlight {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--text-color);
opacity: 0.6;
font-size: 0.8em;
}
.drop-zone-content i {
font-size: 1.2em;
margin-bottom: 2px;
}
/* Main Display Area */
.main-display-area {
flex: 1;
position: relative;
background: var(--card-bg);
overflow: hidden;
}
.main-display-area.empty {
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
color: var(--text-color);
opacity: 0.6;
}
.empty-state i {
font-size: 3em;
margin-bottom: var(--space-2);
opacity: 0.5;
}
.empty-state h3 {
margin: 0 0 var(--space-1);
font-weight: 500;
}
.empty-state p {
margin: 0;
font-size: 0.9em;
}
.navigation-controls {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: 6px;
z-index: 10;
}
.nav-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
opacity: 0.8;
}
.nav-btn:hover {
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.15);
}
.nav-btn.info-btn.active {
background: var(--lora-accent);
color: var(--lora-text);
border-color: var(--lora-accent);
}
.main-media-container {
position: relative;
width: 100%;
height: 100%;
}
.media-wrapper { .media-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%;
background: var(--lora-surface); background: var(--lora-surface);
overflow: hidden; margin-bottom: var(--space-2);
overflow: hidden; /* Ensure metadata panel is contained */
}
.media-wrapper:last-child {
margin-bottom: 0;
} }
.media-wrapper img, .media-wrapper img,
@@ -264,11 +41,50 @@
object-fit: contain; object-fit: contain;
} }
/* Media Controls for main display */ .no-examples {
text-align: center;
padding: var(--space-3);
color: var(--text-color);
opacity: 0.7;
}
/* Adjust the media wrapper for tab system */
#showcase-tab .carousel-container {
margin-top: var(--space-2);
}
/* Add styles for blurred showcase content */
.nsfw-media-wrapper {
position: relative;
}
.media-wrapper img.blurred,
.media-wrapper video.blurred {
filter: blur(25px);
}
.media-wrapper .nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
/* Position the toggle button at the top left of showcase media */
.showcase-toggle-btn {
position: absolute;
z-index: 3;
}
/* Add styles for showcase media controls */
.media-controls { .media-controls {
position: absolute; position: absolute;
top: var(--space-2);
left: var(--space-2);
display: flex; display: flex;
gap: 6px; gap: 6px;
z-index: 4; z-index: 4;
@@ -278,15 +94,15 @@
pointer-events: none; pointer-events: none;
} }
.media-wrapper:hover .media-controls { .media-controls.visible {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
pointer-events: auto; pointer-events: auto;
} }
.media-control-btn { .media-control-btn {
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -319,11 +135,13 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
/* Disabled state for delete button */
.media-control-btn.example-delete-btn.disabled { .media-control-btn.example-delete-btn.disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Two-step confirmation for delete button */
.media-control-btn.example-delete-btn .confirm-icon { .media-control-btn.example-delete-btn .confirm-icon {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -354,29 +172,16 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
/* Toggle blur button for main display */ @keyframes pulse {
.showcase-toggle-btn { 0% {
position: absolute; box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7);
top: calc(var(--space-2) + 44px); }
left: var(--space-2); 70% {
z-index: 3; box-shadow: 0 0 0 5px rgba(220, 53, 69, 0);
width: 32px; }
height: 32px; 100% {
border-radius: 50%; box-shadow: 0 0 0 0 rgba(220, 53, 69, 0);
background: var(--bg-color); }
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
opacity: 0;
}
.media-wrapper:hover .showcase-toggle-btn {
opacity: 1;
} }
/* Image Metadata Panel Styles */ /* Image Metadata Panel Styles */
@@ -390,20 +195,22 @@
padding: var(--space-2); padding: var(--space-2);
transform: translateY(100%); transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease; transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
z-index: 15; z-index: 5;
max-height: 50%; max-height: 50%; /* Reduced to take less space */
overflow-y: auto; overflow-y: auto;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
.image-metadata-panel.visible { /* Show metadata panel only when the 'visible' class is added */
.media-wrapper .image-metadata-panel.visible {
transform: translateY(0); transform: translateY(0);
opacity: 0.98; opacity: 0.98;
pointer-events: auto; pointer-events: auto;
} }
/* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel { [data-theme="dark"] .image-metadata-panel {
background: var(--card-bg); background: var(--card-bg);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
@@ -415,6 +222,7 @@
gap: 10px; gap: 10px;
} }
/* Styling for parameters tags */
.params-tags { .params-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -447,6 +255,7 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
/* Special styling for prompt row */
.metadata-row.prompt-row { .metadata-row.prompt-row {
flex-direction: column; flex-direction: column;
padding-top: 0; padding-top: 0;
@@ -472,7 +281,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 6px 30px 6px 8px; padding: 6px 30px 6px 8px;
margin-top: 2px; margin-top: 2px;
max-height: 80px; max-height: 80px; /* Reduced from 120px */
overflow-y: auto; overflow-y: auto;
word-break: break-word; word-break: break-word;
width: 100%; width: 100%;
@@ -504,6 +313,27 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
/* Scrollbar styling for metadata panel */
.image-metadata-panel::-webkit-scrollbar {
width: 6px;
}
.image-metadata-panel::-webkit-scrollbar-track {
background: transparent;
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* For Firefox */
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* No metadata message styling */
.no-metadata-message { .no-metadata-message {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -522,66 +352,31 @@
opacity: 0.8; opacity: 0.8;
} }
/* Scrollbar styling for metadata panel */ /* Scroll Indicator */
.image-metadata-panel::-webkit-scrollbar { .scroll-indicator {
width: 6px; cursor: pointer;
} padding: var(--space-2);
background: var(--lora-surface);
.image-metadata-panel::-webkit-scrollbar-track { border: 1px solid var(--lora-border);
background: transparent; border-radius: var(--border-radius-sm);
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* NSFW Content Styles */
.media-wrapper img.blurred,
.media-wrapper video.blurred {
filter: blur(25px);
}
.media-wrapper .nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 2;
pointer-events: none;
}
/* NSFW Filter Notification */
.nsfw-filter-notification {
background: var(--lora-warning);
color: var(--lora-text);
padding: var(--space-2);
border-radius: var(--border-radius-xs);
margin-bottom: var(--space-2);
display: flex;
align-items: center;
gap: 8px; gap: 8px;
margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s;
}
.scroll-indicator:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em; font-size: 0.9em;
}
/* No examples message */
.no-examples {
text-align: center;
padding: var(--space-4);
color: var(--text-color); color: var(--text-color);
opacity: 0.7;
} }
/* Lazy loading */
.lazy { .lazy {
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
@@ -591,24 +386,93 @@
opacity: 1; opacity: 1;
} }
/* For dark theme */ /* Example Import Area */
[data-theme="dark"] .import-drop-zone { .example-import-area {
background: rgba(255, 255, 255, 0.03); margin-top: var(--space-4);
padding: var(--space-2);
} }
/* Responsive design for smaller screens */ .example-import-area.empty {
@media (max-width: 768px) { margin-top: var(--space-2);
.thumbnail-sidebar { padding: var(--space-4) var(--space-2);
width: 160px; }
}
.import-container {
.navigation-controls { border: 2px dashed var(--border-color);
top: var(--space-1); border-radius: var(--border-radius-sm);
right: var(--space-1); padding: var(--space-4);
} text-align: center;
transition: all 0.3s ease;
.nav-btn { background: var(--lora-surface);
width: 32px; cursor: pointer;
height: 32px; }
}
.import-container.highlight {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: scale(1.01);
}
.import-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding-top: var(--space-1);
}
.import-placeholder i {
font-size: 2.5rem;
/* color: var(--lora-accent); */
opacity: 0.8;
margin-bottom: var(--space-1);
}
.import-placeholder h3 {
margin: 0 0 var(--space-1);
font-size: 1.2rem;
font-weight: 500;
color: var(--text-color);
}
.import-placeholder p {
margin: var(--space-1) 0;
color: var(--text-color);
opacity: 0.8;
}
.import-placeholder .sub-text {
font-size: 0.9em;
opacity: 0.6;
margin: var(--space-1) 0;
}
.import-formats {
font-size: 0.8em !important;
opacity: 0.6 !important;
margin-top: var(--space-2) !important;
}
.select-files-btn {
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.select-files-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* For dark theme */
[data-theme="dark"] .import-container {
background: rgba(255, 255, 255, 0.03);
} }

View File

@@ -56,6 +56,24 @@
color: var(--lora-error); color: var(--lora-error);
} }
/* Update color scheme to include embeddings */
:root {
--embedding-color: oklch(68% 0.28 120); /* Green for embeddings */
}
/* Update metric cards and chart colors to support embeddings */
.metric-card.embedding .metric-icon {
color: var(--embedding-color);
}
.model-item.embedding {
border-left: 3px solid var(--embedding-color);
}
.model-item.embedding:hover {
border-color: var(--embedding-color);
}
/* Dashboard Content */ /* Dashboard Content */
.dashboard-content { .dashboard-content {
background: var(--card-bg); background: var(--card-bg);

View File

@@ -6,6 +6,7 @@
/* Import Components */ /* Import Components */
@import 'components/header.css'; @import 'components/header.css';
@import 'components/banner.css';
@import 'components/card.css'; @import 'components/card.css';
@import 'components/modal/_base.css'; @import 'components/modal/_base.css';
@import 'components/modal/delete-modal.css'; @import 'components/modal/delete-modal.css';

View File

@@ -1,6 +1,7 @@
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js'; import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js'; import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
@@ -339,6 +340,15 @@ function showExampleAccessModal(card, modelType) {
tabBtn.click(); tabBtn.click();
} }
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
toggleShowcase(scrollIndicator);
}
}
// Finally scroll to the import area // Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' }); importArea.scrollIntoView({ behavior: 'smooth' });
} }
@@ -444,7 +454,7 @@ export function createModelCard(model, modelType) {
card.innerHTML = ` card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}"> <div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ? ${isVideo ?
`<video ${videoAttrs}> `<video ${videoAttrs} style="pointer-events: none;">
<source src="${versionedPreviewUrl}" type="video/mp4"> <source src="${versionedPreviewUrl}" type="video/mp4">
</video>` : </video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">` `<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
@@ -472,6 +482,7 @@ export function createModelCard(model, modelType) {
<div class="card-footer"> <div class="card-footer">
<div class="model-info"> <div class="model-info">
<span class="model-name">${model.model_name}</span> <span class="model-name">${model.model_name}</span>
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<i class="fas fa-folder-open" <i class="fas fa-folder-open"

View File

@@ -1,3 +1,5 @@
import { showToast } from '../../utils/uiHelpers.js';
/** /**
* ModelDescription.js * ModelDescription.js
* Handles model description related functionality - General version * Handles model description related functionality - General version
@@ -40,4 +42,99 @@ export function setupTabSwitching() {
} }
}); });
}); });
}
/**
* Set up model description editing functionality
* @param {string} filePath - File path
*/
export function setupModelDescriptionEditing(filePath) {
const descContent = document.querySelector('.model-description-content');
const descContainer = document.querySelector('.model-description-container');
if (!descContent || !descContainer) return;
// Add edit button if not present
let editBtn = descContainer.querySelector('.edit-model-description-btn');
if (!editBtn) {
editBtn = document.createElement('button');
editBtn.className = 'edit-model-description-btn';
editBtn.title = 'Edit model description';
editBtn.innerHTML = '<i class="fas fa-pencil-alt"></i>';
descContainer.insertBefore(editBtn, descContent);
}
// Show edit button on hover
descContainer.addEventListener('mouseenter', () => {
editBtn.classList.add('visible');
});
descContainer.addEventListener('mouseleave', () => {
if (!descContainer.classList.contains('editing')) {
editBtn.classList.remove('visible');
}
});
// Handle edit button click
editBtn.addEventListener('click', () => {
descContainer.classList.add('editing');
descContent.setAttribute('contenteditable', 'true');
descContent.dataset.originalValue = descContent.innerHTML.trim();
descContent.focus();
// Place cursor at the end
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(descContent);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
editBtn.classList.add('visible');
});
// Keyboard events
descContent.addEventListener('keydown', function(e) {
if (!this.getAttribute('contenteditable')) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.blur();
} else if (e.key === 'Escape') {
e.preventDefault();
this.innerHTML = this.dataset.originalValue;
exitEditMode();
}
});
// Save on blur
descContent.addEventListener('blur', async function() {
if (!this.getAttribute('contenteditable')) return;
const newValue = this.innerHTML.trim();
const originalValue = this.dataset.originalValue;
if (newValue === originalValue) {
exitEditMode();
return;
}
if (!newValue) {
this.innerHTML = originalValue;
showToast('Description cannot be empty', 'error');
exitEditMode();
return;
}
try {
// Save to backend
const { getModelApiClient } = await import('../../api/baseModelApi.js');
await getModelApiClient().saveModelMetadata(filePath, { modelDescription: newValue });
showToast('Model description updated', 'success');
} catch (err) {
this.innerHTML = originalValue;
showToast('Failed to update model description', 'error');
} finally {
exitEditMode();
}
});
function exitEditMode() {
descContent.removeAttribute('contenteditable');
descContainer.classList.remove('editing');
editBtn.classList.remove('visible');
}
} }

View File

@@ -1,9 +1,12 @@
import { showToast, openCivitai } from '../../utils/uiHelpers.js'; import { showToast, openCivitai } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages loadExampleImages
} from './showcase/ShowcaseView.js'; } from './showcase/ShowcaseView.js';
import { setupTabSwitching } from './ModelDescription.js'; import { setupTabSwitching, setupModelDescriptionEditing } from './ModelDescription.js';
import { import {
setupModelNameEditing, setupModelNameEditing,
setupBaseModelEditing, setupBaseModelEditing,
@@ -180,12 +183,17 @@ export function showModelModal(model, modelType) {
<div class="tab-content"> <div class="tab-content">
${tabPanesContent} ${tabPanesContent}
</div> </div>
<button class="back-to-top" data-action="scroll-to-top">
<i class="fas fa-arrow-up"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
`; `;
const onCloseCallback = function() { const onCloseCallback = function() {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId); const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) { if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler); modalElement.removeEventListener('click', modalElement._clickHandler);
@@ -195,12 +203,14 @@ export function showModelModal(model, modelType) {
modalManager.showModal(modalId, content, null, onCloseCallback); modalManager.showModal(modalId, content, null, onCloseCallback);
setupEditableFields(model.file_path, modelType); setupEditableFields(model.file_path, modelType);
setupShowcaseScroll(modalId);
setupTabSwitching(); setupTabSwitching();
setupTagTooltip(); setupTagTooltip();
setupTagEditMode(); setupTagEditMode();
setupModelNameEditing(model.file_path); setupModelNameEditing(model.file_path);
setupBaseModelEditing(model.file_path); setupBaseModelEditing(model.file_path);
setupFileNameEditing(model.file_path); setupFileNameEditing(model.file_path);
setupModelDescriptionEditing(model.file_path, model.modelDescription || '');
setupEventHandlers(model.file_path); setupEventHandlers(model.file_path);
// LoRA specific setup // LoRA specific setup
@@ -213,9 +223,10 @@ export function showModelModal(model, modelType) {
} }
} }
// Load example images asynchronously // Load example images asynchronously - merge regular and custom images
const regularImages = model.civitai?.images || []; const regularImages = model.civitai?.images || [];
const customImages = model.civitai?.customImages || []; const customImages = model.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages]; const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, model.sha256); loadExampleImages(allImages, model.sha256);
} }
@@ -250,14 +261,16 @@ function renderEmbeddingSpecificContent(embedding, escapedWords) {
} }
/** /**
* Sets up event handlers using event delegation for modal * Sets up event handlers using event delegation for LoRA modal
* @param {string} filePath - Path to the model file * @param {string} filePath - Path to the model file
*/ */
function setupEventHandlers(filePath) { function setupEventHandlers(filePath) {
const modalElement = document.getElementById('modelModal'); const modalElement = document.getElementById('modelModal');
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick); modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) { function handleModalClick(event) {
const target = event.target.closest('[data-action]'); const target = event.target.closest('[data-action]');
if (!target) return; if (!target) return;
@@ -268,6 +281,9 @@ function setupEventHandlers(filePath) {
case 'close-modal': case 'close-modal':
modalManager.closeModal('modelModal'); modalManager.closeModal('modelModal');
break; break;
case 'scroll-to-top':
scrollToTop(target);
break;
case 'view-civitai': case 'view-civitai':
openCivitai(target.dataset.filepath); openCivitai(target.dataset.filepath);
break; break;
@@ -280,7 +296,10 @@ function setupEventHandlers(filePath) {
} }
} }
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick); modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick; modalElement._clickHandler = handleModalClick;
} }
@@ -402,7 +421,9 @@ async function saveNotes(filePath) {
// Export the model modal API // Export the model modal API
const modelModal = { const modelModal = {
show: showModelModal show: showModelModal,
toggleShowcase,
scrollToTop
}; };
export { modelModal }; export { modelModal };

View File

@@ -182,46 +182,119 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
* @param {HTMLElement} container - Container element with media wrappers * @param {HTMLElement} container - Container element with media wrappers
*/ */
export function initMetadataPanelHandlers(container) { export function initMetadataPanelHandlers(container) {
// Metadata panel interaction is now handled by the info button const mediaWrappers = container.querySelectorAll('.media-wrapper');
// Keep the existing copy functionality but remove hover-based visibility
const metadataPanel = container.querySelector('.image-metadata-panel');
if (metadataPanel) { mediaWrappers.forEach(wrapper => {
// Prevent events from bubbling // Get the metadata panel and media element (img or video)
metadataPanel.addEventListener('click', (e) => { const metadataPanel = wrapper.querySelector('.image-metadata-panel');
e.stopPropagation(); const mediaControls = wrapper.querySelector('.media-controls');
const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel and controls when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
}); });
// Handle copy prompt buttons wrapper.addEventListener('mouseleave', () => {
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); if (!isOverMetadataPanel) {
copyBtns.forEach(copyBtn => { if (metadataPanel) metadataPanel.classList.remove('visible');
const promptIndex = copyBtn.dataset.promptIndex; if (mediaControls) mediaControls.classList.remove('visible');
const promptElement = container.querySelector(`#prompt-${promptIndex}`); }
});
// Add mouse enter/leave events for the metadata panel itself
if (metadataPanel) {
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
});
copyBtn.addEventListener('click', async (e) => { metadataPanel.addEventListener('mouseleave', () => {
e.stopPropagation(); 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;
if (!promptElement) return; const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
try { if (!isOverMedia) {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard'); metadataPanel.classList.remove('visible');
} catch (err) { if (mediaControls) mediaControls.classList.remove('visible');
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;
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) { // Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
} });
}, { passive: true });
} // Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
});
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
}
});
} }
/** /**
@@ -293,8 +366,9 @@ export function initMediaControlHandlers(container) {
btn.addEventListener('click', async function(e) { btn.addEventListener('click', async function(e) {
e.stopPropagation(); e.stopPropagation();
// Explicitly check for disabled state
if (this.classList.contains('disabled')) { if (this.classList.contains('disabled')) {
return; return; // Don't do anything if button is disabled
} }
const shortId = this.dataset.shortId; const shortId = this.dataset.shortId;
@@ -302,11 +376,14 @@ export function initMediaControlHandlers(container) {
if (!shortId) return; if (!shortId) return;
// Handle two-step confirmation
if (btnState === 'initial') { if (btnState === 'initial') {
// First click: show confirmation state
this.dataset.state = 'confirm'; this.dataset.state = 'confirm';
this.classList.add('confirm'); this.classList.add('confirm');
this.title = 'Click again to confirm deletion'; this.title = 'Click again to confirm deletion';
// Auto-reset after 3 seconds
setTimeout(() => { setTimeout(() => {
if (this.dataset.state === 'confirm') { if (this.dataset.state === 'confirm') {
this.dataset.state = 'initial'; this.dataset.state = 'initial';
@@ -318,16 +395,19 @@ export function initMediaControlHandlers(container) {
return; return;
} }
// Second click within 3 seconds: proceed with deletion
if (btnState === 'confirm') { if (btnState === 'confirm') {
this.disabled = true; this.disabled = true;
this.classList.remove('confirm'); this.classList.remove('confirm');
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
// Get model hash from URL or data attribute
const mediaWrapper = this.closest('.media-wrapper'); const mediaWrapper = this.closest('.media-wrapper');
const modelHashAttr = document.querySelector('.showcase-section')?.dataset; const modelHashAttr = document.querySelector('.showcase-section')?.dataset;
const modelHash = modelHashAttr?.modelHash; const modelHash = modelHashAttr?.modelHash;
try { try {
// Call the API to delete the custom example
const response = await fetch('/api/delete-example-image', { const response = await fetch('/api/delete-example-image', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -342,45 +422,32 @@ export function initMediaControlHandlers(container) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Remove the corresponding thumbnail and update main display // Success: remove the media wrapper from the DOM
const thumbnailItem = container.querySelector(`.thumbnail-item[data-short-id="${shortId}"]`); mediaWrapper.style.opacity = '0';
if (thumbnailItem) { mediaWrapper.style.height = '0';
const wasActive = thumbnailItem.classList.contains('active'); mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s';
thumbnailItem.remove();
// If the deleted item was active, select next item
if (wasActive) {
const remainingThumbnails = container.querySelectorAll('.thumbnail-item');
if (remainingThumbnails.length > 0) {
remainingThumbnails[0].click();
} else {
// No more items, show empty state
const mainContainer = container.querySelector('#mainMediaContainer');
if (mainContainer) {
mainContainer.innerHTML = `
<div class="empty-state">
<i class="fas fa-images"></i>
<h3>No example images available</h3>
<p>Import images or videos using the sidebar</p>
</div>
`;
}
}
}
}
setTimeout(() => {
mediaWrapper.remove();
}, 600);
// Show success toast
showToast('Example image deleted', 'success'); showToast('Example image deleted', 'success');
// Create an update object with only the necessary properties
const updateData = { const updateData = {
civitai: { civitai: {
customImages: result.custom_images || [] customImages: result.custom_images || []
} }
}; };
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData); state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
} else { } else {
// Show error message
showToast(result.error || 'Failed to delete example image', 'error'); showToast(result.error || 'Failed to delete example image', 'error');
// Reset button state
this.disabled = false; this.disabled = false;
this.dataset.state = 'initial'; this.dataset.state = 'initial';
this.classList.remove('confirm'); this.classList.remove('confirm');
@@ -391,6 +458,7 @@ export function initMediaControlHandlers(container) {
console.error('Error deleting example image:', error); console.error('Error deleting example image:', error);
showToast('Failed to delete example image', 'error'); showToast('Failed to delete example image', 'error');
// Reset button state
this.disabled = false; this.disabled = false;
this.dataset.state = 'initial'; this.dataset.state = 'initial';
this.classList.remove('confirm'); this.classList.remove('confirm');
@@ -401,7 +469,11 @@ export function initMediaControlHandlers(container) {
}); });
}); });
// Initialize set preview buttons
initSetPreviewHandlers(container); initSetPreviewHandlers(container);
// Media control visibility is now handled in initMetadataPanelHandlers
// Any click handlers or other functionality can still be added here
} }
/** /**
@@ -472,4 +544,50 @@ function initSetPreviewHandlers(container) {
} }
}); });
}); });
}
/**
* Position media controls within the actual rendered media rectangle
* @param {HTMLElement} mediaWrapper - The wrapper containing the media and controls
*/
export function positionMediaControlsInMediaRect(mediaWrapper) {
const mediaElement = mediaWrapper.querySelector('img, video');
const controlsElement = mediaWrapper.querySelector('.media-controls');
if (!mediaElement || !controlsElement) return;
// Get wrapper dimensions
const wrapperRect = mediaWrapper.getBoundingClientRect();
// Calculate the actual rendered media rectangle
const mediaRect = getRenderedMediaRect(
mediaElement,
wrapperRect.width,
wrapperRect.height
);
// Calculate the position for controls - place them inside the actual media area
const padding = 8; // Padding from the edge of the media
// Position at top-right inside the actual media rectangle
controlsElement.style.top = `${mediaRect.top + padding}px`;
controlsElement.style.right = `${wrapperRect.width - mediaRect.right + padding}px`;
// Also position any toggle blur buttons in the same way but on the left
const toggleBlurBtn = mediaWrapper.querySelector('.toggle-blur-btn');
if (toggleBlurBtn) {
toggleBlurBtn.style.top = `${mediaRect.top + padding}px`;
toggleBlurBtn.style.left = `${mediaRect.left + padding}px`;
}
}
/**
* Position all media controls in a container
* @param {HTMLElement} container - Container with media wrappers
*/
export function positionAllMediaControls(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
positionMediaControlsInMediaRect(wrapper);
});
} }

View File

@@ -23,7 +23,6 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
const promptIndex = Math.random().toString(36).substring(2, 15); const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15); const negPromptIndex = Math.random().toString(36).substring(2, 15);
// Note: Panel visibility is now controlled by the info button, not hover
let content = '<div class="image-metadata-panel"><div class="metadata-content">'; let content = '<div class="image-metadata-panel"><div class="metadata-content">';
if (hasParams) { if (hasParams) {

View File

@@ -9,7 +9,8 @@ import {
initLazyLoading, initLazyLoading,
initNsfwBlurHandlers, initNsfwBlurHandlers,
initMetadataPanelHandlers, initMetadataPanelHandlers,
initMediaControlHandlers initMediaControlHandlers,
positionAllMediaControls
} from './MediaUtils.js'; } from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
@@ -45,10 +46,13 @@ export async function loadExampleImages(images, modelHash) {
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles); showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners // Re-initialize the showcase event listeners
initShowcaseContent(showcaseTab); const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the example import functionality // Initialize the example import functionality
// initExampleImport(modelHash, showcaseTab); initExampleImport(modelHash, showcaseTab);
} catch (error) { } catch (error) {
console.error('Error loading example images:', error); console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab'); const showcaseTab = document.getElementById('showcase-tab');
@@ -67,13 +71,13 @@ export async function loadExampleImages(images, modelHash) {
* Render showcase content * Render showcase content
* @param {Array} images - Array of images/videos to show * @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files * @param {Array} exampleFiles - Local example files
* @param {boolean} startExpanded - Whether to start in expanded state (unused in new design) * @param {boolean} startExpanded - Whether to start in expanded state
* @returns {string} HTML content * @returns {string} HTML content
*/ */
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) { export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
if (!images?.length) { if (!images?.length) {
// Show empty state with import interface // Show empty state with import interface
return renderEmptyShowcase(); return renderImportInterface(true);
} }
// Filter images based on SFW setting // Filter images based on SFW setting
@@ -108,69 +112,29 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
</div>` : ''; </div>` : '';
return ` return `
${hiddenNotification} <div class="scroll-indicator">
<div class="showcase-container"> <i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i>
<div class="thumbnail-sidebar" id="thumbnailSidebar"> <span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span>
<div class="thumbnail-grid"> </div>
${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')} <div class="carousel ${startExpanded ? '' : 'collapsed'}">
</div> ${hiddenNotification}
${renderImportInterface()} <div class="carousel-container">
</div> ${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')}
<div class="main-display-area">
<div class="navigation-controls">
<button class="nav-btn prev-btn" id="prevBtn" title="Previous (←)">
<i class="fas fa-chevron-left"></i>
</button>
<button class="nav-btn next-btn" id="nextBtn" title="Next (→)">
<i class="fas fa-chevron-right"></i>
</button>
<button class="nav-btn info-btn" id="infoBtn" title="Show/Hide Info (i)">
<i class="fas fa-info-circle"></i>
</button>
</div>
<div class="main-media-container" id="mainMediaContainer">
${filteredImages.length > 0 ? renderMainMediaItem(filteredImages[0], 0, exampleFiles) : ''}
</div>
</div> </div>
${renderImportInterface(false)}
</div> </div>
`; `;
} }
/** /**
* Find the matching local file for an image * Render a single media item (image or video)
* @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/
function findLocalFile(img, index, exampleFiles) {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (img.id) {
// This is a custom image, find by custom_<id>
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
}
/**
* Render a thumbnail for the sidebar
* @param {Object} img - Image/video metadata * @param {Object} img - Image/video metadata
* @param {number} index - Index in the array * @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files * @param {Array} exampleFiles - Local files
* @returns {string} HTML for the thumbnail * @returns {string} HTML for the media item
*/ */
function renderThumbnail(img, index, exampleFiles) { function renderMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files // Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles); let localFile = findLocalFile(img, index, exampleFiles);
@@ -179,57 +143,15 @@ function renderThumbnail(img, index, exampleFiles) {
const isVideo = localFile ? localFile.is_video : const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm'); remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Check if media should be blurred // Calculate appropriate aspect ratio
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const aspectRatio = (img.height / img.width) * 100;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; const containerWidth = 800; // modal content maximum width
const minHeightPercent = 40;
return ` const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
<div class="thumbnail-item ${index === 0 ? 'active' : ''}" const heightPercent = Math.max(
data-index="${index}" minHeightPercent,
data-nsfw-level="${nsfwLevel}" Math.min(maxHeightPercent, aspectRatio)
data-short-id="${img.id || ''}"> );
${isVideo ? `
<video class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
muted>
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
</video>
<div class="video-indicator">
<i class="fas fa-play"></i>
</div>
` : `
<img class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Thumbnail"
width="${img.width}"
height="${img.height}">
`}
${shouldBlur ? `
<div class="thumbnail-nsfw-overlay">
<i class="fas fa-eye-slash"></i>
</div>
` : ''}
</div>
`;
}
/**
* Render the main media item in the display area
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the main media item
*/
function renderMainMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles);
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Check if media should be blurred // Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
@@ -290,252 +212,380 @@ function renderMainMediaItem(img, index, exampleFiles) {
// Generate the appropriate wrapper based on media type // Generate the appropriate wrapper based on media type
if (isVideo) { if (isVideo) {
return generateVideoWrapper( return generateVideoWrapper(
img, 100, shouldBlur, nsfwText, metadataPanel, img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
return generateImageWrapper( return generateImageWrapper(
img, 100, shouldBlur, nsfwText, metadataPanel, img, heightPercent, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
/** /**
* Render empty showcase with import interface * Find the matching local file for an image
* @returns {string} HTML content for empty showcase * @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/ */
function renderEmptyShowcase() { function findLocalFile(img, index, exampleFiles) {
return ` if (!exampleFiles || exampleFiles.length === 0) return null;
<div class="showcase-container empty">
<div class="thumbnail-sidebar" id="thumbnailSidebar"> let localFile = null;
<div class="thumbnail-grid">
<!-- Empty thumbnails grid --> if (img.id) {
</div> // This is a custom image, find by custom_<id>
${renderImportInterface()} const customPrefix = `custom_${img.id}`;
</div> localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
<div class="main-display-area empty"> } else {
<div class="empty-state"> // This is a regular image from civitai, find by index
<i class="fas fa-images"></i> localFile = exampleFiles.find(file => {
<h3>No example images available</h3> const match = file.name.match(/image_(\d+)\./);
<p>Import images or videos using the sidebar</p> return match && parseInt(match[1]) === index;
</div> });
</div> }
</div>
`; return localFile;
} }
/** /**
* Render the import interface for example images * Render the import interface for example images
* @param {boolean} isEmpty - Whether there are no existing examples
* @returns {string} HTML content for import interface * @returns {string} HTML content for import interface
*/ */
function renderImportInterface() { function renderImportInterface(isEmpty) {
return ` return `
<div class="import-section"> <div class="example-import-area ${isEmpty ? 'empty' : ''}">
<button class="select-files-btn" id="selectExampleFilesBtn"> <div class="import-container" id="exampleImportContainer">
<i class="fas fa-plus"></i> <div class="import-placeholder">
<span>Add Images</span>
</button>
<div class="import-drop-zone" id="importDropZone">
<div class="drop-zone-content">
<i class="fas fa-cloud-upload-alt"></i> <i class="fas fa-cloud-upload-alt"></i>
<span>Drop here</span> <h3>${isEmpty ? 'No example images available' : 'Add more examples'}</h3>
<p>Drag & drop images or videos here</p>
<p class="sub-text">or</p>
<button class="select-files-btn" id="selectExampleFilesBtn">
<i class="fas fa-folder-open"></i> Select Files
</button>
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p>
</div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
<div class="import-progress-container" style="display: none;">
<div class="import-progress">
<div class="progress-bar"></div>
</div>
<span class="progress-text">Importing files...</span>
</div> </div>
</div> </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
</div> </div>
`; `;
} }
/** /**
* Initialize all showcase content interactions * Initialize the example import functionality
* @param {HTMLElement} showcase - The showcase element * @param {string} modelHash - The SHA256 hash of the model
* @param {Element} container - The container element for the import area
*/ */
export function initShowcaseContent(showcase) { export function initExampleImport(modelHash, container) {
if (!showcase) return;
const container = showcase.querySelector('.showcase-container');
if (!container) return; if (!container) return;
initLazyLoading(container); const importContainer = container.querySelector('#exampleImportContainer');
initNsfwBlurHandlers(container); const fileInput = container.querySelector('#exampleFilesInput');
initThumbnailNavigation(container); const selectFilesBtn = container.querySelector('#selectExampleFilesBtn');
initMainDisplayHandlers(container);
initMediaControlHandlers(container);
// Initialize keyboard navigation // Set up file selection button
initKeyboardNavigation(container); if (selectFilesBtn) {
} selectFilesBtn.addEventListener('click', () => {
fileInput.click();
/**
* Initialize thumbnail navigation
* @param {HTMLElement} container - The showcase container
*/
function initThumbnailNavigation(container) {
const thumbnails = container.querySelectorAll('.thumbnail-item');
const mainContainer = container.querySelector('#mainMediaContainer');
if (!mainContainer) return;
thumbnails.forEach((thumbnail, index) => {
thumbnail.addEventListener('click', () => {
// Update active thumbnail
thumbnails.forEach(t => t.classList.remove('active'));
thumbnail.classList.add('active');
// Get the corresponding image data and render main media
const showcaseSection = document.querySelector('.showcase-section');
const modelHash = showcaseSection?.dataset.modelHash;
// This would need access to the filtered images array
// For now, we'll trigger a re-render of the main display
updateMainDisplay(index, container);
}); });
});
}
/**
* Initialize main display handlers including navigation and info toggle
* @param {HTMLElement} container - The showcase container
*/
function initMainDisplayHandlers(container) {
const prevBtn = container.querySelector('#prevBtn');
const nextBtn = container.querySelector('#nextBtn');
const infoBtn = container.querySelector('#infoBtn');
if (prevBtn) {
prevBtn.addEventListener('click', () => navigateMedia(container, -1));
} }
if (nextBtn) { // Handle file selection
nextBtn.addEventListener('click', () => navigateMedia(container, 1)); if (fileInput) {
} fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
if (infoBtn) { handleImportFiles(Array.from(e.target.files), modelHash, importContainer);
infoBtn.addEventListener('click', () => toggleMetadataPanel(container));
}
// Initialize metadata panel toggle behavior
initMetadataPanelToggle(container);
}
/**
* Initialize keyboard navigation
* @param {HTMLElement} container - The showcase container
*/
function initKeyboardNavigation(container) {
document.addEventListener('keydown', (e) => {
// Only handle if showcase is visible and focused
if (!container.closest('.modal').classList.contains('show')) return;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
navigateMedia(container, -1);
break;
case 'ArrowRight':
e.preventDefault();
navigateMedia(container, 1);
break;
case 'i':
case 'I':
e.preventDefault();
toggleMetadataPanel(container);
break;
}
});
}
/**
* Navigate to previous/next media item
* @param {HTMLElement} container - The showcase container
* @param {number} direction - -1 for previous, 1 for next
*/
function navigateMedia(container, direction) {
const thumbnails = container.querySelectorAll('.thumbnail-item');
const activeThumbnail = container.querySelector('.thumbnail-item.active');
if (!activeThumbnail || thumbnails.length === 0) return;
const currentIndex = Array.from(thumbnails).indexOf(activeThumbnail);
let newIndex = currentIndex + direction;
// Wrap around
if (newIndex < 0) newIndex = thumbnails.length - 1;
if (newIndex >= thumbnails.length) newIndex = 0;
// Click the new thumbnail to trigger the display update
thumbnails[newIndex].click();
}
/**
* Toggle metadata panel visibility
* @param {HTMLElement} container - The showcase container
*/
function toggleMetadataPanel(container) {
const metadataPanel = container.querySelector('.image-metadata-panel');
const infoBtn = container.querySelector('#infoBtn');
if (!metadataPanel || !infoBtn) return;
const isVisible = metadataPanel.classList.contains('visible');
if (isVisible) {
metadataPanel.classList.remove('visible');
infoBtn.classList.remove('active');
} else {
metadataPanel.classList.add('visible');
infoBtn.classList.add('active');
}
}
/**
* Initialize metadata panel toggle behavior
* @param {HTMLElement} container - The showcase container
*/
function initMetadataPanelToggle(container) {
const metadataPanel = container.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
// Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
} }
}); });
}); }
// Prevent panel scroll from causing modal scroll // Set up drag and drop
metadataPanel.addEventListener('wheel', (e) => { if (importContainer) {
const isAtTop = metadataPanel.scrollTop === 0; ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight; importContainer.addEventListener(eventName, preventDefaults, false);
});
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) { function preventDefaults(e) {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
}, { passive: true });
// Highlight drop area on drag over
['dragenter', 'dragover'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.add('highlight');
}, false);
});
// Remove highlight on drag leave
['dragleave', 'drop'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.remove('highlight');
}, false);
});
// Handle dropped files
importContainer.addEventListener('drop', (e) => {
const files = Array.from(e.dataTransfer.files);
handleImportFiles(files, modelHash, importContainer);
}, false);
}
} }
/** /**
* Update main display with new media item * Handle the file import process
* @param {number} index - Index of the media to display * @param {File[]} files - Array of files to import
* @param {HTMLElement} container - The showcase container * @param {string} modelHash - The SHA256 hash of the model
* @param {Element} importContainer - The container element for import UI
*/ */
function updateMainDisplay(index, container) { async function handleImportFiles(files, modelHash, importContainer) {
// This function would need to re-render the main display area // Filter for supported file types
// Implementation depends on how the image data is stored and accessed const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
console.log('Update main display to index:', index); const supportedVideos = ['.mp4', '.webm'];
const supportedExtensions = [...supportedImages, ...supportedVideos];
const validFiles = files.filter(file => {
const ext = '.' + file.name.split('.').pop().toLowerCase();
return supportedExtensions.includes(ext);
});
if (validFiles.length === 0) {
alert('No supported files selected. Please select image or video files.');
return;
}
try {
// Use FormData to upload files
const formData = new FormData();
formData.append('model_hash', modelHash);
validFiles.forEach(file => {
formData.append('files', file);
});
// Call API to import files
const response = await fetch('/api/import-example-images', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to import example files');
}
// Get updated local files
const updatedFilesResponse = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
const updatedFilesResult = await updatedFilesResponse.json();
if (!updatedFilesResult.success) {
throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
}
// Re-render the showcase content
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// Get the updated images from the result
const regularImages = result.regular_images || [];
const customImages = result.custom_images || [];
// Combine both arrays for rendering
const allImages = [...regularImages, ...customImages];
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
// Re-initialize showcase functionality
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the import UI for the new content
initExampleImport(modelHash, showcaseTab);
showToast('Example images imported successfully', 'success');
// Update VirtualScroller if available
if (state.virtualScroller && result.model_file_path) {
// Create an update object with only the necessary properties
const updateData = {
civitai: {
images: regularImages,
customImages: customImages
}
};
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
}
}
} catch (error) {
console.error('Error importing examples:', error);
showToast(`Failed to import example images: ${error.message}`, 'error');
}
}
/**
* Toggle showcase expansion
* @param {HTMLElement} element - The scroll indicator element
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed');
if (isCollapsed) {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initShowcaseContent(carousel);
} else {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`;
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
// Make sure any open metadata panels get closed
const carouselContainer = carousel.querySelector('.carousel-container');
if (carouselContainer) {
carouselContainer.style.height = '0';
setTimeout(() => {
carouselContainer.style.height = '';
}, 300);
}
}
}
/**
* Initialize all showcase content interactions
* @param {HTMLElement} carousel - The carousel element
*/
export function initShowcaseContent(carousel) {
if (!carousel) return;
initLazyLoading(carousel);
initNsfwBlurHandlers(carousel);
initMetadataPanelHandlers(carousel);
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
// Bind scroll-indicator click to toggleShowcase
const scrollIndicator = carousel.previousElementSibling;
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
// Remove previous click listeners to avoid duplicates
scrollIndicator.onclick = null;
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
}
// Add window resize handler
const resizeHandler = () => positionAllMediaControls(carousel);
window.removeEventListener('resize', resizeHandler);
window.addEventListener('resize', resizeHandler);
// Handle images loading which might change dimensions
const mediaElements = carousel.querySelectorAll('img, video');
mediaElements.forEach(media => {
media.addEventListener('load', () => positionAllMediaControls(carousel));
if (media.tagName === 'VIDEO') {
media.addEventListener('loadedmetadata', () => positionAllMediaControls(carousel));
}
});
}
/**
* Scroll to top of modal content
* @param {HTMLElement} button - Back to top button
*/
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
/**
* Set up showcase scroll functionality
* @param {string} modalId - ID of the modal element
*/
export function setupShowcaseScroll(modalId) {
// Listen for wheel events
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false });
// Use MutationObserver to set up back-to-top button when modal content is added
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const modal = document.getElementById(modalId);
if (modal && modal.querySelector('.modal-content')) {
setupBackToTopButton(modal.querySelector('.modal-content'));
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Try to set up the button immediately in case the modal is already open
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (modalContent) {
setupBackToTopButton(modalContent);
}
}
/**
* Set up back-to-top button
* @param {HTMLElement} modalContent - Modal content element
*/
function setupBackToTopButton(modalContent) {
// Remove any existing scroll listeners to avoid duplicates
modalContent.onscroll = null;
// Add new scroll listener
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
}
});
// Trigger a scroll event to check initial position
modalContent.dispatchEvent(new Event('scroll'));
} }

View File

@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js'; import { settingsManager } from './managers/SettingsManager.js';
import { exampleImagesManager } from './managers/ExampleImagesManager.js'; import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js'; import { helpManager } from './managers/HelpManager.js';
import { bannerService } from './managers/BannerService.js';
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js'; import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js'; import { migrateStorageItems } from './utils/storageHelpers.js';
@@ -27,6 +28,7 @@ export class AppCore {
state.loadingManager = new LoadingManager(); state.loadingManager = new LoadingManager();
modalManager.initialize(); modalManager.initialize();
updateService.initialize(); updateService.initialize();
bannerService.initialize();
window.modalManager = modalManager; window.modalManager = modalManager;
window.settingsManager = settingsManager; window.settingsManager = settingsManager;
window.exampleImagesManager = exampleImagesManager; window.exampleImagesManager = exampleImagesManager;

View File

@@ -0,0 +1,176 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
/**
* Banner Service for managing notification banners
*/
class BannerService {
constructor() {
this.banners = new Map();
this.container = null;
this.initialized = false;
}
/**
* Initialize the banner service
*/
initialize() {
if (this.initialized) return;
this.container = document.getElementById('banner-container');
if (!this.container) {
console.warn('Banner container not found');
return;
}
// Register default banners
this.registerBanner('civitai-extension', {
id: 'civitai-extension',
title: 'New Tool Available: LM Civitai Extension!',
content: 'LM Civitai Extension is a browser extension designed to work seamlessly with LoRA Manager to significantly enhance your Civitai browsing experience! See which models you already have, download new ones with a single click, and manage your downloads efficiently.',
actions: [
{
text: 'Chrome Web Store',
icon: 'fab fa-chrome',
url: 'https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb',
type: 'secondary'
},
{
text: 'Firefox Extension',
icon: 'fab fa-firefox-browser',
url: 'https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi',
type: 'secondary'
},
{
text: 'Read more...',
icon: 'fas fa-book',
url: 'https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension)',
type: 'tertiary'
}
],
dismissible: true,
priority: 1
});
this.showActiveBanners();
this.initialized = true;
}
/**
* Register a new banner
* @param {string} id - Unique banner ID
* @param {Object} bannerConfig - Banner configuration
*/
registerBanner(id, bannerConfig) {
this.banners.set(id, bannerConfig);
}
/**
* Check if a banner has been dismissed
* @param {string} bannerId - Banner ID
* @returns {boolean}
*/
isBannerDismissed(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
return dismissedBanners.includes(bannerId);
}
/**
* Dismiss a banner
* @param {string} bannerId - Banner ID
*/
dismissBanner(bannerId) {
const dismissedBanners = getStorageItem('dismissed_banners', []);
if (!dismissedBanners.includes(bannerId)) {
dismissedBanners.push(bannerId);
setStorageItem('dismissed_banners', dismissedBanners);
}
// Remove banner from DOM
const bannerElement = document.querySelector(`[data-banner-id="${bannerId}"]`);
if (bannerElement) {
bannerElement.style.animation = 'banner-slide-up 0.3s ease-in-out forwards';
setTimeout(() => {
bannerElement.remove();
this.updateContainerVisibility();
}, 300);
}
}
/**
* Show all active (non-dismissed) banners
*/
showActiveBanners() {
if (!this.container) return;
const activeBanners = Array.from(this.banners.values())
.filter(banner => !this.isBannerDismissed(banner.id))
.sort((a, b) => (b.priority || 0) - (a.priority || 0));
activeBanners.forEach(banner => {
this.renderBanner(banner);
});
this.updateContainerVisibility();
}
/**
* Render a banner to the DOM
* @param {Object} banner - Banner configuration
*/
renderBanner(banner) {
const bannerElement = document.createElement('div');
bannerElement.className = 'banner-item';
bannerElement.setAttribute('data-banner-id', banner.id);
const actionsHtml = banner.actions ? banner.actions.map(action =>
`<a href="${action.url}" target="_blank" class="banner-action banner-action-${action.type}" rel="noopener noreferrer">
<i class="${action.icon}"></i>
<span>${action.text}</span>
</a>`
).join('') : '';
const dismissButtonHtml = banner.dismissible ?
`<button class="banner-dismiss" onclick="bannerService.dismissBanner('${banner.id}')" title="Dismiss">
<i class="fas fa-times"></i>
</button>` : '';
bannerElement.innerHTML = `
<div class="banner-content">
<div class="banner-text">
<h4 class="banner-title">${banner.title}</h4>
<p class="banner-description">${banner.content}</p>
</div>
<div class="banner-actions">
${actionsHtml}
</div>
</div>
${dismissButtonHtml}
`;
this.container.appendChild(bannerElement);
}
/**
* Update container visibility based on active banners
*/
updateContainerVisibility() {
if (!this.container) return;
const hasActiveBanners = this.container.children.length > 0;
this.container.style.display = hasActiveBanners ? 'block' : 'none';
}
/**
* Clear all dismissed banners (for testing/admin purposes)
*/
clearDismissedBanners() {
setStorageItem('dismissed_banners', []);
location.reload();
}
}
// Create and export singleton instance
export const bannerService = new BannerService();
// Make it globally available
window.bannerService = bannerService;

View File

@@ -1,7 +1,5 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { LoadingManager } from './LoadingManager.js'; import { LoadingManager } from './LoadingManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
import { ImportStepManager } from './import/ImportStepManager.js'; import { ImportStepManager } from './import/ImportStepManager.js';
import { ImageProcessor } from './import/ImageProcessor.js'; import { ImageProcessor } from './import/ImageProcessor.js';
import { RecipeDataManager } from './import/RecipeDataManager.js'; import { RecipeDataManager } from './import/RecipeDataManager.js';
@@ -86,8 +84,8 @@ export class ImportManager {
const uploadError = document.getElementById('uploadError'); const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = ''; if (uploadError) uploadError.textContent = '';
const urlError = document.getElementById('urlError'); const importUrlError = document.getElementById('importUrlError');
if (urlError) urlError.textContent = ''; if (importUrlError) importUrlError.textContent = '';
const recipeName = document.getElementById('recipeName'); const recipeName = document.getElementById('recipeName');
if (recipeName) recipeName.value = ''; if (recipeName) recipeName.value = '';
@@ -167,10 +165,10 @@ export class ImportManager {
// Clear error messages // Clear error messages
const uploadError = document.getElementById('uploadError'); const uploadError = document.getElementById('uploadError');
const urlError = document.getElementById('urlError'); const importUrlError = document.getElementById('importUrlError');
if (uploadError) uploadError.textContent = ''; if (uploadError) uploadError.textContent = '';
if (urlError) urlError.textContent = ''; if (importUrlError) importUrlError.textContent = '';
} }
handleImageUpload(event) { handleImageUpload(event) {
@@ -224,8 +222,8 @@ export class ImportManager {
const uploadError = document.getElementById('uploadError'); const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = ''; if (uploadError) uploadError.textContent = '';
const urlError = document.getElementById('urlError'); const importUrlError = document.getElementById('importUrlError');
if (urlError) urlError.textContent = ''; if (importUrlError) importUrlError.textContent = '';
} }
backToDetails() { backToDetails() {

View File

@@ -90,7 +90,7 @@ class MoveManager {
).join(''); ).join('');
// Set default lora root if available // Set default lora root if available
const defaultRoot = getStorageItem('settings', {}).default_loras_root; const defaultRoot = getStorageItem('settings', {}).default_lora_root;
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
this.loraRootSelect.value = defaultRoot; this.loraRootSelect.value = defaultRoot;
} }

View File

@@ -15,29 +15,39 @@ export class SettingsManager {
// Ensure settings are loaded from localStorage // Ensure settings are loaded from localStorage
this.loadSettingsFromStorage(); this.loadSettingsFromStorage();
// Sync settings to backend if needed
this.syncSettingsToBackendIfNeeded();
this.initialize(); this.initialize();
} }
loadSettingsFromStorage() { loadSettingsFromStorage() {
// Get saved settings from localStorage // Get saved settings from localStorage
const savedSettings = getStorageItem('settings'); const savedSettings = getStorageItem('settings');
// Migrate legacy default_loras_root to default_lora_root if present
if (savedSettings && savedSettings.default_loras_root && !savedSettings.default_lora_root) {
savedSettings.default_lora_root = savedSettings.default_loras_root;
delete savedSettings.default_loras_root;
setStorageItem('settings', savedSettings);
}
// Apply saved settings to state if available // Apply saved settings to state if available
if (savedSettings) { if (savedSettings) {
state.global.settings = { ...state.global.settings, ...savedSettings }; state.global.settings = { ...state.global.settings, ...savedSettings };
} }
// Initialize default values for new settings if they don't exist // Initialize default values for new settings if they don't exist
if (state.global.settings.compactMode === undefined) { if (state.global.settings.compactMode === undefined) {
state.global.settings.compactMode = false; state.global.settings.compactMode = false;
} }
// Set default for optimizeExampleImages if undefined // Set default for optimizeExampleImages if undefined
if (state.global.settings.optimizeExampleImages === undefined) { if (state.global.settings.optimizeExampleImages === undefined) {
state.global.settings.optimizeExampleImages = true; state.global.settings.optimizeExampleImages = true;
} }
// Set default for cardInfoDisplay if undefined // Set default for cardInfoDisplay if undefined
if (state.global.settings.cardInfoDisplay === undefined) { if (state.global.settings.cardInfoDisplay === undefined) {
state.global.settings.cardInfoDisplay = 'always'; state.global.settings.cardInfoDisplay = 'always';
@@ -67,6 +77,55 @@ export class SettingsManager {
if (state.global.settings.base_model_path_mappings === undefined) { if (state.global.settings.base_model_path_mappings === undefined) {
state.global.settings.base_model_path_mappings = {}; state.global.settings.base_model_path_mappings = {};
} }
// Set default for defaultEmbeddingRoot if undefined
if (state.global.settings.default_embedding_root === undefined) {
state.global.settings.default_embedding_root = '';
}
}
async syncSettingsToBackendIfNeeded() {
// Get local settings from storage
const localSettings = getStorageItem('settings') || {};
// Fields that need to be synced to backend
const fieldsToSync = [
'civitai_api_key',
'default_lora_root',
'default_checkpoint_root',
'default_embedding_root',
'base_model_path_mappings',
'download_path_template'
];
// Build payload for syncing
const payload = {};
fieldsToSync.forEach(key => {
if (localSettings[key] !== undefined) {
if (key === 'base_model_path_mappings') {
payload[key] = JSON.stringify(localSettings[key]);
} else {
payload[key] = localSettings[key];
}
}
});
// Only send request if there is something to sync
if (Object.keys(payload).length > 0) {
try {
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
// Log success to console
console.log('Settings synced to backend');
} catch (e) {
// Log error to console
console.error('Failed to sync settings to backend:', e);
}
}
} }
initialize() { initialize() {
@@ -151,8 +210,9 @@ export class SettingsManager {
// Load default checkpoint root // Load default checkpoint root
await this.loadCheckpointRoots(); await this.loadCheckpointRoots();
// Backend settings are loaded from the template directly // Load default embedding root
await this.loadEmbeddingRoots();
} }
async loadLoraRoots() { async loadLoraRoots() {
@@ -185,7 +245,7 @@ export class SettingsManager {
}); });
// Set selected value from settings // Set selected value from settings
const defaultRoot = state.global.settings.default_loras_root || ''; const defaultRoot = state.global.settings.default_lora_root || '';
defaultLoraRootSelect.value = defaultRoot; defaultLoraRootSelect.value = defaultRoot;
} catch (error) { } catch (error) {
@@ -233,6 +293,45 @@ export class SettingsManager {
} }
} }
async loadEmbeddingRoots() {
try {
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
if (!defaultEmbeddingRootSelect) return;
// Fetch embedding roots
const response = await fetch('/api/embeddings/roots');
if (!response.ok) {
throw new Error('Failed to fetch embedding roots');
}
const data = await response.json();
if (!data.roots || data.roots.length === 0) {
throw new Error('No embedding roots found');
}
// Clear existing options except the first one (No Default)
const noDefaultOption = defaultEmbeddingRootSelect.querySelector('option[value=""]');
defaultEmbeddingRootSelect.innerHTML = '';
defaultEmbeddingRootSelect.appendChild(noDefaultOption);
// Add options for each root
data.roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
defaultEmbeddingRootSelect.appendChild(option);
});
// Set selected value from settings
const defaultRoot = state.global.settings.default_embedding_root || '';
defaultEmbeddingRootSelect.value = defaultRoot;
} catch (error) {
console.error('Error loading embedding roots:', error);
showToast('Failed to load embedding roots: ' + error.message, 'error');
}
}
loadBaseModelMappings() { loadBaseModelMappings() {
const mappingsContainer = document.getElementById('baseModelMappingsContainer'); const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return; if (!mappingsContainer) return;
@@ -460,7 +559,7 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // For backend settings, make API call
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover', 'optimize_example_images', 'use_centralized_examples'].includes(settingKey)) { if (['show_only_sfw'].includes(settingKey)) {
const payload = {}; const payload = {};
payload[settingKey] = value; payload[settingKey] = value;
@@ -505,9 +604,11 @@ export class SettingsManager {
// Update frontend state // Update frontend state
if (settingKey === 'default_lora_root') { if (settingKey === 'default_lora_root') {
state.global.settings.default_loras_root = value; state.global.settings.default_lora_root = value;
} else if (settingKey === 'default_checkpoint_root') { } else if (settingKey === 'default_checkpoint_root') {
state.global.settings.default_checkpoint_root = value; state.global.settings.default_checkpoint_root = value;
} else if (settingKey === 'default_embedding_root') {
state.global.settings.default_embedding_root = value;
} else if (settingKey === 'display_density') { } else if (settingKey === 'display_density') {
state.global.settings.displayDensity = value; state.global.settings.displayDensity = value;
@@ -528,7 +629,7 @@ export class SettingsManager {
try { try {
// For backend settings, make API call // For backend settings, make API call
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'download_path_template') { if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root' || settingKey === 'default_embedding_root' || settingKey === 'download_path_template') {
const payload = {}; const payload = {};
payload[settingKey] = value; payload[settingKey] = value;
@@ -583,10 +684,7 @@ export class SettingsManager {
// Update state // Update state
state.global.settings[settingKey] = value; state.global.settings[settingKey] = value;
// Save to localStorage if appropriate setStorageItem('settings', state.global.settings);
if (!settingKey.includes('api_key')) { // Don't store API keys in localStorage for security
setStorageItem('settings', state.global.settings);
}
// For backend settings, make API call // For backend settings, make API call
const payload = {}; const payload = {};
@@ -668,69 +766,6 @@ export class SettingsManager {
} }
} }
async saveSettings() {
// Get frontend settings from UI
const blurMatureContent = document.getElementById('blurMatureContent').checked;
const showOnlySFW = document.getElementById('showOnlySFW').checked;
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
const defaultCheckpointRoot = document.getElementById('defaultCheckpointRoot').value;
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
const optimizeExampleImages = document.getElementById('optimizeExampleImages').checked;
// Get backend settings
const apiKey = document.getElementById('civitaiApiKey').value;
// Update frontend state and save to localStorage
state.global.settings.blurMatureContent = blurMatureContent;
state.global.settings.show_only_sfw = showOnlySFW;
state.global.settings.default_loras_root = defaultLoraRoot;
state.global.settings.default_checkpoint_root = defaultCheckpointRoot;
state.global.settings.autoplayOnHover = autoplayOnHover;
state.global.settings.optimizeExampleImages = optimizeExampleImages;
// Save settings to localStorage
setStorageItem('settings', state.global.settings);
try {
// Save backend settings via API
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
civitai_api_key: apiKey,
show_only_sfw: showOnlySFW,
optimize_example_images: optimizeExampleImages,
default_checkpoint_root: defaultCheckpointRoot
})
});
if (!response.ok) {
throw new Error('Failed to save settings');
}
showToast('Settings saved successfully', 'success');
modalManager.closeModal('settingsModal');
// Apply frontend settings immediately
this.applyFrontendSettings();
if (this.currentPage === 'loras') {
// Reload the loras without updating folders
await resetAndReload(false);
} else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders
await window.recipeManager.loadRecipes();
} else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders
await window.checkpointsManager.loadCheckpoints();
}
} catch (error) {
showToast('Failed to save settings: ' + error.message, 'error');
}
}
applyFrontendSettings() { applyFrontendSettings() {
// Apply blur setting to existing content // Apply blur setting to existing content
const blurSetting = state.global.settings.blurMatureContent; const blurSetting = state.global.settings.blurMatureContent;

View File

@@ -358,9 +358,10 @@ export class UpdateService {
<i class="fas fa-check-circle" style="margin-right: 8px;"></i> <i class="fas fa-check-circle" style="margin-right: 8px;"></i>
Successfully updated to ${newVersion}! Successfully updated to ${newVersion}!
<br><br> <br><br>
<small style="opacity: 0.8;"> <div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
Please restart ComfyUI to complete the update process. Please restart ComfyUI or LoRA Manager to apply update.<br>
</small> Make sure to reload your browser for both LoRA Manager and ComfyUI.
</div>
</div> </div>
`; `;
} }
@@ -370,10 +371,10 @@ export class UpdateService {
this.updateAvailable = false; this.updateAvailable = false;
// Refresh the modal content // Refresh the modal content
setTimeout(() => { // setTimeout(() => {
this.updateModalContent(); // this.updateModalContent();
this.showUpdateProgress(false); // this.showUpdateProgress(false);
}, 2000); // }, 2000);
} }
// Simple markdown parser for changelog items // Simple markdown parser for changelog items

View File

@@ -112,7 +112,7 @@ export class FolderBrowser {
).join(''); ).join('');
// Set default lora root if available // Set default lora root if available
const defaultRoot = getStorageItem('settings', {}).default_loras_root; const defaultRoot = getStorageItem('settings', {}).default_lora_root;
if (defaultRoot && rootsData.roots.includes(defaultRoot)) { if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
loraRoot.value = defaultRoot; loraRoot.value = defaultRoot;
} }

View File

@@ -27,7 +27,7 @@ export class ImageProcessor {
async handleUrlInput() { async handleUrlInput() {
const urlInput = document.getElementById('imageUrlInput'); const urlInput = document.getElementById('imageUrlInput');
const errorElement = document.getElementById('urlError'); const errorElement = document.getElementById('importUrlError');
const input = urlInput.value.trim(); const input = urlInput.value.trim();
// Validate input // Validate input

View File

@@ -150,6 +150,12 @@ class StatisticsManager {
value: this.data.collection.checkpoint_count, value: this.data.collection.checkpoint_count,
label: 'Checkpoints', label: 'Checkpoints',
format: 'number' format: 'number'
},
{
icon: 'fas fa-code',
value: this.data.collection.embedding_count,
label: 'Embeddings',
format: 'number'
} }
]; ];
@@ -195,7 +201,9 @@ class StatisticsManager {
if (!this.data.collection) return 0; if (!this.data.collection) return 0;
const totalModels = this.data.collection.total_models; const totalModels = this.data.collection.total_models;
const unusedModels = this.data.collection.unused_loras + this.data.collection.unused_checkpoints; const unusedModels = this.data.collection.unused_loras +
this.data.collection.unused_checkpoints +
this.data.collection.unused_embeddings;
const usedModels = totalModels - unusedModels; const usedModels = totalModels - unusedModels;
return totalModels > 0 ? (usedModels / totalModels) * 100 : 0; return totalModels > 0 ? (usedModels / totalModels) * 100 : 0;
@@ -233,12 +241,17 @@ class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints'], labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
datasets: [{ datasets: [{
data: [this.data.collection.lora_count, this.data.collection.checkpoint_count], data: [
this.data.collection.lora_count,
this.data.collection.checkpoint_count,
this.data.collection.embedding_count
],
backgroundColor: [ backgroundColor: [
'oklch(68% 0.28 256)', 'oklch(68% 0.28 256)',
'oklch(68% 0.28 200)' 'oklch(68% 0.28 200)',
'oklch(68% 0.28 120)'
], ],
borderWidth: 2, borderWidth: 2,
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color') borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color')
@@ -266,8 +279,13 @@ class StatisticsManager {
const loraData = this.data.baseModels.loras; const loraData = this.data.baseModels.loras;
const checkpointData = this.data.baseModels.checkpoints; const checkpointData = this.data.baseModels.checkpoints;
const embeddingData = this.data.baseModels.embeddings;
const allModels = new Set([...Object.keys(loraData), ...Object.keys(checkpointData)]); const allModels = new Set([
...Object.keys(loraData),
...Object.keys(checkpointData),
...Object.keys(embeddingData)
]);
const data = { const data = {
labels: Array.from(allModels), labels: Array.from(allModels),
@@ -281,6 +299,11 @@ class StatisticsManager {
label: 'Checkpoints', label: 'Checkpoints',
data: Array.from(allModels).map(model => checkpointData[model] || 0), data: Array.from(allModels).map(model => checkpointData[model] || 0),
backgroundColor: 'oklch(68% 0.28 200 / 0.7)' backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
},
{
label: 'Embeddings',
data: Array.from(allModels).map(model => embeddingData[model] || 0),
backgroundColor: 'oklch(68% 0.28 120 / 0.7)'
} }
] ]
}; };
@@ -325,6 +348,13 @@ class StatisticsManager {
borderColor: 'oklch(68% 0.28 200)', borderColor: 'oklch(68% 0.28 200)',
backgroundColor: 'oklch(68% 0.28 200 / 0.1)', backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
fill: true fill: true
},
{
label: 'Embedding Usage',
data: timeline.map(item => item.embedding_usage),
borderColor: 'oklch(68% 0.28 120)',
backgroundColor: 'oklch(68% 0.28 120 / 0.1)',
fill: true
} }
] ]
}; };
@@ -365,11 +395,13 @@ class StatisticsManager {
const topLoras = this.data.usage.top_loras || []; const topLoras = this.data.usage.top_loras || [];
const topCheckpoints = this.data.usage.top_checkpoints || []; const topCheckpoints = this.data.usage.top_checkpoints || [];
const topEmbeddings = this.data.usage.top_embeddings || [];
// Combine and sort all models by usage // Combine and sort all models by usage
const allModels = [ const allModels = [
...topLoras.map(m => ({ ...m, type: 'LoRA' })), ...topLoras.map(m => ({ ...m, type: 'LoRA' })),
...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' })) ...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' })),
...topEmbeddings.map(m => ({ ...m, type: 'Embedding' }))
].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10); ].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10);
const data = { const data = {
@@ -377,9 +409,14 @@ class StatisticsManager {
datasets: [{ datasets: [{
label: 'Usage Count', label: 'Usage Count',
data: allModels.map(model => model.usage_count), data: allModels.map(model => model.usage_count),
backgroundColor: allModels.map(model => backgroundColor: allModels.map(model => {
model.type === 'LoRA' ? 'oklch(68% 0.28 256)' : 'oklch(68% 0.28 200)' switch(model.type) {
) case 'LoRA': return 'oklch(68% 0.28 256)';
case 'Checkpoint': return 'oklch(68% 0.28 200)';
case 'Embedding': return 'oklch(68% 0.28 120)';
default: return 'oklch(68% 0.28 256)';
}
})
}] }]
}; };
@@ -404,12 +441,17 @@ class StatisticsManager {
if (!ctx || !this.data.collection) return; if (!ctx || !this.data.collection) return;
const data = { const data = {
labels: ['LoRAs', 'Checkpoints'], labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
datasets: [{ datasets: [{
data: [this.data.collection.lora_size, this.data.collection.checkpoint_size], data: [
this.data.collection.lora_size,
this.data.collection.checkpoint_size,
this.data.collection.embedding_size
],
backgroundColor: [ backgroundColor: [
'oklch(68% 0.28 256)', 'oklch(68% 0.28 256)',
'oklch(68% 0.28 200)' 'oklch(68% 0.28 200)',
'oklch(68% 0.28 120)'
] ]
}] }]
}; };
@@ -443,10 +485,12 @@ class StatisticsManager {
const loraData = this.data.storage.loras || []; const loraData = this.data.storage.loras || [];
const checkpointData = this.data.storage.checkpoints || []; const checkpointData = this.data.storage.checkpoints || [];
const embeddingData = this.data.storage.embeddings || [];
const allData = [ const allData = [
...loraData.map(item => ({ ...item, type: 'LoRA' })), ...loraData.map(item => ({ ...item, type: 'LoRA' })),
...checkpointData.map(item => ({ ...item, type: 'Checkpoint' })) ...checkpointData.map(item => ({ ...item, type: 'Checkpoint' })),
...embeddingData.map(item => ({ ...item, type: 'Embedding' }))
]; ];
const data = { const data = {
@@ -458,9 +502,14 @@ class StatisticsManager {
name: item.name, name: item.name,
type: item.type type: item.type
})), })),
backgroundColor: allData.map(item => backgroundColor: allData.map(item => {
item.type === 'LoRA' ? 'oklch(68% 0.28 256 / 0.6)' : 'oklch(68% 0.28 200 / 0.6)' switch(item.type) {
) case 'LoRA': return 'oklch(68% 0.28 256 / 0.6)';
case 'Checkpoint': return 'oklch(68% 0.28 200 / 0.6)';
case 'Embedding': return 'oklch(68% 0.28 120 / 0.6)';
default: return 'oklch(68% 0.28 256 / 0.6)';
}
})
}] }]
}; };
@@ -502,6 +551,7 @@ class StatisticsManager {
renderTopModelsLists() { renderTopModelsLists() {
this.renderTopLorasList(); this.renderTopLorasList();
this.renderTopCheckpointsList(); this.renderTopCheckpointsList();
this.renderTopEmbeddingsList();
this.renderLargestModelsList(); this.renderLargestModelsList();
} }
@@ -555,17 +605,44 @@ class StatisticsManager {
`).join(''); `).join('');
} }
renderTopEmbeddingsList() {
const container = document.getElementById('topEmbeddingsList');
if (!container || !this.data.usage?.top_embeddings) return;
const topEmbeddings = this.data.usage.top_embeddings;
if (topEmbeddings.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
return;
}
container.innerHTML = topEmbeddings.map(embedding => `
<div class="model-item">
<img src="${embedding.preview_url || '/loras_static/images/no-preview.png'}"
alt="${embedding.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${embedding.name}">${embedding.name}</div>
<div class="model-meta">${embedding.base_model}${embedding.folder}</div>
</div>
<div class="model-usage">${embedding.usage_count}</div>
</div>
`).join('');
}
renderLargestModelsList() { renderLargestModelsList() {
const container = document.getElementById('largestModelsList'); const container = document.getElementById('largestModelsList');
if (!container || !this.data.storage) return; if (!container || !this.data.storage) return;
const loraModels = this.data.storage.loras || []; const loraModels = this.data.storage.loras || [];
const checkpointModels = this.data.storage.checkpoints || []; const checkpointModels = this.data.storage.checkpoints || [];
const embeddingModels = this.data.storage.embeddings || [];
// Combine and sort by size // Combine and sort by size
const allModels = [ const allModels = [
...loraModels.map(m => ({ ...m, type: 'LoRA' })), ...loraModels.map(m => ({ ...m, type: 'LoRA' })),
...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' })) ...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' })),
...embeddingModels.map(m => ({ ...m, type: 'Embedding' }))
].sort((a, b) => b.size - a.size).slice(0, 10); ].sort((a, b) => b.size - a.size).slice(0, 10);
if (allModels.length === 0) { if (allModels.length === 0) {

View File

@@ -141,7 +141,8 @@ export function migrateStorageItems() {
'recipes_search_prefs', 'recipes_search_prefs',
'checkpoints_search_prefs', 'checkpoints_search_prefs',
'show_update_notifications', 'show_update_notifications',
'last_update_check' 'last_update_check',
'dismissed_banners'
]; ];
// Migrate each known key // Migrate each known key

View File

@@ -82,6 +82,11 @@
</button> </button>
<div class="container"> <div class="container">
<!-- Banner component -->
<div id="banner-container" class="banner-container" style="display: none;">
<!-- Banners will be dynamically inserted here -->
</div>
{% if is_initializing %} {% if is_initializing %}
<!-- Show initialization component when initializing --> <!-- Show initialization component when initializing -->
{% include 'components/initialization.html' %} {% include 'components/initialization.html' %}

View File

@@ -25,7 +25,7 @@
<i class="fas fa-download"></i> Fetch Image <i class="fas fa-download"></i> Fetch Image
</button> </button>
</div> </div>
<div class="error-message" id="urlError"></div> <div class="error-message" id="importUrlError"></div>
</div> </div>
</div> </div>

View File

@@ -128,6 +128,23 @@
Set the default checkpoint root directory for downloads, imports and moves Set the default checkpoint root directory for downloads, imports and moves
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="defaultEmbeddingRoot">Default Embedding Root</label>
</div>
<div class="setting-control select-control">
<select id="defaultEmbeddingRoot" onchange="settingsManager.saveSelectSetting('defaultEmbeddingRoot', 'default_embedding_root')">
<option value="">No Default</option>
<!-- Options will be loaded dynamically -->
</select>
</div>
</div>
<div class="input-help">
Set the default embedding root directory for downloads, imports and moves
</div>
</div>
</div> </div>
<!-- Default Path Customization Section --> <!-- Default Path Customization Section -->

View File

@@ -98,6 +98,14 @@
</div> </div>
</div> </div>
<!-- Top Used Embeddings -->
<div class="list-container">
<h3><i class="fas fa-code"></i> Most Used Embeddings</h3>
<div class="model-list" id="topEmbeddingsList">
<!-- List will be populated by JavaScript -->
</div>
</div>
<!-- Usage Distribution Chart --> <!-- Usage Distribution Chart -->
<div class="chart-container full-width"> <div class="chart-container full-width">
<h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3> <h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3>

View File

@@ -4,39 +4,11 @@ import {
LORA_PATTERN, LORA_PATTERN,
collectActiveLorasFromChain, collectActiveLorasFromChain,
updateConnectedTriggerWords, updateConnectedTriggerWords,
chainCallback chainCallback,
mergeLoras
} from "./utils.js"; } from "./utils.js";
import { addLorasWidget } from "./loras_widget.js"; import { addLorasWidget } from "./loras_widget.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
// Reset pattern index before using
LORA_PATTERN.lastIndex = 0;
// Parse text input and create initial entries
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const modelStrength = Number(match[2]);
// Extract clip strength if provided, otherwise use model strength
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
// Find if this lora exists in the array data
const existingLora = lorasArr.find(l => l.name === name);
result.push({
name: name,
// Use existing strength if available, otherwise use input strength
strength: existingLora ? existingLora.strength : modelStrength,
active: existingLora ? existingLora.active : true,
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
});
}
return result;
}
app.registerExtension({ app.registerExtension({
name: "LoraManager.LoraLoader", name: "LoraManager.LoraLoader",

View File

@@ -4,39 +4,11 @@ import {
getActiveLorasFromNode, getActiveLorasFromNode,
collectActiveLorasFromChain, collectActiveLorasFromChain,
updateConnectedTriggerWords, updateConnectedTriggerWords,
chainCallback chainCallback,
mergeLoras
} from "./utils.js"; } from "./utils.js";
import { addLorasWidget } from "./loras_widget.js"; import { addLorasWidget } from "./loras_widget.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
// Reset pattern index before using
LORA_PATTERN.lastIndex = 0;
// Parse text input and create initial entries
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const modelStrength = Number(match[2]);
// Extract clip strength if provided, otherwise use model strength
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
// Find if this lora exists in the array data
const existingLora = lorasArr.find(l => l.name === name);
result.push({
name: name,
// Use existing strength if available, otherwise use input strength
strength: existingLora ? existingLora.strength : modelStrength,
active: existingLora ? existingLora.active : true,
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
});
}
return result;
}
app.registerExtension({ app.registerExtension({
name: "LoraManager.LoraStacker", name: "LoraManager.LoraStacker",

View File

@@ -1,5 +1,4 @@
import { app } from "../../scripts/app.js"; import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection } from "./loras_widget_components.js";
import { createToggle, createArrowButton, PreviewTooltip } from "./loras_widget_components.js";
import { import {
parseLoraValue, parseLoraValue,
formatLoraValue, formatLoraValue,
@@ -11,7 +10,7 @@ import {
CONTAINER_PADDING, CONTAINER_PADDING,
EMPTY_CONTAINER_HEIGHT EMPTY_CONTAINER_HEIGHT
} from "./loras_widget_utils.js"; } from "./loras_widget_utils.js";
import { initDrag, createContextMenu, initHeaderDrag } from "./loras_widget_events.js"; import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKeyboardNavigation } from "./loras_widget_events.js";
export function addLorasWidget(node, name, opts, callback) { export function addLorasWidget(node, name, opts, callback) {
// Create container for loras // Create container for loras
@@ -42,6 +41,30 @@ export function addLorasWidget(node, name, opts, callback) {
// Create preview tooltip instance // Create preview tooltip instance
const previewTooltip = new PreviewTooltip(); const previewTooltip = new PreviewTooltip();
// Selection state - only one LoRA can be selected at a time
let selectedLora = null;
// Function to select a LoRA
const selectLora = (loraName) => {
selectedLora = loraName;
// Update visual feedback for all entries
container.querySelectorAll('.comfy-lora-entry').forEach(entry => {
const entryLoraName = entry.dataset.loraName;
updateEntrySelection(entry, entryLoraName === selectedLora);
});
};
// Add keyboard event listener to container
container.addEventListener('keydown', (e) => {
if (handleKeyboardNavigation(e, selectedLora, widget, renderLoras, selectLora)) {
e.stopPropagation();
}
});
// Make container focusable for keyboard events
container.tabIndex = 0;
container.style.outline = 'none';
// Function to render loras from data // Function to render loras from data
const renderLoras = (value, widget) => { const renderLoras = (value, widget) => {
// Clear existing content // Clear existing content
@@ -185,6 +208,26 @@ export function addLorasWidget(node, name, opts, callback) {
marginBottom: "4px", marginBottom: "4px",
}); });
// Store lora name and active state in dataset for selection
loraEl.dataset.loraName = name;
loraEl.dataset.active = active;
// Add click handler for selection
loraEl.addEventListener('click', (e) => {
// Skip if clicking on interactive elements
if (e.target.closest('.comfy-lora-toggle') ||
e.target.closest('input') ||
e.target.closest('.comfy-lora-arrow') ||
e.target.closest('.comfy-lora-drag-handle')) {
return;
}
e.preventDefault();
e.stopPropagation();
selectLora(name);
container.focus(); // Focus container for keyboard events
});
// Add double-click handler to toggle clip entry // Add double-click handler to toggle clip entry
loraEl.addEventListener('dblclick', (e) => { loraEl.addEventListener('dblclick', (e) => {
// Skip if clicking on toggle or strength control areas // Skip if clicking on toggle or strength control areas
@@ -220,6 +263,12 @@ export function addLorasWidget(node, name, opts, callback) {
} }
}); });
// Create drag handle for reordering
const dragHandle = createDragHandle();
// Initialize reorder drag functionality
initReorderDrag(dragHandle, name, widget, renderLoras);
// Create toggle for this lora // Create toggle for this lora
const toggle = createToggle(active, (newActive) => { const toggle = createToggle(active, (newActive) => {
// Update this lora's active state // Update this lora's active state
@@ -416,6 +465,7 @@ export function addLorasWidget(node, name, opts, callback) {
minWidth: "0", // Allow shrinking minWidth: "0", // Allow shrinking
}); });
leftSection.appendChild(dragHandle); // Add drag handle first
leftSection.appendChild(toggle); leftSection.appendChild(toggle);
leftSection.appendChild(nameEl); leftSection.appendChild(nameEl);
@@ -424,6 +474,9 @@ export function addLorasWidget(node, name, opts, callback) {
container.appendChild(loraEl); container.appendChild(loraEl);
// Update selection state
updateEntrySelection(loraEl, name === selectedLora);
// If expanded, show the clip entry // If expanded, show the clip entry
if (isExpanded) { if (isExpanded) {
totalVisibleEntries++; totalVisibleEntries++;
@@ -444,6 +497,10 @@ export function addLorasWidget(node, name, opts, callback) {
marginTop: "-2px" marginTop: "-2px"
}); });
// Store the same lora name in clip entry dataset
clipEl.dataset.loraName = name;
clipEl.dataset.active = active;
// Create clip name display // Create clip name display
const clipNameEl = document.createElement("div"); const clipNameEl = document.createElement("div");
clipNameEl.textContent = "[clip] " + name; clipNameEl.textContent = "[clip] " + name;
@@ -601,7 +658,7 @@ export function addLorasWidget(node, name, opts, callback) {
}); });
// Calculate height based on number of loras and fixed sizes // Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 10) * LORA_ENTRY_HEIGHT); const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 12) * LORA_ENTRY_HEIGHT);
updateWidgetHeight(container, calculatedHeight, defaultHeight, node); updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
}; };
@@ -685,6 +742,8 @@ export function addLorasWidget(node, name, opts, callback) {
widget.onRemove = () => { widget.onRemove = () => {
container.remove(); container.remove();
previewTooltip.cleanup(); previewTooltip.cleanup();
// Remove keyboard event listener
container.removeEventListener('keydown', handleKeyboardNavigation);
}; };
return { minWidth: 400, minHeight: defaultHeight, widget }; return { minWidth: 400, minHeight: defaultHeight, widget };

View File

@@ -78,6 +78,87 @@ export function createArrowButton(direction, onClick) {
return button; return button;
} }
// Function to create drag handle
export function createDragHandle() {
const handle = document.createElement("div");
handle.className = "comfy-lora-drag-handle";
handle.innerHTML = "≡";
handle.title = "Drag to reorder LoRA";
Object.assign(handle.style, {
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "grab",
userSelect: "none",
fontSize: "14px",
color: "rgba(226, 232, 240, 0.6)",
transition: "all 0.2s ease",
marginRight: "8px",
flexShrink: "0"
});
// Add hover effect
handle.onmouseenter = () => {
handle.style.color = "rgba(226, 232, 240, 0.9)";
handle.style.transform = "scale(1.1)";
};
handle.onmouseleave = () => {
handle.style.color = "rgba(226, 232, 240, 0.6)";
handle.style.transform = "scale(1)";
};
// Change cursor when dragging
handle.onmousedown = () => {
handle.style.cursor = "grabbing";
};
return handle;
}
// Function to create drop indicator
export function createDropIndicator() {
const indicator = document.createElement("div");
indicator.className = "comfy-lora-drop-indicator";
Object.assign(indicator.style, {
position: "absolute",
left: "0",
right: "0",
height: "3px",
backgroundColor: "rgba(66, 153, 225, 0.9)",
borderRadius: "2px",
opacity: "0",
transition: "opacity 0.2s ease",
boxShadow: "0 0 6px rgba(66, 153, 225, 0.8)",
zIndex: "10",
pointerEvents: "none"
});
return indicator;
}
// Function to update entry selection state
export function updateEntrySelection(entryEl, isSelected) {
const baseColor = entryEl.dataset.active === 'true' ?
"rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)";
const selectedColor = entryEl.dataset.active === 'true' ?
"rgba(66, 153, 225, 0.3)" : "rgba(66, 153, 225, 0.2)";
if (isSelected) {
entryEl.style.backgroundColor = selectedColor;
entryEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
entryEl.style.boxShadow = "0 0 0 1px rgba(66, 153, 225, 0.3)";
} else {
entryEl.style.backgroundColor = baseColor;
entryEl.style.border = "1px solid transparent";
entryEl.style.boxShadow = "none";
}
}
// Function to create menu item // Function to create menu item
export function createMenuItem(text, icon, onClick) { export function createMenuItem(text, icon, onClick) {
const menuItem = document.createElement('div'); const menuItem = document.createElement('div');

View File

@@ -1,6 +1,7 @@
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { createMenuItem } from "./loras_widget_components.js"; import { app } from "../../scripts/app.js";
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast } from "./loras_widget_utils.js"; import { createMenuItem, createDropIndicator } from "./loras_widget_components.js";
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast, moveLoraByDirection, getDropTargetIndex } from "./loras_widget_utils.js";
// Function to handle strength adjustment via dragging // Function to handle strength adjustment via dragging
export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false) { export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false) {
@@ -227,6 +228,223 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
}); });
} }
// Function to initialize drag-and-drop for reordering
export function initReorderDrag(dragHandle, loraName, widget, renderFunction) {
let isDragging = false;
let draggedElement = null;
let dropIndicator = null;
let container = null;
let scale = 1;
dragHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
isDragging = true;
draggedElement = dragHandle.closest('.comfy-lora-entry');
container = draggedElement.parentElement;
// Add dragging class and visual feedback
draggedElement.classList.add('comfy-lora-dragging');
draggedElement.style.opacity = '0.5';
draggedElement.style.transform = 'scale(0.98)';
// Create single drop indicator with absolute positioning
dropIndicator = createDropIndicator();
// Make container relatively positioned for absolute indicator
const originalPosition = container.style.position;
container.style.position = 'relative';
container.appendChild(dropIndicator);
// Store original position for cleanup
container._originalPosition = originalPosition;
// Add global cursor style
document.body.style.cursor = 'grabbing';
// Store workflow scale for accurate positioning
scale = app.canvas.ds.scale;
});
document.addEventListener('mousemove', (e) => {
if (!isDragging || !draggedElement || !dropIndicator) return;
const targetIndex = getDropTargetIndex(container, e.clientY);
const entries = container.querySelectorAll('.comfy-lora-entry, .comfy-lora-clip-entry');
if (targetIndex === 0) {
// Show at top
const firstEntry = entries[0];
if (firstEntry) {
const rect = firstEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
dropIndicator.style.opacity = '1';
}
} else if (targetIndex < entries.length) {
// Show between entries
const targetEntry = entries[targetIndex];
if (targetEntry) {
const rect = targetEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.top - containerRect.top - 2) / scale}px`;
dropIndicator.style.opacity = '1';
}
} else {
// Show at bottom
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
const rect = lastEntry.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
dropIndicator.style.top = `${(rect.bottom - containerRect.top + 2) / scale}px`;
dropIndicator.style.opacity = '1';
}
}
});
document.addEventListener('mouseup', (e) => {
if (!isDragging || !draggedElement) return;
const targetIndex = getDropTargetIndex(container, e.clientY);
// Get current LoRA data
const lorasData = parseLoraValue(widget.value);
const currentIndex = lorasData.findIndex(l => l.name === loraName);
if (currentIndex !== -1 && currentIndex !== targetIndex) {
// Calculate actual target index (excluding clip entries from count)
const loraEntries = container.querySelectorAll('.comfy-lora-entry');
let actualTargetIndex = targetIndex;
// Adjust target index if it's beyond the number of actual LoRA entries
if (actualTargetIndex > loraEntries.length) {
actualTargetIndex = loraEntries.length;
}
// Move the LoRA
const newLoras = [...lorasData];
const [moved] = newLoras.splice(currentIndex, 1);
newLoras.splice(actualTargetIndex > currentIndex ? actualTargetIndex - 1 : actualTargetIndex, 0, moved);
widget.value = formatLoraValue(newLoras);
if (widget.callback) {
widget.callback(widget.value);
}
// Re-render
if (renderFunction) {
renderFunction(widget.value, widget);
}
}
// Cleanup
isDragging = false;
if (draggedElement) {
draggedElement.classList.remove('comfy-lora-dragging');
draggedElement.style.opacity = '';
draggedElement.style.transform = '';
draggedElement = null;
}
if (dropIndicator && container) {
container.removeChild(dropIndicator);
// Restore original position
container.style.position = container._originalPosition || '';
delete container._originalPosition;
dropIndicator = null;
}
// Reset cursor
document.body.style.cursor = '';
container = null;
});
}
// Function to handle keyboard navigation
export function handleKeyboardNavigation(event, selectedLora, widget, renderFunction, selectLora) {
if (!selectedLora) return false;
const lorasData = parseLoraValue(widget.value);
let handled = false;
// Check for Ctrl/Cmd modifier for reordering
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
const newLorasUp = moveLoraByDirection(lorasData, selectedLora, 'up');
widget.value = formatLoraValue(newLorasUp);
if (widget.callback) widget.callback(widget.value);
if (renderFunction) renderFunction(widget.value, widget);
handled = true;
break;
case 'ArrowDown':
event.preventDefault();
const newLorasDown = moveLoraByDirection(lorasData, selectedLora, 'down');
widget.value = formatLoraValue(newLorasDown);
if (widget.callback) widget.callback(widget.value);
if (renderFunction) renderFunction(widget.value, widget);
handled = true;
break;
case 'Home':
event.preventDefault();
const newLorasTop = moveLoraByDirection(lorasData, selectedLora, 'top');
widget.value = formatLoraValue(newLorasTop);
if (widget.callback) widget.callback(widget.value);
if (renderFunction) renderFunction(widget.value, widget);
handled = true;
break;
case 'End':
event.preventDefault();
const newLorasBottom = moveLoraByDirection(lorasData, selectedLora, 'bottom');
widget.value = formatLoraValue(newLorasBottom);
if (widget.callback) widget.callback(widget.value);
if (renderFunction) renderFunction(widget.value, widget);
handled = true;
break;
}
} else {
// Normal navigation without Ctrl/Cmd
switch (event.key) {
case 'ArrowUp':
event.preventDefault();
const currentIndex = lorasData.findIndex(l => l.name === selectedLora);
if (currentIndex > 0) {
selectLora(lorasData[currentIndex - 1].name);
}
handled = true;
break;
case 'ArrowDown':
event.preventDefault();
const currentIndexDown = lorasData.findIndex(l => l.name === selectedLora);
if (currentIndexDown < lorasData.length - 1) {
selectLora(lorasData[currentIndexDown + 1].name);
}
handled = true;
break;
case 'Delete':
case 'Backspace':
event.preventDefault();
const filtered = lorasData.filter(l => l.name !== selectedLora);
widget.value = formatLoraValue(filtered);
if (widget.callback) widget.callback(widget.value);
if (renderFunction) renderFunction(widget.value, widget);
selectLora(null); // Clear selection
handled = true;
break;
}
}
return handled;
}
// Function to create context menu // Function to create context menu
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) { export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
// Hide preview tooltip first // Hide preview tooltip first
@@ -398,6 +616,94 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
} }
); );
// Move Up option with arrow up icon
const moveUpOption = createMenuItem(
'Move Up',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"></path></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
const lorasData = parseLoraValue(widget.value);
const newLoras = moveLoraByDirection(lorasData, loraName, 'up');
widget.value = formatLoraValue(newLoras);
if (widget.callback) {
widget.callback(widget.value);
}
if (renderFunction) {
renderFunction(widget.value, widget);
}
}
);
// Move Down option with arrow down icon
const moveDownOption = createMenuItem(
'Move Down',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"></path></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
const lorasData = parseLoraValue(widget.value);
const newLoras = moveLoraByDirection(lorasData, loraName, 'down');
widget.value = formatLoraValue(newLoras);
if (widget.callback) {
widget.callback(widget.value);
}
if (renderFunction) {
renderFunction(widget.value, widget);
}
}
);
// Move to Top option with chevrons up icon
const moveTopOption = createMenuItem(
'Move to Top',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 11l-5-5-5 5M17 18l-5-5-5 5"></path></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
const lorasData = parseLoraValue(widget.value);
const newLoras = moveLoraByDirection(lorasData, loraName, 'top');
widget.value = formatLoraValue(newLoras);
if (widget.callback) {
widget.callback(widget.value);
}
if (renderFunction) {
renderFunction(widget.value, widget);
}
}
);
// Move to Bottom option with chevrons down icon
const moveBottomOption = createMenuItem(
'Move to Bottom',
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 13l5 5 5-5M7 6l5 5 5-5"></path></svg>',
() => {
menu.remove();
document.removeEventListener('click', closeMenu);
const lorasData = parseLoraValue(widget.value);
const newLoras = moveLoraByDirection(lorasData, loraName, 'bottom');
widget.value = formatLoraValue(newLoras);
if (widget.callback) {
widget.callback(widget.value);
}
if (renderFunction) {
renderFunction(widget.value, widget);
}
}
);
// Add separator // Add separator
const separator1 = document.createElement('div'); const separator1 = document.createElement('div');
Object.assign(separator1.style, { Object.assign(separator1.style, {
@@ -412,9 +718,21 @@ export function createContextMenu(x, y, loraName, widget, previewTooltip, render
borderTop: '1px solid rgba(255, 255, 255, 0.1)', borderTop: '1px solid rgba(255, 255, 255, 0.1)',
}); });
// Add separator for order options
const orderSeparator = document.createElement('div');
Object.assign(orderSeparator.style, {
margin: '4px 0',
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
});
menu.appendChild(viewOnCivitaiOption); menu.appendChild(viewOnCivitaiOption);
menu.appendChild(deleteOption); menu.appendChild(deleteOption);
menu.appendChild(separator1); menu.appendChild(separator1);
menu.appendChild(moveUpOption);
menu.appendChild(moveDownOption);
menu.appendChild(moveTopOption);
menu.appendChild(moveBottomOption);
menu.appendChild(orderSeparator);
menu.appendChild(copyNotesOption); menu.appendChild(copyNotesOption);
menu.appendChild(copyTriggerWordsOption); menu.appendChild(copyTriggerWordsOption);
menu.appendChild(separator2); menu.appendChild(separator2);

View File

@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
// Fixed sizes for component calculations // Fixed sizes for component calculations
export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry export const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
export const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry export const CLIP_ENTRY_HEIGHT = 40; // Height of a clip entry
export const HEADER_HEIGHT = 40; // Height of the header section export const HEADER_HEIGHT = 32; // Height of the header section
export const CONTAINER_PADDING = 12; // Top and bottom padding export const CONTAINER_PADDING = 12; // Top and bottom padding
export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present export const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
@@ -164,3 +164,71 @@ export function showToast(message, type = 'info') {
} }
} }
} }
/**
* Move a LoRA to a new position in the array
* @param {Array} loras - Array of LoRA objects
* @param {number} fromIndex - Current index of the LoRA
* @param {number} toIndex - Target index for the LoRA
* @returns {Array} - New array with LoRA moved
*/
export function moveLoraInArray(loras, fromIndex, toIndex) {
const newLoras = [...loras];
const [removed] = newLoras.splice(fromIndex, 1);
newLoras.splice(toIndex, 0, removed);
return newLoras;
}
/**
* Move a LoRA by name to a specific position
* @param {Array} loras - Array of LoRA objects
* @param {string} loraName - Name of the LoRA to move
* @param {string} direction - 'up', 'down', 'top', 'bottom'
* @returns {Array} - New array with LoRA moved
*/
export function moveLoraByDirection(loras, loraName, direction) {
const currentIndex = loras.findIndex(l => l.name === loraName);
if (currentIndex === -1) return loras;
let newIndex;
switch (direction) {
case 'up':
newIndex = Math.max(0, currentIndex - 1);
break;
case 'down':
newIndex = Math.min(loras.length - 1, currentIndex + 1);
break;
case 'top':
newIndex = 0;
break;
case 'bottom':
newIndex = loras.length - 1;
break;
default:
return loras;
}
if (newIndex === currentIndex) return loras;
return moveLoraInArray(loras, currentIndex, newIndex);
}
/**
* Get the drop target index based on mouse position
* @param {HTMLElement} container - The container element
* @param {number} clientY - Mouse Y position
* @returns {number} - Target index for dropping
*/
export function getDropTargetIndex(container, clientY) {
const entries = container.querySelectorAll('.comfy-lora-entry');
let targetIndex = entries.length;
for (let i = 0; i < entries.length; i++) {
const rect = entries[i].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
targetIndex = i;
break;
}
}
return targetIndex;
}

View File

@@ -183,4 +183,47 @@ export function updateConnectedTriggerWords(node, loraNames) {
}) })
}).catch(err => console.error("Error fetching trigger words:", err)); }).catch(err => console.error("Error fetching trigger words:", err));
} }
}
export function mergeLoras(lorasText, lorasArr) {
// Parse lorasText into a map: name -> {strength, clipStrength}
const parsedLoras = {};
let match;
LORA_PATTERN.lastIndex = 0;
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const modelStrength = Number(match[2]);
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
parsedLoras[name] = { strength: modelStrength, clipStrength };
}
// Build result array in the order of lorasArr
const result = [];
const usedNames = new Set();
for (const lora of lorasArr) {
if (parsedLoras[lora.name]) {
result.push({
name: lora.name,
strength: lora.strength !== undefined ? lora.strength : parsedLoras[lora.name].strength,
active: lora.active !== undefined ? lora.active : true,
clipStrength: lora.clipStrength !== undefined ? lora.clipStrength : parsedLoras[lora.name].clipStrength,
});
usedNames.add(lora.name);
}
}
// Add any new loras from lorasText that are not in lorasArr, in their text order
for (const name in parsedLoras) {
if (!usedNames.has(name)) {
result.push({
name,
strength: parsedLoras[name].strength,
active: true,
clipStrength: parsedLoras[name].clipStrength,
});
}
}
return result;
} }

View File

@@ -2,41 +2,12 @@ import { app } from "../../scripts/app.js";
import { import {
LORA_PATTERN, LORA_PATTERN,
getActiveLorasFromNode, getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords, updateConnectedTriggerWords,
chainCallback chainCallback,
mergeLoras
} from "./utils.js"; } from "./utils.js";
import { addLorasWidget } from "./loras_widget.js"; import { addLorasWidget } from "./loras_widget.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
// Reset pattern index before using
LORA_PATTERN.lastIndex = 0;
// Parse text input and create initial entries
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const modelStrength = Number(match[2]);
// Extract clip strength if provided, otherwise use model strength
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
// Find if this lora exists in the array data
const existingLora = lorasArr.find(l => l.name === name);
result.push({
name: name,
// Use existing strength if available, otherwise use input strength
strength: existingLora ? existingLora.strength : modelStrength,
active: existingLora ? existingLora.active : true,
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
});
}
return result;
}
app.registerExtension({ app.registerExtension({
name: "LoraManager.WanVideoLoraSelect", name: "LoraManager.WanVideoLoraSelect",