Compare commits

..

22 Commits

Author SHA1 Message Date
Will Miao
e1e6e4f3dc feat: update version to 0.8.12 and enhance release notes in README 2025-05-03 17:21:21 +08:00
pixelpaws
fba2853773 Merge pull request #157 from willmiao/dev
Dev
2025-05-03 17:07:48 +08:00
Will Miao
48df7e1078 Refactor code structure for improved readability and maintainability 2025-05-03 17:06:57 +08:00
Will Miao
235dcd5fa6 feat: enhance metadata panel visibility handling in showcase view 2025-05-03 16:41:47 +08:00
Will Miao
2027db7411 feat: refactor model deletion functionality with confirmation modal 2025-05-03 16:31:17 +08:00
Will Miao
611dd33c75 feat: add model exclution functionality frontend 2025-05-03 16:14:09 +08:00
Will Miao
ec1c92a714 feat: add model exclusion functionality with new API endpoints and metadata handling 2025-05-02 22:36:50 +08:00
Will Miao
6ac78156ac feat: comment out "View Details" option in context menus for checkpoints and recipes 2025-05-02 20:59:06 +08:00
pixelpaws
e94b74e92d Merge pull request #156 from willmiao/dev
Dev
2025-05-02 19:35:25 +08:00
Will Miao
2bbec47f63 feat: update WeChat and Alipay QR code to use WebP format for improved performance 2025-05-02 19:34:40 +08:00
pixelpaws
b5ddf4c953 Merge pull request #155 from Rauks/add-base-models
feat: Add "HiDream" and "LTXV" base models
2025-05-02 19:17:18 +08:00
Will Miao
44be75aeef feat: add WeChat and Alipay support section with QR code toggle functionality 2025-05-02 19:15:54 +08:00
Karl Woditsch
2c03759b5d feat: Add "HiDream" and "LTXV" base models 2025-05-02 11:56:10 +02:00
Will Miao
2e3da03723 feat: update metadata panel visibility logic to show on media hover and add rendering calculations 2025-05-02 17:53:15 +08:00
Will Miao
6e96fbcda7 feat: enhance alphabet bar with toggle functionality and visual indicators 2025-05-01 20:50:31 +08:00
Will Miao
d1fd5b7f27 feat: implement alphabet filtering feature with letter counts and UI components v1 2025-05-01 20:07:12 +08:00
Will Miao
9dbcc105e7 feat: add model metadata refresh functionality and enhance download progress tracking. https://github.com/willmiao/ComfyUI-Lora-Manager/issues/151 2025-05-01 18:57:29 +08:00
Will Miao
5cd5a82ddc feat: add creator information to model metadata handling 2025-05-01 15:56:57 +08:00
Will Miao
88c1892dc9 feat: enhance model metadata fetching to include creator information 2025-05-01 15:30:05 +08:00
Will Miao
3c1b181675 fix: enhance version comparison by ignoring suffixes in semantic version strings 2025-05-01 07:47:09 +08:00
Will Miao
6777dc16ca fix: update version to 0.8.11-bugfix in pyproject.toml 2025-05-01 06:19:03 +08:00
Will Miao
3833647dfe refactor: remove unused tkinter imports from misc_routes.py. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/150 2025-05-01 06:06:20 +08:00
45 changed files with 1674 additions and 249 deletions

View File

@@ -20,6 +20,12 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes
### v0.8.12
* **Enhanced Model Discovery** - Added alphabetical navigation bar to LoRAs page for faster browsing through large collections
* **Optimized Example Images** - Improved download logic to automatically refresh stale metadata before fetching example images
* **Model Exclusion System** - New right-click option to exclude specific LoRAs or checkpoints from management
* **Improved Showcase Experience** - Enhanced interaction in LoRA and checkpoint showcase areas for better usability
### v0.8.11
* **Offline Image Support** - Added functionality to download and save all model example images locally, ensuring access even when offline or if images are removed from CivitAI or the site is down
* **Resilient Download System** - Implemented pause/resume capability with checkpoint recovery that persists through restarts or unexpected exits
@@ -283,6 +289,8 @@ If you find this project helpful, consider supporting its development:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/pixelpawsai)
WeChat: [Click to view QR code](https://raw.githubusercontent.com/willmiao/ComfyUI-Lora-Manager/main/static/images/wechat-qr.webp)
## 💬 Community
Join our Discord community for support, discussions, and updates:

View File

@@ -43,6 +43,7 @@ class ApiRoutes:
app.on_startup.append(lambda _: routes.initialize_services())
app.router.add_post('/api/delete_model', routes.delete_model)
app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint
app.router.add_post('/api/fetch-civitai', routes.fetch_civitai)
app.router.add_post('/api/replace_preview', routes.replace_preview)
app.router.add_get('/api/loras', routes.get_loras)
@@ -69,6 +70,9 @@ class ApiRoutes:
# Add the new trigger words route
app.router.add_post('/loramanager/get_trigger_words', routes.get_trigger_words)
# Add new endpoint for letter counts
app.router.add_get('/api/loras/letter-counts', routes.get_letter_counts)
# Add update check routes
UpdateRoutes.setup_routes(app)
@@ -78,6 +82,12 @@ class ApiRoutes:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_delete_model(request, self.scanner)
async def exclude_model(self, request: web.Request) -> web.Response:
"""Handle model exclusion request"""
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_exclude_model(request, self.scanner)
async def fetch_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata fetch request"""
if self.scanner is None:
@@ -126,6 +136,9 @@ class ApiRoutes:
tags = request.query.get('tags', None)
favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # New parameter
# New parameter for alphabet filtering
first_letter = request.query.get('first_letter', None)
# New parameters for recipe filtering
lora_hash = request.query.get('lora_hash', None)
lora_hashes = request.query.get('lora_hashes', None)
@@ -156,7 +169,8 @@ class ApiRoutes:
tags=filters.get('tags', None),
search_options=search_options,
hash_filters=hash_filters,
favorites_only=favorites_only # Pass favorites_only parameter
favorites_only=favorites_only, # Pass favorites_only parameter
first_letter=first_letter # Pass the new first_letter parameter
)
# Get all available folders from cache
@@ -781,11 +795,13 @@ class ApiRoutes:
# Check if we already have the description stored in metadata
description = None
tags = []
creator = {}
if file_path:
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
description = metadata.get('modelDescription')
tags = metadata.get('tags', [])
creator = metadata.get('creator', {})
# If description is not in metadata, fetch from CivitAI
if not description:
@@ -795,6 +811,7 @@ class ApiRoutes:
if (model_metadata):
description = model_metadata.get('description')
tags = model_metadata.get('tags', [])
creator = model_metadata.get('creator', {})
# Save the metadata to file if we have a file path and got metadata
if file_path:
@@ -804,6 +821,7 @@ class ApiRoutes:
metadata['modelDescription'] = description
metadata['tags'] = tags
metadata['creator'] = creator
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
@@ -814,7 +832,8 @@ class ApiRoutes:
return web.json_response({
'success': True,
'description': description or "<p>No model description available.</p>",
'tags': tags
'tags': tags,
'creator': creator
})
except Exception as e:
@@ -1045,3 +1064,23 @@ class ApiRoutes:
"success": False,
"error": str(e)
}, status=500)
async def get_letter_counts(self, request: web.Request) -> web.Response:
"""Get count of loras for each letter of the alphabet"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
# Get letter counts
letter_counts = await self.scanner.get_letter_counts()
return web.json_response({
'success': True,
'letter_counts': letter_counts
})
except Exception as e:
logger.error(f"Error getting letter counts: {e}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -49,6 +49,7 @@ class CheckpointsRoutes:
# Add new routes for model management similar to LoRA routes
app.router.add_post('/api/checkpoints/delete', self.delete_model)
app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint
app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai)
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
@@ -499,6 +500,10 @@ class CheckpointsRoutes:
async def delete_model(self, request: web.Request) -> web.Response:
"""Handle checkpoint model deletion request"""
return await ModelRouteUtils.handle_delete_model(request, self.scanner)
async def exclude_model(self, request: web.Request) -> web.Response:
"""Handle checkpoint model exclusion request"""
return await ModelRouteUtils.handle_exclude_model(request, self.scanner)
async def fetch_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata fetch request for checkpoints"""
@@ -653,7 +658,7 @@ class CheckpointsRoutes:
model_type = response.get('type', '')
# Check model type - should be Checkpoint
if model_type.lower() != 'checkpoint':
if (model_type.lower() != 'checkpoint'):
return web.json_response({
'error': f"Model type mismatch. Expected Checkpoint, got {model_type}"
}, status=400)

View File

@@ -3,8 +3,6 @@ import os
import asyncio
import json
import time
import tkinter as tk
from tkinter import filedialog
import aiohttp
from aiohttp import web
from ..services.settings_manager import settings
@@ -12,6 +10,8 @@ from ..utils.usage_stats import UsageStats
from ..services.service_registry import ServiceRegistry
from ..utils.exif_utils import ExifUtils
from ..utils.constants import EXAMPLE_IMAGE_WIDTH, SUPPORTED_MEDIA_EXTENSIONS
from ..services.civitai_client import CivitaiClient
from ..utils.routes_common import ModelRouteUtils
logger = logging.getLogger(__name__)
@@ -27,7 +27,8 @@ download_progress = {
'last_error': None,
'start_time': None,
'end_time': None,
'processed_models': set() # Track models that have been processed
'processed_models': set(), # Track models that have been processed
'refreshed_models': set() # Track models that had metadata refreshed
}
class MiscRoutes:
@@ -151,6 +152,7 @@ class MiscRoutes:
# Create a copy for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': False,
@@ -213,6 +215,7 @@ class MiscRoutes:
# Create a copy for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': True,
@@ -235,6 +238,7 @@ class MiscRoutes:
# Create a copy of the progress dict with the set converted to a list for JSON serialization
response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({
'success': True,
@@ -284,6 +288,259 @@ class MiscRoutes:
'error': f"Download is in '{download_progress['status']}' state, cannot resume"
}, status=400)
@staticmethod
async def _refresh_model_metadata(model_hash, model_name, scanner_type, scanner):
"""Refresh model metadata from CivitAI
Args:
model_hash: SHA256 hash of the model
model_name: Name of the model (for logging)
scanner_type: Type of scanner ('lora' or 'checkpoint')
scanner: Scanner instance for this model type
Returns:
bool: True if metadata was successfully refreshed, False otherwise
"""
global download_progress
try:
# Find the model in the scanner cache
cache = await scanner.get_cached_data()
model_data = None
for item in cache.raw_data:
if item.get('sha256') == model_hash:
model_data = item
break
if not model_data:
logger.warning(f"Model {model_name} with hash {model_hash} not found in cache")
return False
file_path = model_data.get('file_path')
if not file_path:
logger.warning(f"Model {model_name} has no file path")
return False
# Track that we're refreshing this model
download_progress['refreshed_models'].add(model_hash)
# Use ModelRouteUtils to refresh the metadata
async def update_cache_func(old_path, new_path, metadata):
return await scanner.update_single_model_cache(old_path, new_path, metadata)
success = await ModelRouteUtils.fetch_and_update_model(
model_hash,
file_path,
model_data,
update_cache_func
)
if success:
logger.info(f"Successfully refreshed metadata for {model_name}")
return True
else:
logger.warning(f"Failed to refresh metadata for {model_name}")
return False
except Exception as e:
error_msg = f"Error refreshing metadata for {model_name}: {str(e)}"
logger.error(error_msg, exc_info=True)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False
@staticmethod
async def _process_model_images(model_hash, model_name, model_images, model_dir, optimize, independent_session, delay):
"""Process and download images for a single model
Args:
model_hash: SHA256 hash of the model
model_name: Name of the model
model_images: List of image objects from CivitAI
model_dir: Directory to save images to
optimize: Whether to optimize images
independent_session: aiohttp session for downloads
delay: Delay between downloads
Returns:
bool: True if all images were processed successfully, False otherwise
"""
global download_progress
model_success = True
for i, image in enumerate(model_images, 1):
image_url = image.get('url')
if not image_url:
continue
# Get image filename from URL
image_filename = os.path.basename(image_url.split('?')[0])
image_ext = os.path.splitext(image_filename)[1].lower()
# Handle both images and videos
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug(f"Skipping unsupported file type: {image_filename}")
continue
save_filename = f"image_{i}{image_ext}"
# Check if already downloaded
save_path = os.path.join(model_dir, save_filename)
if os.path.exists(save_path):
logger.debug(f"File already exists: {save_path}")
continue
# Download the file
try:
logger.debug(f"Downloading {save_filename} for {model_name}")
# Direct download using the independent session
async with independent_session.get(image_url, timeout=60) as response:
if response.status == 200:
if is_image and optimize:
# For images, optimize if requested
image_data = await response.read()
optimized_data, ext = ExifUtils.optimize_image(
image_data,
target_width=EXAMPLE_IMAGE_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# Update save filename if format changed
if ext == '.webp':
save_filename = os.path.splitext(save_filename)[0] + '.webp'
save_path = os.path.join(model_dir, save_filename)
# Save the optimized image
with open(save_path, 'wb') as f:
f.write(optimized_data)
else:
# For videos or unoptimized images, save directly
with open(save_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
if chunk:
f.write(chunk)
elif response.status == 404:
error_msg = f"Failed to download file: {image_url}, status code: 404 - Model metadata might be stale"
logger.warning(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed due to 404
# Return early to trigger metadata refresh attempt
return False, True # (success, is_stale_metadata)
else:
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
logger.warning(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
# Add a delay between downloads for remote files only
await asyncio.sleep(delay)
except Exception as e:
error_msg = f"Error downloading file {image_url}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
return model_success, False # (success, is_stale_metadata)
@staticmethod
async def _process_local_example_images(model_file_path, model_file_name, model_name, model_dir, optimize):
"""Process local example images for a model
Args:
model_file_path: Path to the model file
model_file_name: Filename of the model
model_name: Name of the model
model_dir: Directory to save processed images to
optimize: Whether to optimize images
Returns:
bool: True if local images were processed successfully, False otherwise
"""
global download_progress
try:
model_dir_path = os.path.dirname(model_file_path)
local_images = []
# Look for files with pattern: filename.example.*.ext
if model_file_name:
example_prefix = f"{model_file_name}.example."
if os.path.exists(model_dir_path):
for file in os.listdir(model_dir_path):
file_lower = file.lower()
if file_lower.startswith(example_prefix.lower()):
file_ext = os.path.splitext(file_lower)[1]
is_supported = (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'])
if is_supported:
local_images.append(os.path.join(model_dir_path, file))
# Process local images if found
if local_images:
logger.info(f"Found {len(local_images)} local example images for {model_name}")
for i, local_image_path in enumerate(local_images, 1):
local_ext = os.path.splitext(local_image_path)[1].lower()
save_filename = f"image_{i}{local_ext}"
save_path = os.path.join(model_dir, save_filename)
# Skip if already exists in output directory
if os.path.exists(save_path):
logger.debug(f"File already exists in output: {save_path}")
continue
# Handle image processing based on file type and optimize setting
is_image = local_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
if is_image and optimize:
# Optimize the image
with open(local_image_path, 'rb') as img_file:
image_data = img_file.read()
optimized_data, ext = ExifUtils.optimize_image(
image_data,
target_width=EXAMPLE_IMAGE_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# Update save filename if format changed
if ext == '.webp':
save_filename = os.path.splitext(save_filename)[0] + '.webp'
save_path = os.path.join(model_dir, save_filename)
# Save the optimized image
with open(save_path, 'wb') as f:
f.write(optimized_data)
else:
# For videos or unoptimized images, copy directly
with open(local_image_path, 'rb') as src_file:
with open(save_path, 'wb') as dst_file:
dst_file.write(src_file.read())
return True
return False
except Exception as e:
error_msg = f"Error processing local examples for {model_name}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
return False
@staticmethod
async def _download_all_example_images(output_dir, optimize, model_types, delay):
"""Download example images for all models
@@ -332,14 +589,14 @@ class MiscRoutes:
for model in cache.raw_data:
# Only process models with images and a valid sha256
if model.get('civitai') and model.get('civitai', {}).get('images') and model.get('sha256'):
all_models.append((scanner_type, model))
all_models.append((scanner_type, model, scanner))
# Update total count
download_progress['total'] = len(all_models)
logger.info(f"Found {download_progress['total']} models with example images")
# Process each model
for scanner_type, model in all_models:
for scanner_type, model, scanner in all_models:
# Check if download is paused
while download_progress['status'] == 'paused':
await asyncio.sleep(1)
@@ -349,14 +606,13 @@ class MiscRoutes:
logger.info(f"Download stopped: {download_progress['status']}")
break
model_success = True # Track if all images for this model download successfully
model_hash = model.get('sha256', '').lower()
model_name = model.get('model_name', 'Unknown')
model_file_path = model.get('file_path', '')
model_file_name = model.get('file_name', '')
try:
# Update current model info
model_hash = model.get('sha256', '').lower()
model_name = model.get('model_name', 'Unknown')
model_file_path = model.get('file_path', '')
model_file_name = model.get('file_name', '')
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already processed
@@ -381,156 +637,69 @@ class MiscRoutes:
# First check if we have local example images for this model
local_images_processed = False
if model_file_path:
try:
model_dir_path = os.path.dirname(model_file_path)
local_images = []
# Look for files with pattern: filename.example.*.ext
if model_file_name:
example_prefix = f"{model_file_name}.example."
if os.path.exists(model_dir_path):
for file in os.listdir(model_dir_path):
file_lower = file.lower()
if file_lower.startswith(example_prefix.lower()):
file_ext = os.path.splitext(file_lower)[1]
is_supported = (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'])
if is_supported:
local_images.append(os.path.join(model_dir_path, file))
# Process local images if found
if local_images:
logger.info(f"Found {len(local_images)} local example images for {model_name}")
for i, local_image_path in enumerate(local_images, 1):
local_ext = os.path.splitext(local_image_path)[1].lower()
save_filename = f"image_{i}{local_ext}"
save_path = os.path.join(model_dir, save_filename)
# Skip if already exists in output directory
if os.path.exists(save_path):
logger.debug(f"File already exists in output: {save_path}")
continue
# Handle image processing based on file type and optimize setting
is_image = local_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
if is_image and optimize:
# Optimize the image
with open(local_image_path, 'rb') as img_file:
image_data = img_file.read()
optimized_data, ext = ExifUtils.optimize_image(
image_data,
target_width=EXAMPLE_IMAGE_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# Update save filename if format changed
if ext == '.webp':
save_filename = os.path.splitext(save_filename)[0] + '.webp'
save_path = os.path.join(model_dir, save_filename)
# Save the optimized image
with open(save_path, 'wb') as f:
f.write(optimized_data)
else:
# For videos or unoptimized images, copy directly
with open(local_image_path, 'rb') as src_file:
with open(save_path, 'wb') as dst_file:
dst_file.write(src_file.read())
# Mark as successfully processed if all local images were processed
download_progress['processed_models'].add(model_hash)
local_images_processed = True
logger.info(f"Successfully processed local examples for {model_name}")
local_images_processed = await MiscRoutes._process_local_example_images(
model_file_path,
model_file_name,
model_name,
model_dir,
optimize
)
except Exception as e:
error_msg = f"Error processing local examples for {model_name}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
# Continue to remote download if local processing fails
if local_images_processed:
# Mark as successfully processed if all local images were processed
download_progress['processed_models'].add(model_hash)
logger.info(f"Successfully processed local examples for {model_name}")
# If we didn't process local images, download from remote
if not local_images_processed:
# Download example images
for i, image in enumerate(images, 1):
image_url = image.get('url')
if not image_url:
continue
# Try to download images
model_success, is_stale_metadata = await MiscRoutes._process_model_images(
model_hash,
model_name,
images,
model_dir,
optimize,
independent_session,
delay
)
# If metadata is stale (404 error), try to refresh it and download again
if is_stale_metadata and model_hash not in download_progress['refreshed_models']:
logger.info(f"Metadata seems stale for {model_name}, attempting to refresh...")
# Get image filename from URL
image_filename = os.path.basename(image_url.split('?')[0])
image_ext = os.path.splitext(image_filename)[1].lower()
# Refresh metadata from CivitAI
refresh_success = await MiscRoutes._refresh_model_metadata(
model_hash,
model_name,
scanner_type,
scanner
)
# Handle both images and videos
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images']
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']
if not (is_image or is_video):
logger.debug(f"Skipping unsupported file type: {image_filename}")
continue
save_filename = f"image_{i}{image_ext}"
# Check if already downloaded
save_path = os.path.join(model_dir, save_filename)
if os.path.exists(save_path):
logger.debug(f"File already exists: {save_path}")
continue
# Download the file
try:
logger.debug(f"Downloading {save_filename} for {model_name}")
if refresh_success:
# Get updated model data
updated_cache = await scanner.get_cached_data()
updated_model = None
# Direct download using the independent session
async with independent_session.get(image_url, timeout=60) as response:
if response.status == 200:
if is_image and optimize:
# For images, optimize if requested
image_data = await response.read()
optimized_data, ext = ExifUtils.optimize_image(
image_data,
target_width=EXAMPLE_IMAGE_WIDTH,
format='webp',
quality=85,
preserve_metadata=False
)
# Update save filename if format changed
if ext == '.webp':
save_filename = os.path.splitext(save_filename)[0] + '.webp'
save_path = os.path.join(model_dir, save_filename)
# Save the optimized image
with open(save_path, 'wb') as f:
f.write(optimized_data)
else:
# For videos or unoptimized images, save directly
with open(save_path, 'wb') as f:
async for chunk in response.content.iter_chunked(8192):
if chunk:
f.write(chunk)
else:
error_msg = f"Failed to download file: {image_url}, status code: {response.status}"
logger.warning(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
for item in updated_cache.raw_data:
if item.get('sha256') == model_hash:
updated_model = item
break
# Add a delay between downloads for remote files only
await asyncio.sleep(delay)
except Exception as e:
error_msg = f"Error downloading file {image_url}: {str(e)}"
logger.error(error_msg)
download_progress['errors'].append(error_msg)
download_progress['last_error'] = error_msg
model_success = False # Mark model as failed
if updated_model and updated_model.get('civitai', {}).get('images'):
# Try downloading with updated metadata
logger.info(f"Retrying download with refreshed metadata for {model_name}")
updated_images = updated_model.get('civitai', {}).get('images', [])
# Retry download with new images
model_success, _ = await MiscRoutes._process_model_images(
model_hash,
model_name,
updated_images,
model_dir,
optimize,
independent_session,
delay
)
# Only mark model as processed if all images downloaded successfully
if model_success:
@@ -544,6 +713,7 @@ class MiscRoutes:
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time()
@@ -584,6 +754,7 @@ class MiscRoutes:
with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({
'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'],
'total': download_progress['total'],
'last_update': time.time(),

View File

@@ -150,11 +150,16 @@ class UpdateRoutes:
"""
Compare two semantic version strings
Returns True if version2 is newer than version1
Ignores any suffixes after '-' (e.g., -bugfix, -alpha)
"""
try:
# Clean version strings - remove any suffix after '-'
v1_clean = version1.split('-')[0]
v2_clean = version2.split('-')[0]
# Split versions into components
v1_parts = [int(x) for x in version1.split('.')]
v2_parts = [int(x) for x in version2.split('.')]
v1_parts = [int(x) for x in v1_clean.split('.')]
v2_parts = [int(x) for x in v2_clean.split('.')]
# Ensure both have 3 components (major.minor.patch)
while len(v1_parts) < 3:

View File

@@ -267,7 +267,7 @@ class CivitaiClient:
return None, error_msg
async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
"""Fetch model metadata (description and tags) from Civitai API
"""Fetch model metadata (description, tags, and creator info) from Civitai API
Args:
model_id: The Civitai model ID
@@ -294,10 +294,14 @@ class CivitaiClient:
# Extract relevant metadata
metadata = {
"description": data.get("description") or "No model description available",
"tags": data.get("tags", [])
"tags": data.get("tags", []),
"creator": {
"username": data.get("creator", {}).get("username"),
"image": data.get("creator", {}).get("image")
}
}
if metadata["description"] or metadata["tags"]:
if metadata["description"] or metadata["tags"] or metadata["creator"]["username"]:
return metadata, status_code
else:
logger.warning(f"No metadata found for model {model_id}")

View File

@@ -154,7 +154,7 @@ class DownloadManager:
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
logger.info(f"Creating LoraMetadata for {file_name}")
# 5.1 Get and update model tags and description
# 5.1 Get and update model tags, description and creator info
model_id = version_info.get('modelId')
if model_id:
model_metadata, _ = await civitai_client.get_model_metadata(str(model_id))
@@ -163,6 +163,8 @@ class DownloadManager:
metadata.tags = model_metadata.get("tags", [])
if model_metadata.get("description"):
metadata.modelDescription = model_metadata.get("description", "")
if model_metadata.get("creator"):
metadata.civitai["creator"] = model_metadata.get("creator")
# 6. Start download process
result = await self._execute_download(

View File

@@ -4,6 +4,7 @@ import logging
import asyncio
import shutil
import time
import re
from typing import List, Dict, Optional, Set
from ..utils.models import LoraMetadata
@@ -123,7 +124,7 @@ class LoraScanner(ModelScanner):
folder: str = None, search: str = None, fuzzy_search: bool = False,
base_models: list = None, tags: list = None,
search_options: dict = None, hash_filters: dict = None,
favorites_only: bool = False) -> Dict:
favorites_only: bool = False, first_letter: str = None) -> Dict:
"""Get paginated and filtered lora data
Args:
@@ -138,6 +139,7 @@ class LoraScanner(ModelScanner):
search_options: Dictionary with search options (filename, modelname, tags, recursive)
hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes)
favorites_only: Filter for favorite models only
first_letter: Filter by first letter of model name
"""
cache = await self.get_cached_data()
@@ -202,6 +204,10 @@ class LoraScanner(ModelScanner):
lora for lora in filtered_data
if lora.get('favorite', False) is True
]
# Apply first letter filtering
if first_letter:
filtered_data = self._filter_by_first_letter(filtered_data, first_letter)
# Apply folder filtering
if folder is not None:
@@ -273,6 +279,101 @@ class LoraScanner(ModelScanner):
return result
def _filter_by_first_letter(self, data, letter):
"""Filter data by first letter of model name
Special handling:
- '#': Numbers (0-9)
- '@': Special characters (not alphanumeric)
- '': CJK characters
"""
filtered_data = []
for lora in data:
model_name = lora.get('model_name', '')
if not model_name:
continue
first_char = model_name[0].upper()
if letter == '#' and first_char.isdigit():
filtered_data.append(lora)
elif letter == '@' and not first_char.isalnum():
# Special characters (not alphanumeric)
filtered_data.append(lora)
elif letter == '' and self._is_cjk_character(first_char):
# CJK characters
filtered_data.append(lora)
elif letter.upper() == first_char:
# Regular alphabet matching
filtered_data.append(lora)
return filtered_data
def _is_cjk_character(self, char):
"""Check if character is a CJK character"""
# Define Unicode ranges for CJK characters
cjk_ranges = [
(0x4E00, 0x9FFF), # CJK Unified Ideographs
(0x3400, 0x4DBF), # CJK Unified Ideographs Extension A
(0x20000, 0x2A6DF), # CJK Unified Ideographs Extension B
(0x2A700, 0x2B73F), # CJK Unified Ideographs Extension C
(0x2B740, 0x2B81F), # CJK Unified Ideographs Extension D
(0x2B820, 0x2CEAF), # CJK Unified Ideographs Extension E
(0x2CEB0, 0x2EBEF), # CJK Unified Ideographs Extension F
(0x30000, 0x3134F), # CJK Unified Ideographs Extension G
(0xF900, 0xFAFF), # CJK Compatibility Ideographs
(0x3300, 0x33FF), # CJK Compatibility
(0x3200, 0x32FF), # Enclosed CJK Letters and Months
(0x3100, 0x312F), # Bopomofo
(0x31A0, 0x31BF), # Bopomofo Extended
(0x3040, 0x309F), # Hiragana
(0x30A0, 0x30FF), # Katakana
(0x31F0, 0x31FF), # Katakana Phonetic Extensions
(0xAC00, 0xD7AF), # Hangul Syllables
(0x1100, 0x11FF), # Hangul Jamo
(0xA960, 0xA97F), # Hangul Jamo Extended-A
(0xD7B0, 0xD7FF), # Hangul Jamo Extended-B
]
code_point = ord(char)
return any(start <= code_point <= end for start, end in cjk_ranges)
async def get_letter_counts(self):
"""Get count of models for each letter of the alphabet"""
cache = await self.get_cached_data()
data = cache.sorted_by_name
# Define letter categories
letters = {
'#': 0, # Numbers
'A': 0, 'B': 0, 'C': 0, 'D': 0, 'E': 0, 'F': 0, 'G': 0, 'H': 0,
'I': 0, 'J': 0, 'K': 0, 'L': 0, 'M': 0, 'N': 0, 'O': 0, 'P': 0,
'Q': 0, 'R': 0, 'S': 0, 'T': 0, 'U': 0, 'V': 0, 'W': 0, 'X': 0,
'Y': 0, 'Z': 0,
'@': 0, # Special characters
'': 0 # CJK characters
}
# Count models for each letter
for lora in data:
model_name = lora.get('model_name', '')
if not model_name:
continue
first_char = model_name[0].upper()
if first_char.isdigit():
letters['#'] += 1
elif first_char in letters:
letters[first_char] += 1
elif self._is_cjk_character(first_char):
letters[''] += 1
elif not first_char.isalnum():
letters['@'] += 1
return letters
async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
"""Update file paths in metadata file"""
try:

View File

@@ -38,6 +38,7 @@ class ModelScanner:
self._hash_index = hash_index or ModelHashIndex()
self._tags_count = {} # Dictionary to store tag counts
self._is_initializing = False # Flag to track initialization state
self._excluded_models = [] # List to track excluded models
# Register this service
asyncio.create_task(self._register_service())
@@ -394,6 +395,9 @@ class ModelScanner:
if file_path in cached_paths:
found_paths.add(file_path)
continue
if file_path in self._excluded_models:
continue
# Try case-insensitive match on Windows
if os.name == 'nt':
@@ -406,7 +410,7 @@ class ModelScanner:
break
if matched:
continue
# This is a new file to process
new_files.append(file_path)
@@ -586,6 +590,11 @@ class ModelScanner:
model_data = metadata.to_dict()
# Skip excluded models
if model_data.get('exclude', False):
self._excluded_models.append(model_data['file_path'])
return None
await self._fetch_missing_metadata(file_path, model_data)
rel_path = os.path.relpath(file_path, root_path)
folder = os.path.dirname(rel_path)
@@ -610,7 +619,10 @@ class ModelScanner:
model_id = str(model_id)
tags_missing = not model_data.get('tags') or len(model_data.get('tags', [])) == 0
desc_missing = not model_data.get('modelDescription') or model_data.get('modelDescription') in (None, "")
needs_metadata_update = tags_missing or desc_missing
# TODO: not for now, but later we should check if the creator is missing
# creator_missing = not model_data.get('civitai', {}).get('creator')
creator_missing = False
needs_metadata_update = tags_missing or desc_missing or creator_missing
if needs_metadata_update and model_id:
logger.debug(f"Fetching missing metadata for {file_path} with model ID {model_id}")
@@ -636,6 +648,8 @@ class ModelScanner:
if model_metadata.get('description') and (not model_data.get('modelDescription') or model_data.get('modelDescription') in (None, "")):
model_data['modelDescription'] = model_metadata['description']
model_data['civitai']['creator'] = model_metadata['creator']
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
with open(metadata_path, 'w', encoding='utf-8') as f:
@@ -900,6 +914,10 @@ class ModelScanner:
logger.error(f"Error getting model info by name: {e}", exc_info=True)
return None
def get_excluded_models(self) -> List[str]:
"""Get list of excluded model file paths"""
return self._excluded_models.copy()
async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
"""Update preview URL in cache for a specific lora
@@ -913,4 +931,4 @@ class ModelScanner:
if self._cache is None:
return False
return await self._cache.update_preview_url(file_path, preview_url)
return await self._cache.update_preview_url(file_path, preview_url)

View File

@@ -23,6 +23,7 @@ class BaseModelMetadata:
modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue

View File

@@ -53,6 +53,7 @@ class ModelRouteUtils:
if model_metadata:
local_metadata['modelDescription'] = model_metadata.get('description', '')
local_metadata['tags'] = model_metadata.get('tags', [])
local_metadata['civitai']['creator'] = model_metadata['creator']
# Update base model
local_metadata['base_model'] = determine_base_model(civitai_metadata.get('baseModel'))
@@ -424,6 +425,65 @@ class ModelRouteUtils:
logger.error(f"Error replacing preview: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
@staticmethod
async def handle_exclude_model(request: web.Request, scanner) -> web.Response:
"""Handle model exclusion request
Args:
request: The aiohttp request
scanner: The model scanner instance with cache management methods
Returns:
web.Response: The HTTP response
"""
try:
data = await request.json()
file_path = data.get('file_path')
if not file_path:
return web.Response(text='Model path is required', status=400)
# Update metadata to mark as excluded
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
metadata['exclude'] = True
# Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False)
# Update cache
cache = await scanner.get_cached_data()
# Find and remove model from cache
model_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
if model_to_remove:
# Update tags count
for tag in model_to_remove.get('tags', []):
if tag in scanner._tags_count:
scanner._tags_count[tag] = max(0, scanner._tags_count[tag] - 1)
if scanner._tags_count[tag] == 0:
del scanner._tags_count[tag]
# Remove from hash index if available
if hasattr(scanner, '_hash_index') and scanner._hash_index:
scanner._hash_index.remove_by_path(file_path)
# Remove from cache data
cache.raw_data = [item for item in cache.raw_data if item['file_path'] != file_path]
await cache.resort()
# Add to excluded models list
scanner._excluded_models.append(file_path)
return web.json_response({
'success': True,
'message': f"Model {os.path.basename(file_path)} excluded"
})
except Exception as e:
logger.error(f"Error excluding model: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
@staticmethod
async def handle_download_model(request: web.Request, download_manager: DownloadManager, model_type="lora") -> web.Response:
"""Handle model download request
@@ -500,4 +560,4 @@ class ModelRouteUtils:
)
logger.error(f"Error downloading {model_type}: {error_message}")
return web.Response(status=500, text=error_message)
return web.Response(status=500, text=error_message)

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.8.11"
version = "0.8.12"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -0,0 +1,165 @@
/* Alphabet Bar Component */
.alphabet-bar-container {
position: fixed;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
transition: transform 0.3s ease;
}
.alphabet-bar-container.collapsed {
transform: translateY(-50%) translateX(-90%);
}
/* New visual indicator for when a letter is active and bar is collapsed */
.alphabet-bar-container.collapsed .toggle-alphabet-bar.has-active-letter {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.15);
}
.alphabet-bar-container.collapsed .toggle-alphabet-bar.has-active-letter::after {
content: '';
position: absolute;
top: 7px;
right: 7px;
width: 8px;
height: 8px;
background-color: var(--lora-accent);
border-radius: 50%;
animation: pulse-active 2s infinite;
}
@keyframes pulse-active {
0% { transform: scale(0.8); opacity: 0.7; }
50% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(0.8); opacity: 0.7; }
}
.alphabet-bar {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
padding: 8px 4px;
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
max-height: 80vh;
overflow-y: auto;
scrollbar-width: thin;
}
.alphabet-bar::-webkit-scrollbar {
width: 4px;
}
.alphabet-bar::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.toggle-alphabet-bar {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-left: none;
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
padding: 8px 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-color);
width: 20px;
height: 40px;
align-self: center;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
.toggle-alphabet-bar:hover {
background: var(--bg-hover);
}
.toggle-alphabet-bar i {
transition: transform 0.3s ease;
}
.alphabet-bar-container.collapsed .toggle-alphabet-bar i {
transform: rotate(180deg);
}
.letter-chip {
padding: 4px 2px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
min-width: 24px;
text-align: center;
font-size: 0.85em;
transition: all 0.2s ease;
border: 1px solid var(--border-color);
}
.letter-chip:hover {
background: var(--lora-accent);
color: white;
transform: scale(1.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.letter-chip.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
.letter-chip.disabled {
opacity: 0.5;
pointer-events: none;
cursor: default;
}
/* Hide the count by default, only show in tooltip */
.letter-chip .count {
display: none;
}
.alphabet-bar-title {
font-size: 0.75em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 6px;
writing-mode: vertical-lr;
transform: rotate(180deg);
white-space: nowrap;
}
@media (max-width: 768px) {
.alphabet-bar-container {
transform: translateY(-50%) translateX(-90%);
}
.alphabet-bar-container.active {
transform: translateY(-50%) translateX(0);
}
.letter-chip {
padding: 3px 1px;
min-width: 20px;
font-size: 0.75em;
}
}
/* Keyframe animations for the active letter */
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.letter-chip.active {
animation: pulse 1s ease-in-out 1;
}

View File

@@ -1133,8 +1133,8 @@
pointer-events: none;
}
/* Show metadata panel only on hover */
.media-wrapper:hover .image-metadata-panel {
/* Show metadata panel only when the 'visible' class is added */
.media-wrapper .image-metadata-panel.visible {
transform: translateY(0);
opacity: 0.98;
pointer-events: auto;

View File

@@ -44,26 +44,12 @@ body.modal-open {
}
/* Delete Modal specific styles */
.delete-modal-content {
max-width: 500px;
text-align: center;
}
.delete-message {
color: var(--text-color);
margin: var(--space-2) 0;
}
.delete-model-info {
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
margin: var(--space-2) 0;
color: var(--text-color);
word-break: break-all;
}
/* Update delete modal styles */
.delete-modal {
display: none; /* Set initial display to none */
@@ -92,7 +78,8 @@ body.modal-open {
animation: modalFadeIn 0.2s ease-out;
}
.delete-model-info {
.delete-model-info,
.exclude-model-info {
/* Update info display styling */
background: var(--lora-surface);
border: 1px solid var(--lora-border);
@@ -123,7 +110,7 @@ body.modal-open {
margin-top: var(--space-3);
}
.cancel-btn, .delete-btn {
.cancel-btn, .delete-btn, .exclude-btn {
padding: 8px var(--space-2);
border-radius: 6px;
border: none;
@@ -143,6 +130,12 @@ body.modal-open {
color: white;
}
/* Style for exclude button - different from delete button */
.exclude-btn {
background: var(--lora-accent, #4f46e5);
color: white;
}
.cancel-btn:hover {
background: var(--lora-border);
}
@@ -151,6 +144,11 @@ body.modal-open {
opacity: 0.9;
}
.exclude-btn:hover {
opacity: 0.9;
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
}
.modal-content h2 {
color: var(--text-color);
margin-bottom: var(--space-2);
@@ -587,7 +585,7 @@ input:checked + .toggle-slider:before {
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
color: var (--text-color);
font-size: 0.95em;
height: 32px;
}

View File

@@ -117,9 +117,50 @@
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* QR Code section styles */
.qrcode-toggle {
width: 100%;
margin-top: var(--space-2);
justify-content: center;
position: relative;
}
.qrcode-toggle .toggle-icon {
margin-left: 8px;
transition: transform 0.3s ease;
}
.qrcode-toggle.active .toggle-icon {
transform: rotate(180deg);
}
.qrcode-container {
max-height: 0;
overflow: hidden;
transition: max-height 0.4s ease, opacity 0.3s ease;
opacity: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.qrcode-container.show {
max-height: 500px;
opacity: 1;
margin-top: var(--space-3);
}
.qrcode-image {
max-width: 80%;
height: auto;
border-radius: var(--border-radius-sm);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid var(--lora-border);
aspect-ratio: 1/1; /* Ensure proper aspect ratio for the square QR code */
}
.support-footer {
text-align: center;
margin-top: var(--space-1);
font-style: italic;
color: var(--text-color);
}

View File

@@ -21,6 +21,7 @@
@import 'components/filter-indicator.css';
@import 'components/initialization.css';
@import 'components/progress-panel.css';
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
.initialization-notice {
display: flex;

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -49,6 +49,11 @@ export async function loadMoreModels(options = {}) {
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
// Add active letter filter if set
if (pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter);
}
// Add search parameters if there's a search term
if (pageState.filters?.search) {
@@ -203,13 +208,44 @@ export function replaceModelPreview(filePath, modelType = 'lora') {
}
// Delete a model (generic)
export function deleteModel(filePath, modelType = 'lora') {
if (modelType === 'checkpoint') {
confirmDelete('Are you sure you want to delete this checkpoint?', () => {
performDelete(filePath, modelType);
export async function deleteModel(filePath, modelType = 'lora') {
try {
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/delete'
: '/api/delete_model';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
} else {
showDeleteModal(filePath);
if (!response.ok) {
throw new Error(`Failed to delete ${modelType}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
// Remove the card from UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
}
showToast(`${modelType} deleted successfully`, 'success');
return true;
} else {
throw new Error(data.error || `Failed to delete ${modelType}`);
}
} catch (error) {
console.error(`Error deleting ${modelType}:`, error);
showToast(`Failed to delete ${modelType}: ${error.message}`, 'error');
return false;
}
}
@@ -389,6 +425,48 @@ export async function refreshSingleModelMetadata(filePath, modelType = 'lora') {
}
}
// Generic function to exclude a model
export async function excludeModel(filePath, modelType = 'lora') {
try {
const endpoint = modelType === 'checkpoint'
? '/api/checkpoints/exclude'
: '/api/loras/exclude';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
throw new Error(`Failed to exclude ${modelType}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
// Remove the card from UI
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (card) {
card.remove();
}
showToast(`${modelType} excluded successfully`, 'success');
return true;
} else {
throw new Error(data.error || `Failed to exclude ${modelType}`);
}
} catch (error) {
console.error(`Error excluding ${modelType}:`, error);
showToast(`Failed to exclude ${modelType}: ${error.message}`, 'error');
return false;
}
}
// Private methods
// Upload a preview image

View File

@@ -6,7 +6,8 @@ import {
deleteModel as baseDeleteModel,
replaceModelPreview,
fetchCivitaiMetadata,
refreshSingleModelMetadata
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
// Load more checkpoints with pagination
@@ -85,4 +86,13 @@ export async function saveModelMetadata(filePath, data) {
}
return response.json();
}
/**
* Exclude a checkpoint model from being shown in the UI
* @param {string} filePath - File path of the checkpoint to exclude
* @returns {Promise<boolean>} Promise resolving to success status
*/
export function excludeCheckpoint(filePath) {
return baseExcludeModel(filePath, 'checkpoint');
}

View File

@@ -6,7 +6,8 @@ import {
deleteModel as baseDeleteModel,
replaceModelPreview,
fetchCivitaiMetadata,
refreshSingleModelMetadata
refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js';
/**
@@ -34,6 +35,15 @@ export async function saveModelMetadata(filePath, data) {
return response.json();
}
/**
* Exclude a lora model from being shown in the UI
* @param {string} filePath - File path of the model to exclude
* @returns {Promise<boolean>} Promise resolving to success status
*/
export async function excludeLora(filePath) {
return baseExcludeModel(filePath, 'lora');
}
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
return loadMoreModels({
resetPage,

View File

@@ -1,6 +1,6 @@
import { appCore } from './core.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js';
import { loadMoreCheckpoints } from './api/checkpointApi.js';
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
@@ -23,6 +23,8 @@ class CheckpointsPageManager {
// Minimal set of functions that need to remain global
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.confirmExclude = confirmExclude;
window.closeExcludeModal = closeExcludeModal;
// Add loadCheckpoints function to window for FilterManager compatibility
window.checkpointManager = {

View File

@@ -3,6 +3,7 @@ import { state } from '../state/index.js';
import { showCheckpointModal } from './checkpointModal/index.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js';
import { showDeleteModal } from '../utils/modalUtils.js';
export function createCheckpointCard(checkpoint) {
const card = document.createElement('div');
@@ -262,7 +263,7 @@ export function createCheckpointCard(checkpoint) {
// Delete button click event
card.querySelector('.fa-trash')?.addEventListener('click', e => {
e.stopPropagation();
deleteCheckpoint(checkpoint.file_path);
showDeleteModal(checkpoint.file_path);
});
// Replace preview button click event
@@ -322,17 +323,6 @@ function openCivitai(modelName) {
}
}
function deleteCheckpoint(filePath) {
if (window.deleteCheckpoint) {
window.deleteCheckpoint(filePath);
} else {
// Use the modal delete functionality
import('../utils/modalUtils.js').then(({ showDeleteModal }) => {
showDeleteModal(filePath, 'checkpoint');
});
}
}
function replaceCheckpointPreview(filePath) {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);

View File

@@ -3,6 +3,7 @@ import { refreshSingleCheckpointMetadata, saveModelMetadata } from '../../api/ch
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js';
export class CheckpointContextMenu extends BaseContextMenu {
constructor() {
@@ -61,6 +62,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Move to folder (placeholder)
showToast('Move to folder feature coming soon', 'info');
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint');
break;
}
}

View File

@@ -3,6 +3,7 @@ import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js';
export class LoraContextMenu extends BaseContextMenu {
constructor() {
@@ -51,6 +52,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
}
}

View File

@@ -78,5 +78,33 @@ export class HeaderManager {
// Handle support panel logic
});
}
// Handle QR code toggle
const qrToggle = document.getElementById('toggleQRCode');
const qrContainer = document.getElementById('qrCodeContainer');
if (qrToggle && qrContainer) {
qrToggle.addEventListener('click', function() {
qrContainer.classList.toggle('show');
qrToggle.classList.toggle('active');
const toggleText = qrToggle.querySelector('.toggle-text');
if (qrContainer.classList.contains('show')) {
toggleText.textContent = 'Hide WeChat QR Code';
// Add small delay to ensure DOM is updated before scrolling
setTimeout(() => {
const supportModal = document.querySelector('.support-modal');
if (supportModal) {
supportModal.scrollTo({
top: supportModal.scrollHeight,
behavior: 'smooth'
});
}
}, 250);
} else {
toggleText.textContent = 'Show WeChat QR Code';
}
});
}
}
}

View File

@@ -3,7 +3,8 @@ import { state } from '../state/index.js';
import { showLoraModal } from './loraModal/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replacePreview, deleteModel, saveModelMetadata } from '../api/loraApi.js'
import { replacePreview, saveModelMetadata } from '../api/loraApi.js'
import { showDeleteModal } from '../utils/modalUtils.js';
export function createLoraCard(lora) {
const card = document.createElement('div');
@@ -260,7 +261,7 @@ export function createLoraCard(lora) {
// Delete button click event
card.querySelector('.fa-trash')?.addEventListener('click', e => {
e.stopPropagation();
deleteModel(lora.file_path);
showDeleteModal(lora.file_path);
});
// Replace preview button click event

View File

@@ -0,0 +1,319 @@
// AlphabetBar.js - Component for alphabet filtering
import { getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js';
import { resetAndReload } from '../../api/loraApi.js';
/**
* AlphabetBar class - Handles the alphabet filtering UI and interactions
*/
export class AlphabetBar {
constructor(pageType = 'loras') {
// Store the page type
this.pageType = pageType;
// Get the current page state
this.pageState = getCurrentPageState();
// Initialize letter counts
this.letterCounts = {};
// Initialize the component
this.initializeComponent();
}
/**
* Initialize the alphabet bar component
*/
async initializeComponent() {
// Get letter counts from API
await this.fetchLetterCounts();
// Initialize event listeners
this.initEventListeners();
// Restore the active letter filter from storage if available
this.restoreActiveLetterFilter();
// Restore collapse state from storage
this.restoreCollapseState();
// Update the toggle button indicator if there's an active letter filter
this.updateToggleIndicator();
}
/**
* Fetch letter counts from the API
*/
async fetchLetterCounts() {
try {
const response = await fetch('/api/loras/letter-counts');
if (!response.ok) {
throw new Error(`Failed to fetch letter counts: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.letter_counts) {
this.letterCounts = data.letter_counts;
// Update the count display in the UI
this.updateLetterCountsDisplay();
}
} catch (error) {
console.error('Error fetching letter counts:', error);
}
}
/**
* Update the letter counts display in the UI
*/
updateLetterCountsDisplay() {
const letterChips = document.querySelectorAll('.letter-chip');
letterChips.forEach(chip => {
const letter = chip.dataset.letter;
const count = this.letterCounts[letter] || 0;
// Update the title attribute for tooltip display
if (count > 0) {
chip.title = `${letter}: ${count} LoRAs`;
chip.classList.remove('disabled');
} else {
chip.title = `${letter}: No LoRAs`;
chip.classList.add('disabled');
}
// Keep the count span for backward compatibility
const countSpan = chip.querySelector('.count');
if (countSpan) {
countSpan.textContent = ` (${count})`;
}
});
}
/**
* Initialize event listeners for the alphabet bar
*/
initEventListeners() {
const alphabetBar = document.querySelector('.alphabet-bar');
const toggleButton = document.querySelector('.toggle-alphabet-bar');
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
if (alphabetBar) {
// Use event delegation for letter chips
alphabetBar.addEventListener('click', (e) => {
const letterChip = e.target.closest('.letter-chip');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
}
});
// Add toggle button listener
if (toggleButton && alphabetBarContainer) {
toggleButton.addEventListener('click', () => {
alphabetBarContainer.classList.toggle('collapsed');
// If expanding and there's an active letter, scroll it into view
if (!alphabetBarContainer.classList.contains('collapsed')) {
this.scrollActiveLetterIntoView();
}
// Save collapse state to storage
setStorageItem(`${this.pageType}_alphabetBarCollapsed`,
alphabetBarContainer.classList.contains('collapsed'));
// Update toggle indicator
this.updateToggleIndicator();
});
}
// Add keyboard shortcut listeners
document.addEventListener('keydown', (e) => {
// Alt + letter shortcuts
if (e.altKey && !e.ctrlKey && !e.metaKey) {
const key = e.key.toUpperCase();
// Check if it's a letter A-Z
if (/^[A-Z]$/.test(key)) {
const letterChip = document.querySelector(`.letter-chip[data-letter="${key}"]`);
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
}
// Special cases for non-letter filters
else if (e.key === '0' || e.key === ')') {
// Alt+0 for numbers (#)
const letterChip = document.querySelector('.letter-chip[data-letter="#"]');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
} else if (e.key === '2' || e.key === '@') {
// Alt+@ for special characters
const letterChip = document.querySelector('.letter-chip[data-letter="@"]');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
} else if (e.key === 'c' || e.key === 'C') {
// Alt+C for CJK characters
const letterChip = document.querySelector('.letter-chip[data-letter="漢"]');
if (letterChip && !letterChip.classList.contains('disabled')) {
this.handleLetterClick(letterChip);
e.preventDefault();
}
}
}
});
}
}
/**
* Restore the collapse state from storage
*/
restoreCollapseState() {
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
if (alphabetBarContainer) {
const isCollapsed = getStorageItem(`${this.pageType}_alphabetBarCollapsed`);
// If there's a stored preference, apply it
if (isCollapsed !== null) {
if (isCollapsed) {
alphabetBarContainer.classList.add('collapsed');
} else {
alphabetBarContainer.classList.remove('collapsed');
}
}
}
}
/**
* Handle letter chip click
* @param {HTMLElement} letterChip - The letter chip that was clicked
*/
handleLetterClick(letterChip) {
const letter = letterChip.dataset.letter;
const wasActive = letterChip.classList.contains('active');
// Remove active class from all letter chips
document.querySelectorAll('.letter-chip').forEach(chip => {
chip.classList.remove('active');
});
if (!wasActive) {
// Set the new active letter
letterChip.classList.add('active');
this.pageState.activeLetterFilter = letter;
// Save to storage
setStorageItem(`${this.pageType}_activeLetterFilter`, letter);
} else {
// Clear the active letter filter
this.pageState.activeLetterFilter = null;
// Remove from storage
setStorageItem(`${this.pageType}_activeLetterFilter`, null);
}
// Update visual indicator on toggle button
this.updateToggleIndicator();
// Trigger a reload with the new filter
resetAndReload(true);
}
/**
* Restore the active letter filter from storage
*/
restoreActiveLetterFilter() {
const activeLetterFilter = getStorageItem(`${this.pageType}_activeLetterFilter`);
if (activeLetterFilter) {
const letterChip = document.querySelector(`.letter-chip[data-letter="${activeLetterFilter}"]`);
if (letterChip && !letterChip.classList.contains('disabled')) {
letterChip.classList.add('active');
this.pageState.activeLetterFilter = activeLetterFilter;
// Scroll the active letter into view if the alphabet bar is expanded
this.scrollActiveLetterIntoView();
}
}
}
/**
* Clear the active letter filter
*/
clearActiveLetterFilter() {
// Remove active class from all letter chips
document.querySelectorAll('.letter-chip').forEach(chip => {
chip.classList.remove('active');
});
// Clear the active letter filter
this.pageState.activeLetterFilter = null;
// Remove from storage
setStorageItem(`${this.pageType}_activeLetterFilter`, null);
// Update the toggle button indicator
this.updateToggleIndicator();
}
/**
* Update letter counts with new data
* @param {Object} newCounts - New letter count data
*/
updateCounts(newCounts) {
this.letterCounts = { ...newCounts };
this.updateLetterCountsDisplay();
}
/**
* Update the toggle button visual indicator based on active filter
*/
updateToggleIndicator() {
const toggleButton = document.querySelector('.toggle-alphabet-bar');
const hasActiveFilter = this.pageState.activeLetterFilter !== null;
if (toggleButton) {
if (hasActiveFilter) {
toggleButton.classList.add('has-active-letter');
} else {
toggleButton.classList.remove('has-active-letter');
}
}
}
/**
* Scroll the active letter into view if the alphabet bar is expanded
*/
scrollActiveLetterIntoView() {
if (!this.pageState.activeLetterFilter) return;
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
if (alphabetBarContainer) {
const activeLetterChip = document.querySelector(`.letter-chip.active`);
if (activeLetterChip) {
// Use a small timeout to ensure the alphabet bar is fully expanded
setTimeout(() => {
activeLetterChip.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}, 300);
}
}
}
}

View File

@@ -0,0 +1,14 @@
// Alphabet component index file
import { AlphabetBar } from './AlphabetBar.js';
// Export the class
export { AlphabetBar };
/**
* Factory function to create the appropriate alphabet bar
* @param {string} pageType - The type of page ('loras' or 'checkpoints')
* @returns {AlphabetBar} - The alphabet bar instance
*/
export function createAlphabetBar(pageType) {
return new AlphabetBar(pageType);
}

View File

@@ -171,12 +171,13 @@ export function setupBaseModelEditing(filePath) {
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.UNKNOWN
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.UNKNOWN
]
};

View File

@@ -322,8 +322,72 @@ function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
const mediaElement = wrapper.querySelector('img, video');
if (!metadataPanel || !mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
// Only hide panel when mouse leaves the wrapper and not over the metadata panel
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
}
});
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
@@ -352,11 +416,61 @@ function initMetadataPanelHandlers(container) {
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
e.stopPropagation();
});
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
});
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @returns {Object} - Rect with left, top, right, bottom coordinates
*/
function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
// Get natural dimensions of the media
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
if (!naturalWidth || !naturalHeight) {
// Fallback if dimensions cannot be determined
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
}
// Calculate aspect ratios
const containerRatio = containerWidth / containerHeight;
const mediaRatio = naturalWidth / naturalHeight;
let renderedWidth, renderedHeight, left = 0, top = 0;
// Apply object-fit: contain logic
if (containerRatio > mediaRatio) {
// Container is wider than media - will have empty space on sides
renderedHeight = containerHeight;
renderedWidth = renderedHeight * mediaRatio;
left = (containerWidth - renderedWidth) / 2;
} else {
// Container is taller than media - will have empty space top/bottom
renderedWidth = containerWidth;
renderedHeight = renderedWidth / mediaRatio;
top = (containerHeight - renderedHeight) / 2;
}
return {
left,
top,
right: left + renderedWidth,
bottom: top + renderedHeight
};
}
/**
* Initialize blur toggle handlers
*/

View File

@@ -2,6 +2,7 @@
import { PageControls } from './PageControls.js';
import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js';
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { createAlphabetBar } from '../alphabet/index.js';
/**
* LorasControls class - Extends PageControls for LoRA-specific functionality
@@ -16,6 +17,9 @@ export class LorasControls extends PageControls {
// Check for custom filters (e.g., from recipe navigation)
this.checkCustomFilters();
// Initialize alphabet bar component
this.initAlphabetBar();
}
/**
@@ -142,4 +146,15 @@ export class LorasControls extends PageControls {
_truncateText(text, maxLength) {
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
}
/**
* Initialize the alphabet bar component
*/
initAlphabetBar() {
// Create the alphabet bar component
this.alphabetBar = createAlphabetBar('loras');
// Expose the alphabet bar to the global scope for debugging
window.alphabetBar = this.alphabetBar;
}
}

View File

@@ -173,12 +173,13 @@ export function setupBaseModelEditing(filePath) {
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW,
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.UNKNOWN
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
BASE_MODELS.UNKNOWN
]
};

View File

@@ -329,9 +329,72 @@ function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
const mediaElement = wrapper.querySelector('img, video');
if (!metadataPanel || !mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel when over media content
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
// Only hide panel when mouse leaves the wrapper and not over the metadata panel
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
}
});
// Prevent events from the metadata panel from bubbling
metadataPanel.addEventListener('click', (e) => {
@@ -371,6 +434,50 @@ function initMetadataPanelHandlers(container) {
});
}
/**
* Get the actual rendered rectangle of a media element with object-fit: contain
* @param {HTMLElement} mediaElement - The img or video element
* @param {number} containerWidth - Width of the container
* @param {number} containerHeight - Height of the container
* @returns {Object} - Rect with left, top, right, bottom coordinates
*/
function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
// Get natural dimensions of the media
const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
if (!naturalWidth || !naturalHeight) {
// Fallback if dimensions cannot be determined
return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
}
// Calculate aspect ratios
const containerRatio = containerWidth / containerHeight;
const mediaRatio = naturalWidth / naturalHeight;
let renderedWidth, renderedHeight, left = 0, top = 0;
// Apply object-fit: contain logic
if (containerRatio > mediaRatio) {
// Container is wider than media - will have empty space on sides
renderedHeight = containerHeight;
renderedWidth = renderedHeight * mediaRatio;
left = (containerWidth - renderedWidth) / 2;
} else {
// Container is taller than media - will have empty space top/bottom
renderedWidth = containerWidth;
renderedHeight = renderedWidth / mediaRatio;
top = (containerHeight - renderedHeight) / 2;
}
return {
left,
top,
right: left + renderedWidth,
bottom: top + renderedHeight
};
}
/**
* 初始化模糊切换处理
*/

View File

@@ -8,7 +8,7 @@ import { DownloadManager } from './managers/DownloadManager.js';
import { moveManager } from './managers/MoveManager.js';
import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
// Initialize the LoRA page
class LoraPageManager {
@@ -35,6 +35,8 @@ class LoraPageManager {
window.showLoraModal = showLoraModal;
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.confirmExclude = confirmExclude;
window.closeExcludeModal = closeExcludeModal;
window.downloadManager = this.downloadManager;
window.moveManager = moveManager;
window.toggleShowcase = toggleShowcase;

View File

@@ -59,6 +59,19 @@ export class ModalManager {
}
});
}
// Add excludeModal registration
const excludeModal = document.getElementById('excludeModal');
if (excludeModal) {
this.registerModal('excludeModal', {
element: excludeModal,
onClose: () => {
this.getModal('excludeModal').element.classList.remove('show');
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add downloadModal registration
const downloadModal = document.getElementById('downloadModal');
@@ -208,7 +221,7 @@ export class ModalManager {
// Store current scroll position before showing modal
this.scrollPosition = window.scrollY;
if (id === 'deleteModal') {
if (id === 'deleteModal' || id === 'excludeModal') {
modal.element.classList.add('show');
} else {
modal.element.style.display = 'block';

View File

@@ -27,6 +27,7 @@ export const state = {
hasMore: true,
sortBy: 'name',
activeFolder: null,
activeLetterFilter: null, // New property for letter filtering
previewVersions: loraPreviewVersions,
searchManager: null,
searchOptions: {

View File

@@ -33,9 +33,11 @@ export const BASE_MODELS = {
NOOBAI: "NoobAI",
ILLUSTRIOUS: "Illustrious",
PONY: "Pony",
HIDREAM: "HiDream",
// Video models
SVD: "SVD",
LTXV: "LTXV",
WAN_VIDEO: "Wan Video",
HUNYUAN_VIDEO: "Hunyuan Video",
@@ -69,6 +71,7 @@ export const BASE_MODEL_CLASSES = {
// Video models
[BASE_MODELS.SVD]: "svd",
[BASE_MODELS.LTXV]: "ltxv",
[BASE_MODELS.WAN_VIDEO]: "wan-video",
[BASE_MODELS.HUNYUAN_VIDEO]: "hunyuan-video",
@@ -84,6 +87,7 @@ export const BASE_MODEL_CLASSES = {
[BASE_MODELS.NOOBAI]: "noobai",
[BASE_MODELS.ILLUSTRIOUS]: "il",
[BASE_MODELS.PONY]: "pony",
[BASE_MODELS.HIDREAM]: "hidream",
// Default
[BASE_MODELS.UNKNOWN]: "unknown"

View File

@@ -1,15 +1,18 @@
import { modalManager } from '../managers/ModalManager.js';
import { excludeLora, deleteModel as deleteLora } from '../api/loraApi.js';
import { excludeCheckpoint, deleteCheckpoint } from '../api/checkpointApi.js';
let pendingDeletePath = null;
let pendingModelType = null;
let pendingExcludePath = null;
let pendingExcludeModelType = null;
export function showDeleteModal(filePath, modelType = 'lora') {
// event.stopPropagation();
pendingDeletePath = filePath;
pendingModelType = modelType;
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const modelName = card.dataset.name;
const modelName = card ? card.dataset.name : filePath.split('/').pop();
const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info');
@@ -28,31 +31,19 @@ export async function confirmDelete() {
const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`);
try {
// Use the appropriate endpoint based on model type
const endpoint = pendingModelType === 'checkpoint' ?
'/api/checkpoints/delete' :
'/api/delete_model';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: pendingDeletePath
})
});
if (response.ok) {
if (card) {
card.remove();
}
closeDeleteModal();
// Use appropriate delete function based on model type
if (pendingModelType === 'checkpoint') {
await deleteCheckpoint(pendingDeletePath);
} else {
const error = await response.text();
alert(`Failed to delete model: ${error}`);
await deleteLora(pendingDeletePath);
}
if (card) {
card.remove();
}
closeDeleteModal();
} catch (error) {
console.error('Error deleting model:', error);
alert(`Error deleting model: ${error}`);
}
}
@@ -61,4 +52,46 @@ export function closeDeleteModal() {
modalManager.closeModal('deleteModal');
pendingDeletePath = null;
pendingModelType = null;
}
// Functions for the exclude modal
export function showExcludeModal(filePath, modelType = 'lora') {
pendingExcludePath = filePath;
pendingExcludeModelType = modelType;
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const modelName = card ? card.dataset.name : filePath.split('/').pop();
const modal = modalManager.getModal('excludeModal').element;
const modelInfo = modal.querySelector('.exclude-model-info');
modelInfo.innerHTML = `
<strong>Model:</strong> ${modelName}
<br>
<strong>File:</strong> ${filePath}
`;
modalManager.showModal('excludeModal');
}
export function closeExcludeModal() {
modalManager.closeModal('excludeModal');
pendingExcludePath = null;
pendingExcludeModelType = null;
}
export async function confirmExclude() {
if (!pendingExcludePath) return;
try {
// Use appropriate exclude function based on model type
if (pendingExcludeModelType === 'checkpoint') {
await excludeCheckpoint(pendingExcludePath);
} else {
await excludeLora(pendingExcludePath);
}
closeExcludeModal();
} catch (error) {
console.error('Error excluding model:', error);
}
}

View File

@@ -15,7 +15,7 @@
{% include 'components/checkpoint_modals.html' %}
<div id="checkpointContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div>
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div>
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div>
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
@@ -23,6 +23,7 @@
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
<div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> Move to Folder</div>
<div class="context-menu-item" data-action="exclude"><i class="fas fa-eye-slash"></i> Exclude Model</div>
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> Delete Model</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,22 @@
<div class="alphabet-bar-container collapsed">
<div class="alphabet-bar">
<!-- <span class="alphabet-bar-title">Filter by</span> -->
<div class="letter-chip" data-letter="#" title="Numbers">
#<span class="count"></span>
</div>
{% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
<div class="letter-chip" data-letter="{{ letter }}" title="{{ letter }}">
{{ letter }}<span class="count"></span>
</div>
{% endfor %}
<div class="letter-chip" data-letter="@" title="Special characters">
@<span class="count"></span>
</div>
<div class="letter-chip" data-letter="漢" title="CJK characters">
<span class="count"></span>
</div>
</div>
<div class="toggle-alphabet-bar" title="Toggle alphabet filter">
<i class="fas fa-chevron-right"></i>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<div id="loraContextMenu" class="context-menu">
<div class="context-menu-item" data-action="detail">
<!-- <div class="context-menu-item" data-action="detail">
<i class="fas fa-info-circle"></i> Show Details
</div>
</div> -->
<div class="context-menu-item" data-action="civitai">
<i class="fas fa-external-link-alt"></i> View on Civitai
</div>
@@ -21,6 +21,9 @@
<div class="context-menu-item" data-action="move">
<i class="fas fa-folder-open"></i> Move to Folder
</div>
<div class="context-menu-item" data-action="exclude">
<i class="fas fa-eye-slash"></i> Exclude Model
</div>
<div class="context-menu-item delete-item" data-action="delete">
<i class="fas fa-trash"></i> Delete Model
</div>

View File

@@ -11,6 +11,19 @@
</div>
</div>
<!-- Exclude Confirmation Modal -->
<div id="excludeModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2>Exclude Model</h2>
<p class="delete-message">Are you sure you want to exclude this model? Excluded models won't appear in searches or model lists.</p>
<div class="exclude-model-info"></div>
<div class="modal-actions">
<button class="cancel-btn" onclick="closeExcludeModal()">Cancel</button>
<button class="exclude-btn" onclick="confirmExclude()">Exclude</button>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content settings-modal">
@@ -229,6 +242,20 @@
<span>Support on Ko-fi</span>
</a>
</div>
<!-- New section for Chinese payment methods -->
<div class="support-section">
<h3><i class="fas fa-qrcode"></i> WeChat Support</h3>
<p>For users in China, you can support via WeChat:</p>
<button class="secondary-btn qrcode-toggle" id="toggleQRCode">
<i class="fas fa-qrcode"></i>
<span class="toggle-text">Show WeChat QR Code</span>
<i class="fas fa-chevron-down toggle-icon"></i>
</button>
<div class="qrcode-container" id="qrCodeContainer">
<img src="/loras_static/images/wechat-qr.webp" alt="WeChat Pay QR Code" class="qrcode-image">
</div>
</div>
<div class="support-footer">
<p>Thank you for using LoRA Manager! ❤️</p>

View File

@@ -19,6 +19,7 @@
{% block content %}
{% include 'components/controls.html' %}
{% include 'components/alphabet_bar.html' %}
<!-- Lora卡片容器 -->
<div class="card-grid" id="loraGrid">
<!-- Cards will be dynamically inserted here -->

View File

@@ -18,7 +18,7 @@
{% include 'components/recipe_modal.html' %}
<div id="recipeContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div>
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> Share Recipe</div>
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> Copy Recipe Syntax</div>
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> View All LoRAs</div>