Compare commits

...

14 Commits

Author SHA1 Message Date
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
30 changed files with 1023 additions and 202 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

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

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

@@ -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.23"
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>