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
### 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
* **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

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:
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
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
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!
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):
# 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, *args, **kwargs):
hidden_inputs = kwargs.get('hidden_inputs', None)
# Only collect metadata when calling the main function of nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'):
try:
# Get the current prompt_id from the registry
registry = MetadataRegistry()
# We now have prompt_id directly from the function parameters
if prompt_id is not None:
# Get node class type
class_type = obj.__class__.__name__
# Use the passed unique_id parameter instead of trying to extract it
node_id = unique_id
# Record inputs before execution
if node_id is not None:
registry.record_node_execution(node_id, class_type, input_data_all, None)
except Exception as e:
print(f"Error collecting metadata (pre-execution): {str(e)}")
# Execute the original async function with ALL parameters in the correct order
results = await original_map_node_over_list(prompt_id, unique_id, obj, input_data_all, func, allow_interrupt, execution_block_cb, pre_execute_cb)
# 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, *args, **kwargs
)
# After execution, collect outputs for relevant nodes
if func == obj.FUNCTION and hasattr(obj, '__class__'):
try:
# Get the current prompt_id from the registry
registry = MetadataRegistry()
if prompt_id is not None:
# Get node class type
class_type = obj.__class__.__name__
# Use the passed unique_id parameter
node_id = unique_id
# Record outputs after execution
if node_id is not None:
registry.update_node_execution(node_id, class_type, results)
except Exception as e:
print(f"Error collecting metadata (post-execution): {str(e)}")
return results
# Also hook the execute function to track the current prompt_id
original_execute = execution.execute

View File

@@ -119,10 +119,10 @@ class RecipeMetadataParser(ABC):
# Check if exists locally
if recipe_scanner and lora_entry['hash']:
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:
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['localPath'] = local_path
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)
if 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"):
# Initialize lora entry
lora_entry = {
'id': resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""),
'version': resource.get("modelVersionName", resource.get("versionName", "")),
'type': resource.get("type", "lora"),
'weight': round(float(resource.get("weight", 1.0)), 2),
'existsLocally': False,

View File

@@ -55,7 +55,7 @@ class RecipeFormatParser(RecipeMetadataParser):
# Check if this LoRA exists locally by SHA256 hash
if lora.get('hash') and recipe_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:
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)

View File

@@ -408,7 +408,7 @@ class BaseModelRoutes(ABC):
group["models"].append(await self.service.format_response(model))
# 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:
main_path = self.service.get_path_by_hash(hash_val)
if main_path and main_path not in paths:

View File

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

View File

@@ -20,6 +20,7 @@ class StatsRoutes:
def __init__(self):
self.lora_scanner = None
self.checkpoint_scanner = None
self.embedding_scanner = None
self.usage_stats = None
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),
@@ -30,6 +31,7 @@ class StatsRoutes:
"""Initialize services from ServiceRegistry"""
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
self.embedding_scanner = await ServiceRegistry.get_embedding_scanner()
self.usage_stats = UsageStats()
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)
)
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')
rendered = template.render(
@@ -85,21 +92,29 @@ class StatsRoutes:
checkpoint_count = len(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
usage_data = await self.usage_stats.get_stats()
return web.json_response({
'success': True,
'data': {
'total_models': lora_count + checkpoint_count,
'total_models': lora_count + checkpoint_count + embedding_count,
'lora_count': lora_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,
'checkpoint_size': checkpoint_size,
'embedding_size': embedding_size,
'total_generations': usage_data.get('total_executions', 0),
'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
lora_cache = await self.lora_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
lora_map = {lora['sha256']: lora for lora in lora_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
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_embeddings = self._get_top_used_models(usage_data.get('embeddings', {}), embedding_map, 10)
# Prepare usage timeline (last 30 days)
timeline = self._get_usage_timeline(usage_data, 30)
@@ -138,6 +156,7 @@ class StatsRoutes:
'data': {
'top_loras': top_loras,
'top_checkpoints': top_checkpoints,
'top_embeddings': top_embeddings,
'usage_timeline': timeline,
'total_executions': usage_data.get('total_executions', 0)
}
@@ -158,16 +177,19 @@ class StatsRoutes:
# Get model data
lora_cache = await self.lora_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
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)
embedding_base_models = Counter(emb.get('base_model', 'Unknown') for emb in embedding_cache.raw_data)
return web.json_response({
'success': True,
'data': {
'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
lora_cache = await self.lora_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
all_tags = []
@@ -193,6 +216,8 @@ class StatsRoutes:
all_tags.extend(lora.get('tags', []))
for cp in checkpoint_cache.raw_data:
all_tags.extend(cp.get('tags', []))
for emb in embedding_cache.raw_data:
all_tags.extend(emb.get('tags', []))
tag_counts = Counter(all_tags)
@@ -225,6 +250,7 @@ class StatsRoutes:
# Get model data
lora_cache = await self.lora_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
lora_storage = []
@@ -255,15 +281,31 @@ class StatsRoutes:
'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
lora_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({
'success': True,
'data': {
'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
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
embedding_cache = await self.embedding_scanner.get_cached_data()
insights = []
# Calculate unused models
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_embeddings = self._count_unused_models(embedding_cache.raw_data, usage_data.get('embeddings', {}))
total_loras = len(lora_cache.raw_data)
total_checkpoints = len(checkpoint_cache.raw_data)
total_embeddings = len(embedding_cache.raw_data)
if total_loras > 0:
unused_lora_percent = (unused_loras / total_loras) * 100
@@ -315,9 +360,20 @@ class StatsRoutes:
'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
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
insights.append({
@@ -390,6 +446,7 @@ class StatsRoutes:
lora_usage = 0
checkpoint_usage = 0
embedding_usage = 0
# Count usage for this date
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:
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({
'date': date_str,
'lora_usage': lora_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

View File

@@ -4,6 +4,9 @@ import aiohttp
import logging
import toml
import git
import zipfile
import shutil
import tempfile
from datetime import datetime
from aiohttp import web
from typing import Dict, List
@@ -101,34 +104,36 @@ class UpdateRoutes:
@staticmethod
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:
# Parse request body
body = await request.json() if request.has_body else {}
nightly = body.get('nightly', False)
# Get current plugin directory
current_dir = os.path.dirname(os.path.abspath(__file__))
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_backup = None
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
settings_backup = f.read()
logger.info("Backed up settings.json")
# Perform Git update
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
# Restore settings.json if we backed it up
git_folder = os.path.join(plugin_root, '.git')
if os.path.exists(git_folder):
# Git update
success, new_version = await UpdateRoutes._perform_git_update(plugin_root, nightly)
else:
# Fallback: Download ZIP and replace files
success, new_version = await UpdateRoutes._download_and_replace_zip(plugin_root)
if settings_backup and success:
with open(settings_path, 'w', encoding='utf-8') as f:
f.write(settings_backup)
logger.info("Restored settings.json")
if success:
return web.json_response({
'success': True,
@@ -138,15 +143,86 @@ class UpdateRoutes:
else:
return web.json_response({
'success': False,
'error': 'Failed to complete Git update'
'error': 'Failed to complete update'
})
except Exception as e:
logger.error(f"Failed to perform update: {e}", exc_info=True)
return web.json_response({
'success': False,
'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
async def _get_nightly_version() -> tuple[str, List[str]]:
@@ -291,7 +367,7 @@ class UpdateRoutes:
git_info = {
'commit_hash': 'unknown',
'short_hash': 'unknown',
'short_hash': 'stable',
'branch': 'unknown',
'commit_date': 'unknown'
}

View File

@@ -9,6 +9,7 @@ class SettingsManager:
def __init__(self):
self.settings_file = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'settings.json')
self.settings = self._load_settings()
self._auto_set_default_roots()
self._check_environment_variables()
def _load_settings(self) -> Dict[str, Any]:
@@ -21,6 +22,28 @@ class SettingsManager:
logger.error(f"Error loading settings: {e}")
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:
"""Check for environment variables and update settings if needed"""
env_api_key = os.environ.get('CIVITAI_API_KEY')

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.8.21"
version = "0.8.24"
license = {file = "LICENSE"}
dependencies = [
"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;
}
/* 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 */
.model-card,
.model-card *,

View File

@@ -183,7 +183,11 @@
outline: none;
}
.edit-file-name-btn {
/* 合并编辑按钮样式 */
.edit-model-name-btn,
.edit-file-name-btn,
.edit-base-model-btn,
.edit-model-description-btn {
background: transparent;
border: none;
color: var(--text-color);
@@ -195,17 +199,28 @@
margin-left: var(--space-1);
}
.edit-model-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;
}
.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;
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);
}
@@ -234,32 +249,6 @@
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 {
width: 100%;
padding: 3px 5px;
@@ -316,32 +305,6 @@
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 */
.showcase-tabs {
display: flex;

View File

@@ -56,6 +56,24 @@
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 {
background: var(--card-bg);

View File

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

View File

@@ -454,7 +454,7 @@ export function createModelCard(model, modelType) {
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs}>
`<video ${videoAttrs} style="pointer-events: none;">
<source src="${versionedPreviewUrl}" type="video/mp4">
</video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}">`
@@ -482,6 +482,7 @@ export function createModelCard(model, modelType) {
<div class="card-footer">
<div class="model-info">
<span class="model-name">${model.model_name}</span>
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''}
</div>
<div class="card-actions">
<i class="fas fa-folder-open"

View File

@@ -1,3 +1,5 @@
import { showToast } from '../../utils/uiHelpers.js';
/**
* ModelDescription.js
* 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,
loadExampleImages
} from './showcase/ShowcaseView.js';
import { setupTabSwitching } from './ModelDescription.js';
import { setupTabSwitching, setupModelDescriptionEditing } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
@@ -33,7 +33,6 @@ export function showModelModal(model, modelType) {
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Generate model type specific content
// const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : '';
let typeSpecificContent;
if (modelType === 'loras') {
typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
@@ -211,6 +210,7 @@ export function showModelModal(model, modelType) {
setupModelNameEditing(model.file_path);
setupBaseModelEditing(model.file_path);
setupFileNameEditing(model.file_path);
setupModelDescriptionEditing(model.file_path, model.modelDescription || '');
setupEventHandlers(model.file_path);
// LoRA specific setup

View File

@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { bannerService } from './managers/BannerService.js';
import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { migrateStorageItems } from './utils/storageHelpers.js';
@@ -27,6 +28,7 @@ export class AppCore {
state.loadingManager = new LoadingManager();
modalManager.initialize();
updateService.initialize();
bannerService.initialize();
window.modalManager = modalManager;
window.settingsManager = settingsManager;
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 { showToast } from '../utils/uiHelpers.js';
import { LoadingManager } from './LoadingManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
import { ImportStepManager } from './import/ImportStepManager.js';
import { ImageProcessor } from './import/ImageProcessor.js';
import { RecipeDataManager } from './import/RecipeDataManager.js';
@@ -86,8 +84,8 @@ export class ImportManager {
const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = '';
const urlError = document.getElementById('urlError');
if (urlError) urlError.textContent = '';
const importUrlError = document.getElementById('importUrlError');
if (importUrlError) importUrlError.textContent = '';
const recipeName = document.getElementById('recipeName');
if (recipeName) recipeName.value = '';
@@ -167,10 +165,10 @@ export class ImportManager {
// Clear error messages
const uploadError = document.getElementById('uploadError');
const urlError = document.getElementById('urlError');
const importUrlError = document.getElementById('importUrlError');
if (uploadError) uploadError.textContent = '';
if (urlError) urlError.textContent = '';
if (importUrlError) importUrlError.textContent = '';
}
handleImageUpload(event) {
@@ -224,8 +222,8 @@ export class ImportManager {
const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = '';
const urlError = document.getElementById('urlError');
if (urlError) urlError.textContent = '';
const importUrlError = document.getElementById('importUrlError');
if (importUrlError) importUrlError.textContent = '';
}
backToDetails() {

View File

@@ -90,7 +90,7 @@ class MoveManager {
).join('');
// 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)) {
this.loraRootSelect.value = defaultRoot;
}

View File

@@ -15,29 +15,39 @@ export class SettingsManager {
// Ensure settings are loaded from localStorage
this.loadSettingsFromStorage();
// Sync settings to backend if needed
this.syncSettingsToBackendIfNeeded();
this.initialize();
}
loadSettingsFromStorage() {
// Get saved settings from localStorage
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
if (savedSettings) {
state.global.settings = { ...state.global.settings, ...savedSettings };
}
// Initialize default values for new settings if they don't exist
if (state.global.settings.compactMode === undefined) {
state.global.settings.compactMode = false;
}
// Set default for optimizeExampleImages if undefined
if (state.global.settings.optimizeExampleImages === undefined) {
state.global.settings.optimizeExampleImages = true;
}
// Set default for cardInfoDisplay if undefined
if (state.global.settings.cardInfoDisplay === undefined) {
state.global.settings.cardInfoDisplay = 'always';
@@ -67,6 +77,55 @@ export class SettingsManager {
if (state.global.settings.base_model_path_mappings === undefined) {
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() {
@@ -151,8 +210,9 @@ export class SettingsManager {
// Load default checkpoint root
await this.loadCheckpointRoots();
// Backend settings are loaded from the template directly
// Load default embedding root
await this.loadEmbeddingRoots();
}
async loadLoraRoots() {
@@ -185,7 +245,7 @@ export class SettingsManager {
});
// Set selected value from settings
const defaultRoot = state.global.settings.default_loras_root || '';
const defaultRoot = state.global.settings.default_lora_root || '';
defaultLoraRootSelect.value = defaultRoot;
} 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() {
const mappingsContainer = document.getElementById('baseModelMappingsContainer');
if (!mappingsContainer) return;
@@ -460,7 +559,7 @@ export class SettingsManager {
try {
// 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 = {};
payload[settingKey] = value;
@@ -505,9 +604,11 @@ export class SettingsManager {
// Update frontend state
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') {
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') {
state.global.settings.displayDensity = value;
@@ -528,7 +629,7 @@ export class SettingsManager {
try {
// 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 = {};
payload[settingKey] = value;
@@ -583,10 +684,7 @@ export class SettingsManager {
// Update state
state.global.settings[settingKey] = value;
// Save to localStorage if appropriate
if (!settingKey.includes('api_key')) { // Don't store API keys in localStorage for security
setStorageItem('settings', state.global.settings);
}
setStorageItem('settings', state.global.settings);
// For backend settings, make API call
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() {
// Apply blur setting to existing content
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>
Successfully updated to ${newVersion}!
<br><br>
<small style="opacity: 0.8;">
Please restart ComfyUI to complete the update process.
</small>
<div style="opacity: 0.95; color: var(--lora-error); font-size: 1em;">
Please restart ComfyUI or LoRA Manager to apply update.<br>
Make sure to reload your browser for both LoRA Manager and ComfyUI.
</div>
</div>
`;
}
@@ -370,10 +371,10 @@ export class UpdateService {
this.updateAvailable = false;
// Refresh the modal content
setTimeout(() => {
this.updateModalContent();
this.showUpdateProgress(false);
}, 2000);
// setTimeout(() => {
// this.updateModalContent();
// this.showUpdateProgress(false);
// }, 2000);
}
// Simple markdown parser for changelog items

View File

@@ -112,7 +112,7 @@ export class FolderBrowser {
).join('');
// 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)) {
loraRoot.value = defaultRoot;
}

View File

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

View File

@@ -150,6 +150,12 @@ class StatisticsManager {
value: this.data.collection.checkpoint_count,
label: 'Checkpoints',
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;
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;
return totalModels > 0 ? (usedModels / totalModels) * 100 : 0;
@@ -233,12 +241,17 @@ class StatisticsManager {
if (!ctx || !this.data.collection) return;
const data = {
labels: ['LoRAs', 'Checkpoints'],
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
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: [
'oklch(68% 0.28 256)',
'oklch(68% 0.28 200)'
'oklch(68% 0.28 200)',
'oklch(68% 0.28 120)'
],
borderWidth: 2,
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color')
@@ -266,8 +279,13 @@ class StatisticsManager {
const loraData = this.data.baseModels.loras;
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 = {
labels: Array.from(allModels),
@@ -281,6 +299,11 @@ class StatisticsManager {
label: 'Checkpoints',
data: Array.from(allModels).map(model => checkpointData[model] || 0),
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)',
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
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 topCheckpoints = this.data.usage.top_checkpoints || [];
const topEmbeddings = this.data.usage.top_embeddings || [];
// Combine and sort all models by usage
const allModels = [
...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);
const data = {
@@ -377,9 +409,14 @@ class StatisticsManager {
datasets: [{
label: 'Usage Count',
data: allModels.map(model => model.usage_count),
backgroundColor: allModels.map(model =>
model.type === 'LoRA' ? 'oklch(68% 0.28 256)' : 'oklch(68% 0.28 200)'
)
backgroundColor: allModels.map(model => {
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;
const data = {
labels: ['LoRAs', 'Checkpoints'],
labels: ['LoRAs', 'Checkpoints', 'Embeddings'],
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: [
'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 checkpointData = this.data.storage.checkpoints || [];
const embeddingData = this.data.storage.embeddings || [];
const allData = [
...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 = {
@@ -458,9 +502,14 @@ class StatisticsManager {
name: item.name,
type: item.type
})),
backgroundColor: allData.map(item =>
item.type === 'LoRA' ? 'oklch(68% 0.28 256 / 0.6)' : 'oklch(68% 0.28 200 / 0.6)'
)
backgroundColor: allData.map(item => {
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() {
this.renderTopLorasList();
this.renderTopCheckpointsList();
this.renderTopEmbeddingsList();
this.renderLargestModelsList();
}
@@ -555,17 +605,44 @@ class StatisticsManager {
`).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() {
const container = document.getElementById('largestModelsList');
if (!container || !this.data.storage) return;
const loraModels = this.data.storage.loras || [];
const checkpointModels = this.data.storage.checkpoints || [];
const embeddingModels = this.data.storage.embeddings || [];
// Combine and sort by size
const allModels = [
...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);
if (allModels.length === 0) {

View File

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

View File

@@ -82,6 +82,11 @@
</button>
<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 %}
<!-- Show initialization component when initializing -->
{% include 'components/initialization.html' %}

View File

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

View File

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

View File

@@ -98,6 +98,14 @@
</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 -->
<div class="chart-container full-width">
<h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3>

View File

@@ -4,39 +4,11 @@ import {
LORA_PATTERN,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback
chainCallback,
mergeLoras
} from "./utils.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({
name: "LoraManager.LoraLoader",

View File

@@ -4,39 +4,11 @@ import {
getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback
chainCallback,
mergeLoras
} from "./utils.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({
name: "LoraManager.LoraStacker",

View File

@@ -1,5 +1,4 @@
import { app } from "../../scripts/app.js";
import { createToggle, createArrowButton, PreviewTooltip } from "./loras_widget_components.js";
import { createToggle, createArrowButton, PreviewTooltip, createDragHandle, updateEntrySelection } from "./loras_widget_components.js";
import {
parseLoraValue,
formatLoraValue,
@@ -11,7 +10,7 @@ import {
CONTAINER_PADDING,
EMPTY_CONTAINER_HEIGHT
} 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) {
// Create container for loras
@@ -42,6 +41,30 @@ export function addLorasWidget(node, name, opts, callback) {
// Create preview tooltip instance
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
const renderLoras = (value, widget) => {
// Clear existing content
@@ -185,6 +208,26 @@ export function addLorasWidget(node, name, opts, callback) {
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
loraEl.addEventListener('dblclick', (e) => {
// 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
const toggle = createToggle(active, (newActive) => {
// Update this lora's active state
@@ -416,6 +465,7 @@ export function addLorasWidget(node, name, opts, callback) {
minWidth: "0", // Allow shrinking
});
leftSection.appendChild(dragHandle); // Add drag handle first
leftSection.appendChild(toggle);
leftSection.appendChild(nameEl);
@@ -424,6 +474,9 @@ export function addLorasWidget(node, name, opts, callback) {
container.appendChild(loraEl);
// Update selection state
updateEntrySelection(loraEl, name === selectedLora);
// If expanded, show the clip entry
if (isExpanded) {
totalVisibleEntries++;
@@ -444,6 +497,10 @@ export function addLorasWidget(node, name, opts, callback) {
marginTop: "-2px"
});
// Store the same lora name in clip entry dataset
clipEl.dataset.loraName = name;
clipEl.dataset.active = active;
// Create clip name display
const clipNameEl = document.createElement("div");
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
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);
};
@@ -685,6 +742,8 @@ export function addLorasWidget(node, name, opts, callback) {
widget.onRemove = () => {
container.remove();
previewTooltip.cleanup();
// Remove keyboard event listener
container.removeEventListener('keydown', handleKeyboardNavigation);
};
return { minWidth: 400, minHeight: defaultHeight, widget };

View File

@@ -78,6 +78,87 @@ export function createArrowButton(direction, onClick) {
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
export function createMenuItem(text, icon, onClick) {
const menuItem = document.createElement('div');

View File

@@ -1,6 +1,7 @@
import { api } from "../../scripts/api.js";
import { createMenuItem } from "./loras_widget_components.js";
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast } from "./loras_widget_utils.js";
import { app } from "../../scripts/app.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
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
export function createContextMenu(x, y, loraName, widget, previewTooltip, renderFunction) {
// 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
const separator1 = document.createElement('div');
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)',
});
// 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(deleteOption);
menu.appendChild(separator1);
menu.appendChild(moveUpOption);
menu.appendChild(moveDownOption);
menu.appendChild(moveTopOption);
menu.appendChild(moveBottomOption);
menu.appendChild(orderSeparator);
menu.appendChild(copyNotesOption);
menu.appendChild(copyTriggerWordsOption);
menu.appendChild(separator2);

View File

@@ -3,7 +3,7 @@ import { app } from "../../scripts/app.js";
// Fixed sizes for component calculations
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 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 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));
}
}
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 {
LORA_PATTERN,
getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords,
chainCallback
chainCallback,
mergeLoras
} from "./utils.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({
name: "LoraManager.WanVideoLoraSelect",