mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-24 06:32:12 -03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e6e4f3dc | ||
|
|
fba2853773 | ||
|
|
48df7e1078 | ||
|
|
235dcd5fa6 | ||
|
|
2027db7411 | ||
|
|
611dd33c75 | ||
|
|
ec1c92a714 | ||
|
|
6ac78156ac | ||
|
|
e94b74e92d | ||
|
|
2bbec47f63 | ||
|
|
b5ddf4c953 | ||
|
|
44be75aeef | ||
|
|
2c03759b5d | ||
|
|
2e3da03723 | ||
|
|
6e96fbcda7 | ||
|
|
d1fd5b7f27 | ||
|
|
9dbcc105e7 | ||
|
|
5cd5a82ddc | ||
|
|
88c1892dc9 | ||
|
|
3c1b181675 | ||
|
|
6777dc16ca | ||
|
|
3833647dfe |
@@ -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:
|
|||||||
|
|
||||||
[](https://ko-fi.com/pixelpawsai)
|
[](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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
165
static/css/components/alphabet-bar.css
Normal file
165
static/css/components/alphabet-bar.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
BIN
static/images/wechat-qr.webp
Normal file
BIN
static/images/wechat-qr.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
319
static/js/components/alphabet/AlphabetBar.js
Normal file
319
static/js/components/alphabet/AlphabetBar.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
static/js/components/alphabet/index.js
Normal file
14
static/js/components/alphabet/index.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化模糊切换处理
|
* 初始化模糊切换处理
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
22
templates/components/alphabet_bar.html
Normal file
22
templates/components/alphabet_bar.html
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user