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
42 changed files with 1658 additions and 322 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

@@ -183,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);
@@ -195,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);
} }
@@ -234,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;
@@ -316,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

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

@@ -454,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}">`
@@ -482,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

@@ -6,7 +6,7 @@ import {
scrollToTop, 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,
@@ -33,7 +33,6 @@ export function showModelModal(model, modelType) {
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Generate model type specific content // Generate model type specific content
// const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : '';
let typeSpecificContent; let typeSpecificContent;
if (modelType === 'loras') { if (modelType === 'loras') {
typeSpecificContent = renderLoraSpecificContent(model, escapedWords); typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
@@ -211,6 +210,7 @@ export function showModelModal(model, modelType) {
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

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",