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 ## 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 ### 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 * **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 * **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) [![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 ## 💬 Community
Join our Discord community for support, discussions, and updates: 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.on_startup.append(lambda _: routes.initialize_services())
app.router.add_post('/api/delete_model', routes.delete_model) 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/fetch-civitai', routes.fetch_civitai)
app.router.add_post('/api/replace_preview', routes.replace_preview) app.router.add_post('/api/replace_preview', routes.replace_preview)
app.router.add_get('/api/loras', routes.get_loras) app.router.add_get('/api/loras', routes.get_loras)
@@ -69,6 +70,9 @@ class ApiRoutes:
# Add the new trigger words route # Add the new trigger words route
app.router.add_post('/loramanager/get_trigger_words', routes.get_trigger_words) 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 # Add update check routes
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
@@ -78,6 +82,12 @@ class ApiRoutes:
self.scanner = await ServiceRegistry.get_lora_scanner() self.scanner = await ServiceRegistry.get_lora_scanner()
return await ModelRouteUtils.handle_delete_model(request, self.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: async def fetch_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata fetch request""" """Handle CivitAI metadata fetch request"""
if self.scanner is None: if self.scanner is None:
@@ -126,6 +136,9 @@ class ApiRoutes:
tags = request.query.get('tags', None) tags = request.query.get('tags', None)
favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # New parameter 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 # New parameters for recipe filtering
lora_hash = request.query.get('lora_hash', None) lora_hash = request.query.get('lora_hash', None)
lora_hashes = request.query.get('lora_hashes', None) lora_hashes = request.query.get('lora_hashes', None)
@@ -156,7 +169,8 @@ class ApiRoutes:
tags=filters.get('tags', None), tags=filters.get('tags', None),
search_options=search_options, search_options=search_options,
hash_filters=hash_filters, 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 # Get all available folders from cache
@@ -781,11 +795,13 @@ class ApiRoutes:
# Check if we already have the description stored in metadata # Check if we already have the description stored in metadata
description = None description = None
tags = [] tags = []
creator = {}
if file_path: if file_path:
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
metadata = await ModelRouteUtils.load_local_metadata(metadata_path) metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
description = metadata.get('modelDescription') description = metadata.get('modelDescription')
tags = metadata.get('tags', []) tags = metadata.get('tags', [])
creator = metadata.get('creator', {})
# If description is not in metadata, fetch from CivitAI # If description is not in metadata, fetch from CivitAI
if not description: if not description:
@@ -795,6 +811,7 @@ class ApiRoutes:
if (model_metadata): if (model_metadata):
description = model_metadata.get('description') description = model_metadata.get('description')
tags = model_metadata.get('tags', []) tags = model_metadata.get('tags', [])
creator = model_metadata.get('creator', {})
# Save the metadata to file if we have a file path and got metadata # Save the metadata to file if we have a file path and got metadata
if file_path: if file_path:
@@ -804,6 +821,7 @@ class ApiRoutes:
metadata['modelDescription'] = description metadata['modelDescription'] = description
metadata['tags'] = tags metadata['tags'] = tags
metadata['creator'] = creator
with open(metadata_path, 'w', encoding='utf-8') as f: with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, indent=2, ensure_ascii=False) json.dump(metadata, f, indent=2, ensure_ascii=False)
@@ -814,7 +832,8 @@ class ApiRoutes:
return web.json_response({ return web.json_response({
'success': True, 'success': True,
'description': description or "<p>No model description available.</p>", 'description': description or "<p>No model description available.</p>",
'tags': tags 'tags': tags,
'creator': creator
}) })
except Exception as e: except Exception as e:
@@ -1045,3 +1064,23 @@ class ApiRoutes:
"success": False, "success": False,
"error": str(e) "error": str(e)
}, status=500) }, 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 # 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/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/fetch-civitai', self.fetch_civitai)
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview) app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
app.router.add_post('/api/checkpoints/download', self.download_checkpoint) 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: async def delete_model(self, request: web.Request) -> web.Response:
"""Handle checkpoint model deletion request""" """Handle checkpoint model deletion request"""
return await ModelRouteUtils.handle_delete_model(request, self.scanner) 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: async def fetch_civitai(self, request: web.Request) -> web.Response:
"""Handle CivitAI metadata fetch request for checkpoints""" """Handle CivitAI metadata fetch request for checkpoints"""
@@ -653,7 +658,7 @@ class CheckpointsRoutes:
model_type = response.get('type', '') model_type = response.get('type', '')
# Check model type - should be Checkpoint # Check model type - should be Checkpoint
if model_type.lower() != 'checkpoint': if (model_type.lower() != 'checkpoint'):
return web.json_response({ return web.json_response({
'error': f"Model type mismatch. Expected Checkpoint, got {model_type}" 'error': f"Model type mismatch. Expected Checkpoint, got {model_type}"
}, status=400) }, status=400)

View File

@@ -3,8 +3,6 @@ import os
import asyncio import asyncio
import json import json
import time import time
import tkinter as tk
from tkinter import filedialog
import aiohttp import aiohttp
from aiohttp import web from aiohttp import web
from ..services.settings_manager import settings from ..services.settings_manager import settings
@@ -12,6 +10,8 @@ from ..utils.usage_stats import UsageStats
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.constants import EXAMPLE_IMAGE_WIDTH, SUPPORTED_MEDIA_EXTENSIONS 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__) logger = logging.getLogger(__name__)
@@ -27,7 +27,8 @@ download_progress = {
'last_error': None, 'last_error': None,
'start_time': None, 'start_time': None,
'end_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: class MiscRoutes:
@@ -151,6 +152,7 @@ class MiscRoutes:
# Create a copy for JSON serialization # Create a copy for JSON serialization
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({ return web.json_response({
'success': False, 'success': False,
@@ -213,6 +215,7 @@ class MiscRoutes:
# Create a copy for JSON serialization # Create a copy for JSON serialization
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({ return web.json_response({
'success': True, '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 # Create a copy of the progress dict with the set converted to a list for JSON serialization
response_progress = download_progress.copy() response_progress = download_progress.copy()
response_progress['processed_models'] = list(download_progress['processed_models']) response_progress['processed_models'] = list(download_progress['processed_models'])
response_progress['refreshed_models'] = list(download_progress['refreshed_models'])
return web.json_response({ return web.json_response({
'success': True, 'success': True,
@@ -284,6 +288,259 @@ class MiscRoutes:
'error': f"Download is in '{download_progress['status']}' state, cannot resume" 'error': f"Download is in '{download_progress['status']}' state, cannot resume"
}, status=400) }, 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 @staticmethod
async def _download_all_example_images(output_dir, optimize, model_types, delay): async def _download_all_example_images(output_dir, optimize, model_types, delay):
"""Download example images for all models """Download example images for all models
@@ -332,14 +589,14 @@ class MiscRoutes:
for model in cache.raw_data: for model in cache.raw_data:
# Only process models with images and a valid sha256 # Only process models with images and a valid sha256
if model.get('civitai') and model.get('civitai', {}).get('images') and model.get('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 # Update total count
download_progress['total'] = len(all_models) download_progress['total'] = len(all_models)
logger.info(f"Found {download_progress['total']} models with example images") logger.info(f"Found {download_progress['total']} models with example images")
# Process each model # Process each model
for scanner_type, model in all_models: for scanner_type, model, scanner in all_models:
# Check if download is paused # Check if download is paused
while download_progress['status'] == 'paused': while download_progress['status'] == 'paused':
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -349,14 +606,13 @@ class MiscRoutes:
logger.info(f"Download stopped: {download_progress['status']}") logger.info(f"Download stopped: {download_progress['status']}")
break 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: try:
# Update current model info # 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]})" download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already processed # Skip if already processed
@@ -381,156 +637,69 @@ class MiscRoutes:
# First check if we have local example images for this model # First check if we have local example images for this model
local_images_processed = False local_images_processed = False
if model_file_path: if model_file_path:
try: local_images_processed = await MiscRoutes._process_local_example_images(
model_dir_path = os.path.dirname(model_file_path) model_file_path,
local_images = [] model_file_name,
model_name,
# Look for files with pattern: filename.example.*.ext model_dir,
if model_file_name: optimize
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}")
except Exception as e: if local_images_processed:
error_msg = f"Error processing local examples for {model_name}: {str(e)}" # Mark as successfully processed if all local images were processed
logger.error(error_msg) download_progress['processed_models'].add(model_hash)
download_progress['errors'].append(error_msg) logger.info(f"Successfully processed local examples for {model_name}")
download_progress['last_error'] = error_msg
# Continue to remote download if local processing fails
# If we didn't process local images, download from remote # If we didn't process local images, download from remote
if not local_images_processed: if not local_images_processed:
# Download example images # Try to download images
for i, image in enumerate(images, 1): model_success, is_stale_metadata = await MiscRoutes._process_model_images(
image_url = image.get('url') model_hash,
if not image_url: model_name,
continue 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 # Refresh metadata from CivitAI
image_filename = os.path.basename(image_url.split('?')[0]) refresh_success = await MiscRoutes._refresh_model_metadata(
image_ext = os.path.splitext(image_filename)[1].lower() model_hash,
model_name,
scanner_type,
scanner
)
# Handle both images and videos if refresh_success:
is_image = image_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] # Get updated model data
is_video = image_ext in SUPPORTED_MEDIA_EXTENSIONS['videos'] updated_cache = await scanner.get_cached_data()
updated_model = None
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 for item in updated_cache.raw_data:
async with independent_session.get(image_url, timeout=60) as response: if item.get('sha256') == model_hash:
if response.status == 200: updated_model = item
if is_image and optimize: break
# 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
# Add a delay between downloads for remote files only if updated_model and updated_model.get('civitai', {}).get('images'):
await asyncio.sleep(delay) # Try downloading with updated metadata
except Exception as e: logger.info(f"Retrying download with refreshed metadata for {model_name}")
error_msg = f"Error downloading file {image_url}: {str(e)}" updated_images = updated_model.get('civitai', {}).get('images', [])
logger.error(error_msg)
download_progress['errors'].append(error_msg) # Retry download with new images
download_progress['last_error'] = error_msg model_success, _ = await MiscRoutes._process_model_images(
model_success = False # Mark model as failed model_hash,
model_name,
updated_images,
model_dir,
optimize,
independent_session,
delay
)
# Only mark model as processed if all images downloaded successfully # Only mark model as processed if all images downloaded successfully
if model_success: if model_success:
@@ -544,6 +713,7 @@ class MiscRoutes:
with open(progress_file, 'w', encoding='utf-8') as f: with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({ json.dump({
'processed_models': list(download_progress['processed_models']), 'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'], 'completed': download_progress['completed'],
'total': download_progress['total'], 'total': download_progress['total'],
'last_update': time.time() 'last_update': time.time()
@@ -584,6 +754,7 @@ class MiscRoutes:
with open(progress_file, 'w', encoding='utf-8') as f: with open(progress_file, 'w', encoding='utf-8') as f:
json.dump({ json.dump({
'processed_models': list(download_progress['processed_models']), 'processed_models': list(download_progress['processed_models']),
'refreshed_models': list(download_progress['refreshed_models']),
'completed': download_progress['completed'], 'completed': download_progress['completed'],
'total': download_progress['total'], 'total': download_progress['total'],
'last_update': time.time(), 'last_update': time.time(),

View File

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

View File

@@ -267,7 +267,7 @@ class CivitaiClient:
return None, error_msg return None, error_msg
async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]: 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: Args:
model_id: The Civitai model ID model_id: The Civitai model ID
@@ -294,10 +294,14 @@ class CivitaiClient:
# Extract relevant metadata # Extract relevant metadata
metadata = { metadata = {
"description": data.get("description") or "No model description available", "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 return metadata, status_code
else: else:
logger.warning(f"No metadata found for model {model_id}") 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) metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
logger.info(f"Creating LoraMetadata for {file_name}") 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') model_id = version_info.get('modelId')
if model_id: if model_id:
model_metadata, _ = await civitai_client.get_model_metadata(str(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", []) metadata.tags = model_metadata.get("tags", [])
if model_metadata.get("description"): if model_metadata.get("description"):
metadata.modelDescription = 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 # 6. Start download process
result = await self._execute_download( result = await self._execute_download(

View File

@@ -4,6 +4,7 @@ import logging
import asyncio import asyncio
import shutil import shutil
import time import time
import re
from typing import List, Dict, Optional, Set from typing import List, Dict, Optional, Set
from ..utils.models import LoraMetadata from ..utils.models import LoraMetadata
@@ -123,7 +124,7 @@ class LoraScanner(ModelScanner):
folder: str = None, search: str = None, fuzzy_search: bool = False, folder: str = None, search: str = None, fuzzy_search: bool = False,
base_models: list = None, tags: list = None, base_models: list = None, tags: list = None,
search_options: dict = None, hash_filters: dict = 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 """Get paginated and filtered lora data
Args: Args:
@@ -138,6 +139,7 @@ class LoraScanner(ModelScanner):
search_options: Dictionary with search options (filename, modelname, tags, recursive) search_options: Dictionary with search options (filename, modelname, tags, recursive)
hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes) hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes)
favorites_only: Filter for favorite models only favorites_only: Filter for favorite models only
first_letter: Filter by first letter of model name
""" """
cache = await self.get_cached_data() cache = await self.get_cached_data()
@@ -202,6 +204,10 @@ class LoraScanner(ModelScanner):
lora for lora in filtered_data lora for lora in filtered_data
if lora.get('favorite', False) is True 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 # Apply folder filtering
if folder is not None: if folder is not None:
@@ -273,6 +279,101 @@ class LoraScanner(ModelScanner):
return result 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: async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
"""Update file paths in metadata file""" """Update file paths in metadata file"""
try: try:

View File

@@ -38,6 +38,7 @@ class ModelScanner:
self._hash_index = hash_index or ModelHashIndex() self._hash_index = hash_index or ModelHashIndex()
self._tags_count = {} # Dictionary to store tag counts self._tags_count = {} # Dictionary to store tag counts
self._is_initializing = False # Flag to track initialization state self._is_initializing = False # Flag to track initialization state
self._excluded_models = [] # List to track excluded models
# Register this service # Register this service
asyncio.create_task(self._register_service()) asyncio.create_task(self._register_service())
@@ -394,6 +395,9 @@ class ModelScanner:
if file_path in cached_paths: if file_path in cached_paths:
found_paths.add(file_path) found_paths.add(file_path)
continue continue
if file_path in self._excluded_models:
continue
# Try case-insensitive match on Windows # Try case-insensitive match on Windows
if os.name == 'nt': if os.name == 'nt':
@@ -406,7 +410,7 @@ class ModelScanner:
break break
if matched: if matched:
continue continue
# This is a new file to process # This is a new file to process
new_files.append(file_path) new_files.append(file_path)
@@ -586,6 +590,11 @@ class ModelScanner:
model_data = metadata.to_dict() 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) await self._fetch_missing_metadata(file_path, model_data)
rel_path = os.path.relpath(file_path, root_path) rel_path = os.path.relpath(file_path, root_path)
folder = os.path.dirname(rel_path) folder = os.path.dirname(rel_path)
@@ -610,7 +619,10 @@ class ModelScanner:
model_id = str(model_id) model_id = str(model_id)
tags_missing = not model_data.get('tags') or len(model_data.get('tags', [])) == 0 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, "") 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: if needs_metadata_update and model_id:
logger.debug(f"Fetching missing metadata for {file_path} with model ID {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, "")): 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['modelDescription'] = model_metadata['description']
model_data['civitai']['creator'] = model_metadata['creator']
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
with open(metadata_path, 'w', encoding='utf-8') as f: 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) logger.error(f"Error getting model info by name: {e}", exc_info=True)
return None 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: async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
"""Update preview URL in cache for a specific lora """Update preview URL in cache for a specific lora
@@ -913,4 +931,4 @@ class ModelScanner:
if self._cache is None: if self._cache is None:
return False 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 modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite favorite: bool = False # Whether the model is a favorite
exclude: bool = False # Whether to exclude this model from the cache
def __post_init__(self): def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue # Initialize empty lists to avoid mutable default parameter issue

View File

@@ -53,6 +53,7 @@ class ModelRouteUtils:
if model_metadata: if model_metadata:
local_metadata['modelDescription'] = model_metadata.get('description', '') local_metadata['modelDescription'] = model_metadata.get('description', '')
local_metadata['tags'] = model_metadata.get('tags', []) local_metadata['tags'] = model_metadata.get('tags', [])
local_metadata['civitai']['creator'] = model_metadata['creator']
# Update base model # Update base model
local_metadata['base_model'] = determine_base_model(civitai_metadata.get('baseModel')) 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) logger.error(f"Error replacing preview: {e}", exc_info=True)
return web.Response(text=str(e), status=500) 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 @staticmethod
async def handle_download_model(request: web.Request, download_manager: DownloadManager, model_type="lora") -> web.Response: async def handle_download_model(request: web.Request, download_manager: DownloadManager, model_type="lora") -> web.Response:
"""Handle model download request """Handle model download request
@@ -500,4 +560,4 @@ class ModelRouteUtils:
) )
logger.error(f"Error downloading {model_type}: {error_message}") 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] [project]
name = "comfyui-lora-manager" 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." 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"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "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; pointer-events: none;
} }
/* Show metadata panel only on hover */ /* Show metadata panel only when the 'visible' class is added */
.media-wrapper:hover .image-metadata-panel { .media-wrapper .image-metadata-panel.visible {
transform: translateY(0); transform: translateY(0);
opacity: 0.98; opacity: 0.98;
pointer-events: auto; pointer-events: auto;

View File

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

View File

@@ -117,9 +117,50 @@
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 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 { .support-footer {
text-align: center; text-align: center;
margin-top: var(--space-1);
font-style: italic; font-style: italic;
color: var(--text-color); color: var(--text-color);
} }

View File

@@ -21,6 +21,7 @@
@import 'components/filter-indicator.css'; @import 'components/filter-indicator.css';
@import 'components/initialization.css'; @import 'components/initialization.css';
@import 'components/progress-panel.css'; @import 'components/progress-panel.css';
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
.initialization-notice { .initialization-notice {
display: flex; 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) { if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true'); 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 // Add search parameters if there's a search term
if (pageState.filters?.search) { if (pageState.filters?.search) {
@@ -203,13 +208,44 @@ export function replaceModelPreview(filePath, modelType = 'lora') {
} }
// Delete a model (generic) // Delete a model (generic)
export function deleteModel(filePath, modelType = 'lora') { export async function deleteModel(filePath, modelType = 'lora') {
if (modelType === 'checkpoint') { try {
confirmDelete('Are you sure you want to delete this checkpoint?', () => { const endpoint = modelType === 'checkpoint'
performDelete(filePath, modelType); ? '/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 // Private methods
// Upload a preview image // Upload a preview image

View File

@@ -6,7 +6,8 @@ import {
deleteModel as baseDeleteModel, deleteModel as baseDeleteModel,
replaceModelPreview, replaceModelPreview,
fetchCivitaiMetadata, fetchCivitaiMetadata,
refreshSingleModelMetadata refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js'; } from './baseModelApi.js';
// Load more checkpoints with pagination // Load more checkpoints with pagination
@@ -85,4 +86,13 @@ export async function saveModelMetadata(filePath, data) {
} }
return response.json(); 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, deleteModel as baseDeleteModel,
replaceModelPreview, replaceModelPreview,
fetchCivitaiMetadata, fetchCivitaiMetadata,
refreshSingleModelMetadata refreshSingleModelMetadata,
excludeModel as baseExcludeModel
} from './baseModelApi.js'; } from './baseModelApi.js';
/** /**
@@ -34,6 +35,15 @@ export async function saveModelMetadata(filePath, data) {
return response.json(); 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) { export async function loadMoreLoras(resetPage = false, updateFolders = false) {
return loadMoreModels({ return loadMoreModels({
resetPage, resetPage,

View File

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

View File

@@ -3,6 +3,7 @@ import { state } from '../state/index.js';
import { showCheckpointModal } from './checkpointModal/index.js'; import { showCheckpointModal } from './checkpointModal/index.js';
import { NSFW_LEVELS } from '../utils/constants.js'; import { NSFW_LEVELS } from '../utils/constants.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js'; import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js';
import { showDeleteModal } from '../utils/modalUtils.js';
export function createCheckpointCard(checkpoint) { export function createCheckpointCard(checkpoint) {
const card = document.createElement('div'); const card = document.createElement('div');
@@ -262,7 +263,7 @@ export function createCheckpointCard(checkpoint) {
// Delete button click event // Delete button click event
card.querySelector('.fa-trash')?.addEventListener('click', e => { card.querySelector('.fa-trash')?.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
deleteCheckpoint(checkpoint.file_path); showDeleteModal(checkpoint.file_path);
}); });
// Replace preview button click event // 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) { function replaceCheckpointPreview(filePath) {
if (window.replaceCheckpointPreview) { if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath); window.replaceCheckpointPreview(filePath);

View File

@@ -3,6 +3,7 @@ import { refreshSingleCheckpointMetadata, saveModelMetadata } from '../../api/ch
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js'; import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js'; import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js';
export class CheckpointContextMenu extends BaseContextMenu { export class CheckpointContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -61,6 +62,10 @@ export class CheckpointContextMenu extends BaseContextMenu {
// Move to folder (placeholder) // Move to folder (placeholder)
showToast('Move to folder feature coming soon', 'info'); showToast('Move to folder feature coming soon', 'info');
break; 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 { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js'; import { getStorageItem } from '../../utils/storageHelpers.js';
import { showExcludeModal } from '../../utils/modalUtils.js';
export class LoraContextMenu extends BaseContextMenu { export class LoraContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -51,6 +52,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'set-nsfw': case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard); this.showNSFWLevelSelector(null, null, this.currentCard);
break; break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
} }
} }

View File

@@ -78,5 +78,33 @@ export class HeaderManager {
// Handle support panel logic // 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 { showLoraModal } from './loraModal/index.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS } from '../utils/constants.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) { export function createLoraCard(lora) {
const card = document.createElement('div'); const card = document.createElement('div');
@@ -260,7 +261,7 @@ export function createLoraCard(lora) {
// Delete button click event // Delete button click event
card.querySelector('.fa-trash')?.addEventListener('click', e => { card.querySelector('.fa-trash')?.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
deleteModel(lora.file_path); showDeleteModal(lora.file_path);
}); });
// Replace preview button click event // 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 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], '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], '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': [ 'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW, 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.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, 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'); const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => { mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel'); 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 // Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => { metadataPanel.addEventListener('click', (e) => {
@@ -352,11 +416,61 @@ function initMetadataPanelHandlers(container) {
// Prevent panel scroll from causing modal scroll // Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => { 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 * Initialize blur toggle handlers
*/ */

View File

@@ -2,6 +2,7 @@
import { PageControls } from './PageControls.js'; import { PageControls } from './PageControls.js';
import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js'; import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js';
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { createAlphabetBar } from '../alphabet/index.js';
/** /**
* LorasControls class - Extends PageControls for LoRA-specific functionality * 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) // Check for custom filters (e.g., from recipe navigation)
this.checkCustomFilters(); this.checkCustomFilters();
// Initialize alphabet bar component
this.initAlphabetBar();
} }
/** /**
@@ -142,4 +146,15 @@ export class LorasControls extends PageControls {
_truncateText(text, maxLength) { _truncateText(text, maxLength) {
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text; 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 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], '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], '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': [ 'Other Models': [
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW, 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.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, 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'); const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(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'); 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 // Prevent events from the metadata panel from bubbling
metadataPanel.addEventListener('click', (e) => { 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 { moveManager } from './managers/MoveManager.js';
import { LoraContextMenu } from './components/ContextMenu/index.js'; import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/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 // Initialize the LoRA page
class LoraPageManager { class LoraPageManager {
@@ -35,6 +35,8 @@ class LoraPageManager {
window.showLoraModal = showLoraModal; window.showLoraModal = showLoraModal;
window.confirmDelete = confirmDelete; window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal; window.closeDeleteModal = closeDeleteModal;
window.confirmExclude = confirmExclude;
window.closeExcludeModal = closeExcludeModal;
window.downloadManager = this.downloadManager; window.downloadManager = this.downloadManager;
window.moveManager = moveManager; window.moveManager = moveManager;
window.toggleShowcase = toggleShowcase; 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 // Add downloadModal registration
const downloadModal = document.getElementById('downloadModal'); const downloadModal = document.getElementById('downloadModal');
@@ -208,7 +221,7 @@ export class ModalManager {
// Store current scroll position before showing modal // Store current scroll position before showing modal
this.scrollPosition = window.scrollY; this.scrollPosition = window.scrollY;
if (id === 'deleteModal') { if (id === 'deleteModal' || id === 'excludeModal') {
modal.element.classList.add('show'); modal.element.classList.add('show');
} else { } else {
modal.element.style.display = 'block'; modal.element.style.display = 'block';

View File

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

View File

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

View File

@@ -1,15 +1,18 @@
import { modalManager } from '../managers/ModalManager.js'; 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 pendingDeletePath = null;
let pendingModelType = null; let pendingModelType = null;
let pendingExcludePath = null;
let pendingExcludeModelType = null;
export function showDeleteModal(filePath, modelType = 'lora') { export function showDeleteModal(filePath, modelType = 'lora') {
// event.stopPropagation();
pendingDeletePath = filePath; pendingDeletePath = filePath;
pendingModelType = modelType; pendingModelType = modelType;
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); 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 modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info'); const modelInfo = modal.querySelector('.delete-model-info');
@@ -28,31 +31,19 @@ export async function confirmDelete() {
const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`); const card = document.querySelector(`.lora-card[data-filepath="${pendingDeletePath}"]`);
try { try {
// Use the appropriate endpoint based on model type // Use appropriate delete function based on model type
const endpoint = pendingModelType === 'checkpoint' ? if (pendingModelType === 'checkpoint') {
'/api/checkpoints/delete' : await deleteCheckpoint(pendingDeletePath);
'/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();
} else { } else {
const error = await response.text(); await deleteLora(pendingDeletePath);
alert(`Failed to delete model: ${error}`);
} }
if (card) {
card.remove();
}
closeDeleteModal();
} catch (error) { } catch (error) {
console.error('Error deleting model:', error);
alert(`Error deleting model: ${error}`); alert(`Error deleting model: ${error}`);
} }
} }
@@ -61,4 +52,46 @@ export function closeDeleteModal() {
modalManager.closeModal('deleteModal'); modalManager.closeModal('deleteModal');
pendingDeletePath = null; pendingDeletePath = null;
pendingModelType = 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' %} {% include 'components/checkpoint_modals.html' %}
<div id="checkpointContextMenu" class="context-menu" style="display: none;"> <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="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="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> <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-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-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="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 class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> Delete Model</div>
</div> </div>
{% endblock %} {% 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 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 <i class="fas fa-info-circle"></i> Show Details
</div> </div> -->
<div class="context-menu-item" data-action="civitai"> <div class="context-menu-item" data-action="civitai">
<i class="fas fa-external-link-alt"></i> View on Civitai <i class="fas fa-external-link-alt"></i> View on Civitai
</div> </div>
@@ -21,6 +21,9 @@
<div class="context-menu-item" data-action="move"> <div class="context-menu-item" data-action="move">
<i class="fas fa-folder-open"></i> Move to Folder <i class="fas fa-folder-open"></i> Move to Folder
</div> </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"> <div class="context-menu-item delete-item" data-action="delete">
<i class="fas fa-trash"></i> Delete Model <i class="fas fa-trash"></i> Delete Model
</div> </div>

View File

@@ -11,6 +11,19 @@
</div> </div>
</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 --> <!-- Settings Modal -->
<div id="settingsModal" class="modal"> <div id="settingsModal" class="modal">
<div class="modal-content settings-modal"> <div class="modal-content settings-modal">
@@ -229,6 +242,20 @@
<span>Support on Ko-fi</span> <span>Support on Ko-fi</span>
</a> </a>
</div> </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"> <div class="support-footer">
<p>Thank you for using LoRA Manager! ❤️</p> <p>Thank you for using LoRA Manager! ❤️</p>

View File

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

View File

@@ -18,7 +18,7 @@
{% include 'components/recipe_modal.html' %} {% include 'components/recipe_modal.html' %}
<div id="recipeContextMenu" class="context-menu" style="display: none;"> <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="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="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> <div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> View All LoRAs</div>