mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 13:42:12 -03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64c9e4aeca | ||
|
|
08b90e8767 | ||
|
|
0206613f9e | ||
|
|
ae0629628e | ||
|
|
785b2e7287 | ||
|
|
43e3d0552e | ||
|
|
801aa2e876 | ||
|
|
bddc7a438d | ||
|
|
b8c78a68e7 | ||
|
|
49219f4447 |
@@ -20,6 +20,12 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.8.5
|
||||
* **Enhanced LoRA & Recipe Connectivity** - Added Recipes tab in LoRA details to see all recipes using a specific LoRA
|
||||
* **Improved Navigation** - New shortcuts to jump between related LoRAs and Recipes with one-click navigation
|
||||
* **Video Preview Controls** - Added "Autoplay Videos on Hover" setting to optimize performance and reduce resource usage
|
||||
* **UI Experience Refinements** - Smoother transitions between related content pages
|
||||
|
||||
### v0.8.4
|
||||
* **Node Layout Improvements** - Fixed layout issues with LoRA Loader and Trigger Words Toggle nodes in newer ComfyUI frontend versions
|
||||
* **Recipe LoRA Reconnection** - Added ability to reconnect deleted LoRAs in recipes by clicking the "deleted" badge in recipe details
|
||||
|
||||
@@ -132,13 +132,9 @@ class ApiRoutes:
|
||||
page = int(request.query.get('page', '1'))
|
||||
page_size = int(request.query.get('page_size', '20'))
|
||||
sort_by = request.query.get('sort_by', 'name')
|
||||
folder = request.query.get('folder')
|
||||
search = request.query.get('search', '').lower()
|
||||
fuzzy = request.query.get('fuzzy', 'false').lower() == 'true'
|
||||
|
||||
# Parse base models filter parameter
|
||||
base_models = request.query.get('base_models', '').split(',')
|
||||
base_models = [model.strip() for model in base_models if model.strip()]
|
||||
folder = request.query.get('folder', None)
|
||||
search = request.query.get('search', None)
|
||||
fuzzy_search = request.query.get('fuzzy', 'false').lower() == 'true'
|
||||
|
||||
# Parse search options
|
||||
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
|
||||
@@ -146,62 +142,68 @@ class ApiRoutes:
|
||||
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
|
||||
recursive = request.query.get('recursive', 'false').lower() == 'true'
|
||||
|
||||
# Validate parameters
|
||||
if page < 1 or page_size < 1 or page_size > 100:
|
||||
return web.json_response({
|
||||
'error': 'Invalid pagination parameters'
|
||||
}, status=400)
|
||||
# Get filter parameters
|
||||
base_models = request.query.get('base_models', None)
|
||||
tags = request.query.get('tags', None)
|
||||
|
||||
if sort_by not in ['date', 'name']:
|
||||
return web.json_response({
|
||||
'error': 'Invalid sort parameter'
|
||||
}, status=400)
|
||||
# New parameters for recipe filtering
|
||||
lora_hash = request.query.get('lora_hash', None)
|
||||
lora_hashes = request.query.get('lora_hashes', None)
|
||||
|
||||
# Parse tags filter parameter
|
||||
tags = request.query.get('tags', '').split(',')
|
||||
tags = [tag.strip() for tag in tags if tag.strip()]
|
||||
# Parse filter parameters
|
||||
filters = {}
|
||||
if base_models:
|
||||
filters['base_model'] = base_models.split(',')
|
||||
if tags:
|
||||
filters['tags'] = tags.split(',')
|
||||
|
||||
# Get paginated data with search and filters
|
||||
result = await self.scanner.get_paginated_data(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
# Add search options to filters
|
||||
search_options = {
|
||||
'filename': search_filename,
|
||||
'modelname': search_modelname,
|
||||
'tags': search_tags,
|
||||
'recursive': recursive
|
||||
}
|
||||
|
||||
# Add lora hash filtering options
|
||||
hash_filters = {}
|
||||
if lora_hash:
|
||||
hash_filters['single_hash'] = lora_hash.lower()
|
||||
elif lora_hashes:
|
||||
hash_filters['multiple_hashes'] = [h.lower() for h in lora_hashes.split(',')]
|
||||
|
||||
# Get file data
|
||||
data = await self.scanner.get_paginated_data(
|
||||
page,
|
||||
page_size,
|
||||
sort_by=sort_by,
|
||||
folder=folder,
|
||||
search=search,
|
||||
fuzzy=fuzzy,
|
||||
base_models=base_models, # Pass base models filter
|
||||
tags=tags, # Add tags parameter
|
||||
search_options={
|
||||
'filename': search_filename,
|
||||
'modelname': search_modelname,
|
||||
'tags': search_tags,
|
||||
'recursive': recursive
|
||||
}
|
||||
fuzzy_search=fuzzy_search,
|
||||
base_models=filters.get('base_model', None),
|
||||
tags=filters.get('tags', None),
|
||||
search_options=search_options,
|
||||
hash_filters=hash_filters
|
||||
)
|
||||
|
||||
# Format the response data
|
||||
formatted_items = [
|
||||
self._format_lora_response(item)
|
||||
for item in result['items']
|
||||
]
|
||||
|
||||
# Get all available folders from cache
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
return web.json_response({
|
||||
'items': formatted_items,
|
||||
'total': result['total'],
|
||||
'page': result['page'],
|
||||
'page_size': result['page_size'],
|
||||
'total_pages': result['total_pages'],
|
||||
'folders': cache.folders
|
||||
})
|
||||
# Convert output to match expected format
|
||||
result = {
|
||||
'items': [self._format_lora_response(lora) for lora in data['items']],
|
||||
'folders': cache.folders,
|
||||
'total': data['total'],
|
||||
'page': data['page'],
|
||||
'page_size': data['page_size'],
|
||||
'total_pages': data['total_pages']
|
||||
}
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_loras: {str(e)}", exc_info=True)
|
||||
return web.json_response({
|
||||
'error': 'Internal server error'
|
||||
}, status=500)
|
||||
logger.error(f"Error retrieving loras: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
def _format_lora_response(self, lora: Dict) -> Dict:
|
||||
"""Format LoRA data for API response"""
|
||||
@@ -831,7 +833,10 @@ class ApiRoutes:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
||||
"""Handle bulk model move request"""
|
||||
|
||||
@@ -60,6 +60,9 @@ class RecipeRoutes:
|
||||
app.on_startup.append(routes._init_cache)
|
||||
|
||||
app.router.add_post('/api/recipes/save-from-widget', routes.save_recipe_from_widget)
|
||||
|
||||
# Add route to get recipes for a specific Lora
|
||||
app.router.add_get('/api/recipes/for-lora', routes.get_recipes_for_lora)
|
||||
|
||||
async def _init_cache(self, app):
|
||||
"""Initialize cache on startup"""
|
||||
@@ -98,6 +101,9 @@ class RecipeRoutes:
|
||||
base_models = request.query.get('base_models', None)
|
||||
tags = request.query.get('tags', None)
|
||||
|
||||
# New parameter: get LoRA hash filter
|
||||
lora_hash = request.query.get('lora_hash', None)
|
||||
|
||||
# Parse filter parameters
|
||||
filters = {}
|
||||
if base_models:
|
||||
@@ -113,14 +119,15 @@ class RecipeRoutes:
|
||||
'lora_model': search_lora_model
|
||||
}
|
||||
|
||||
# Get paginated data
|
||||
# Get paginated data with the new lora_hash parameter
|
||||
result = await self.recipe_scanner.get_paginated_data(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
search=search,
|
||||
filters=filters,
|
||||
search_options=search_options
|
||||
search_options=search_options,
|
||||
lora_hash=lora_hash
|
||||
)
|
||||
|
||||
# Format the response data with static URLs for file paths
|
||||
@@ -148,20 +155,14 @@ class RecipeRoutes:
|
||||
"""Get detailed information about a specific recipe"""
|
||||
try:
|
||||
recipe_id = request.match_info['recipe_id']
|
||||
|
||||
# Get all recipes from cache
|
||||
cache = await self.recipe_scanner.get_cached_data()
|
||||
|
||||
# Find the specific recipe
|
||||
recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None)
|
||||
# Use the new get_recipe_by_id method from recipe_scanner
|
||||
recipe = await self.recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
|
||||
if not recipe:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
# Format recipe data
|
||||
formatted_recipe = self._format_recipe_data(recipe)
|
||||
|
||||
return web.json_response(formatted_recipe)
|
||||
return web.json_response(recipe)
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving recipe details: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -1134,3 +1135,49 @@ class RecipeRoutes:
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconnecting LoRA: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||
"""Get recipes that use a specific Lora"""
|
||||
try:
|
||||
lora_hash = request.query.get('hash')
|
||||
|
||||
# Hash is required
|
||||
if not lora_hash:
|
||||
return web.json_response({'success': False, 'error': 'Lora hash is required'}, status=400)
|
||||
|
||||
# Log the search parameters
|
||||
logger.info(f"Getting recipes for Lora by hash: {lora_hash}")
|
||||
|
||||
# Get all recipes from cache
|
||||
cache = await self.recipe_scanner.get_cached_data()
|
||||
|
||||
# Filter recipes that use this Lora by hash
|
||||
matching_recipes = []
|
||||
for recipe in cache.raw_data:
|
||||
# Check if any of the recipe's loras match this hash
|
||||
loras = recipe.get('loras', [])
|
||||
for lora in loras:
|
||||
if lora.get('hash', '').lower() == lora_hash.lower():
|
||||
matching_recipes.append(recipe)
|
||||
break # No need to check other loras in this recipe
|
||||
|
||||
# Process the recipes similar to get_paginated_data to ensure all needed data is available
|
||||
for recipe in matching_recipes:
|
||||
# Add inLibrary information for each lora
|
||||
if 'loras' in recipe:
|
||||
for lora in recipe['loras']:
|
||||
if 'hash' in lora and lora['hash']:
|
||||
lora['inLibrary'] = self.recipe_scanner._lora_scanner.has_lora_hash(lora['hash'].lower())
|
||||
lora['preview_url'] = self.recipe_scanner._lora_scanner.get_preview_url_by_hash(lora['hash'].lower())
|
||||
lora['localPath'] = self.recipe_scanner._lora_scanner.get_lora_path_by_hash(lora['hash'].lower())
|
||||
|
||||
# Ensure file_url is set (needed by frontend)
|
||||
if 'file_path' in recipe:
|
||||
recipe['file_url'] = self._format_recipe_file_url(recipe['file_path'])
|
||||
else:
|
||||
recipe['file_url'] = '/loras_static/images/no-preview.png'
|
||||
|
||||
return web.json_response({'success': True, 'recipes': matching_recipes})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting recipes for Lora: {str(e)}")
|
||||
return web.json_response({'success': False, 'error': str(e)}, status=500)
|
||||
|
||||
@@ -136,9 +136,9 @@ class LoraScanner:
|
||||
)
|
||||
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
||||
folder: str = None, search: str = None, fuzzy: bool = False,
|
||||
folder: str = None, search: str = None, fuzzy_search: bool = False,
|
||||
base_models: list = None, tags: list = None,
|
||||
search_options: dict = None) -> Dict:
|
||||
search_options: dict = None, hash_filters: dict = None) -> Dict:
|
||||
"""Get paginated and filtered lora data
|
||||
|
||||
Args:
|
||||
@@ -147,10 +147,11 @@ class LoraScanner:
|
||||
sort_by: Sort method ('name' or 'date')
|
||||
folder: Filter by folder path
|
||||
search: Search term
|
||||
fuzzy: Use fuzzy matching for search
|
||||
fuzzy_search: Use fuzzy matching for search
|
||||
base_models: List of base models to filter by
|
||||
tags: List of tags to filter by
|
||||
search_options: Dictionary with search options (filename, modelname, tags, recursive)
|
||||
hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes)
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
@@ -160,90 +161,108 @@ class LoraScanner:
|
||||
'filename': True,
|
||||
'modelname': True,
|
||||
'tags': False,
|
||||
'recursive': False
|
||||
'recursive': False,
|
||||
}
|
||||
|
||||
# Get the base data set
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# Apply hash filtering if provided (highest priority)
|
||||
if hash_filters:
|
||||
single_hash = hash_filters.get('single_hash')
|
||||
multiple_hashes = hash_filters.get('multiple_hashes')
|
||||
|
||||
if single_hash:
|
||||
# Filter by single hash
|
||||
single_hash = single_hash.lower() # Ensure lowercase for matching
|
||||
filtered_data = [
|
||||
lora for lora in filtered_data
|
||||
if lora.get('sha256', '').lower() == single_hash
|
||||
]
|
||||
elif multiple_hashes:
|
||||
# Filter by multiple hashes
|
||||
hash_set = set(hash.lower() for hash in multiple_hashes) # Convert to set for faster lookup
|
||||
filtered_data = [
|
||||
lora for lora in filtered_data
|
||||
if lora.get('sha256', '').lower() in hash_set
|
||||
]
|
||||
|
||||
|
||||
# Jump to pagination
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
|
||||
result = {
|
||||
'items': filtered_data[start_idx:end_idx],
|
||||
'total': total_items,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'total_pages': (total_items + page_size - 1) // page_size
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
# Apply SFW filtering if enabled
|
||||
if settings.get('show_only_sfw', False):
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if not item.get('preview_nsfw_level') or item.get('preview_nsfw_level') < NSFW_LEVELS['R']
|
||||
lora for lora in filtered_data
|
||||
if not lora.get('preview_nsfw_level') or lora.get('preview_nsfw_level') < NSFW_LEVELS['R']
|
||||
]
|
||||
|
||||
# Apply folder filtering
|
||||
if folder is not None:
|
||||
if search_options.get('recursive', False):
|
||||
# Recursive mode: match all paths starting with this folder
|
||||
# Recursive folder filtering - include all subfolders
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'].startswith(folder + '/') or item['folder'] == folder
|
||||
lora for lora in filtered_data
|
||||
if lora['folder'].startswith(folder)
|
||||
]
|
||||
else:
|
||||
# Non-recursive mode: match exact folder
|
||||
# Exact folder filtering
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'] == folder
|
||||
lora for lora in filtered_data
|
||||
if lora['folder'] == folder
|
||||
]
|
||||
|
||||
# Apply base model filtering
|
||||
if base_models and len(base_models) > 0:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item.get('base_model') in base_models
|
||||
lora for lora in filtered_data
|
||||
if lora.get('base_model') in base_models
|
||||
]
|
||||
|
||||
# Apply tag filtering
|
||||
if tags and len(tags) > 0:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(tag in item.get('tags', []) for tag in tags)
|
||||
lora for lora in filtered_data
|
||||
if any(tag in lora.get('tags', []) for tag in tags)
|
||||
]
|
||||
|
||||
# Apply search filtering
|
||||
if search:
|
||||
search_results = []
|
||||
for item in filtered_data:
|
||||
# Check filename if enabled
|
||||
if search_options.get('filename', True):
|
||||
if fuzzy:
|
||||
if fuzzy_match(item.get('file_name', ''), search):
|
||||
search_results.append(item)
|
||||
continue
|
||||
else:
|
||||
if search.lower() in item.get('file_name', '').lower():
|
||||
search_results.append(item)
|
||||
continue
|
||||
|
||||
# Check model name if enabled
|
||||
if search_options.get('modelname', True):
|
||||
if fuzzy:
|
||||
if fuzzy_match(item.get('model_name', ''), search):
|
||||
search_results.append(item)
|
||||
continue
|
||||
else:
|
||||
if search.lower() in item.get('model_name', '').lower():
|
||||
search_results.append(item)
|
||||
continue
|
||||
|
||||
# Check tags if enabled
|
||||
if search_options.get('tags', False) and item.get('tags'):
|
||||
found_tag = False
|
||||
for tag in item['tags']:
|
||||
if fuzzy:
|
||||
if fuzzy_match(tag, search):
|
||||
found_tag = True
|
||||
break
|
||||
else:
|
||||
if search.lower() in tag.lower():
|
||||
found_tag = True
|
||||
break
|
||||
if found_tag:
|
||||
search_results.append(item)
|
||||
search_opts = search_options or {}
|
||||
|
||||
for lora in filtered_data:
|
||||
# Search by file name
|
||||
if search_opts.get('filename', True):
|
||||
if fuzzy_match(lora.get('file_name', ''), search):
|
||||
search_results.append(lora)
|
||||
continue
|
||||
|
||||
# Search by model name
|
||||
if search_opts.get('modelname', True):
|
||||
if fuzzy_match(lora.get('model_name', ''), search):
|
||||
search_results.append(lora)
|
||||
continue
|
||||
|
||||
# Search by tags
|
||||
if search_opts.get('tags', False) and 'tags' in lora:
|
||||
if any(fuzzy_match(tag, search) for tag in lora['tags']):
|
||||
search_results.append(lora)
|
||||
continue
|
||||
|
||||
filtered_data = search_results
|
||||
|
||||
# Calculate pagination
|
||||
|
||||
@@ -330,7 +330,7 @@ class RecipeScanner:
|
||||
logger.error(f"Error getting base model for lora: {e}")
|
||||
return None
|
||||
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None):
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True):
|
||||
"""Get paginated and filtered recipe data
|
||||
|
||||
Args:
|
||||
@@ -340,69 +340,89 @@ class RecipeScanner:
|
||||
search: Search term
|
||||
filters: Dictionary of filters to apply
|
||||
search_options: Dictionary of search options to apply
|
||||
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
||||
bypass_filters: If True, ignore other filters when a lora_hash is provided
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Get base dataset
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
# Default search options if none provided
|
||||
if not search_options:
|
||||
search_options = {
|
||||
'title': True,
|
||||
'tags': True,
|
||||
'lora_name': True,
|
||||
'lora_model': True
|
||||
}
|
||||
# Special case: Filter by LoRA hash (takes precedence if bypass_filters is True)
|
||||
if lora_hash:
|
||||
# Filter recipes that contain this LoRA hash
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if 'loras' in item and any(
|
||||
lora.get('hash', '').lower() == lora_hash.lower()
|
||||
for lora in item['loras']
|
||||
)
|
||||
]
|
||||
|
||||
# Build the search predicate based on search options
|
||||
def matches_search(item):
|
||||
# Search in title if enabled
|
||||
if search_options.get('title', True):
|
||||
if fuzzy_match(str(item.get('title', '')), search):
|
||||
return True
|
||||
|
||||
# Search in tags if enabled
|
||||
if search_options.get('tags', True) and 'tags' in item:
|
||||
for tag in item['tags']:
|
||||
if fuzzy_match(tag, search):
|
||||
return True
|
||||
|
||||
# Search in lora file names if enabled
|
||||
if search_options.get('lora_name', True) and 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if fuzzy_match(str(lora.get('file_name', '')), search):
|
||||
return True
|
||||
|
||||
# Search in lora model names if enabled
|
||||
if search_options.get('lora_model', True) and 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if fuzzy_match(str(lora.get('modelName', '')), search):
|
||||
return True
|
||||
|
||||
# No match found
|
||||
return False
|
||||
|
||||
# Filter the data using the search predicate
|
||||
filtered_data = [item for item in filtered_data if matches_search(item)]
|
||||
if bypass_filters:
|
||||
# Skip other filters if bypass_filters is True
|
||||
pass
|
||||
# Otherwise continue with normal filtering after applying LoRA hash filter
|
||||
|
||||
# Apply additional filters
|
||||
if filters:
|
||||
# Filter by base model
|
||||
if 'base_model' in filters and filters['base_model']:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item.get('base_model', '') in filters['base_model']
|
||||
]
|
||||
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
||||
if not (lora_hash and bypass_filters):
|
||||
# Apply search filter
|
||||
if search:
|
||||
# Default search options if none provided
|
||||
if not search_options:
|
||||
search_options = {
|
||||
'title': True,
|
||||
'tags': True,
|
||||
'lora_name': True,
|
||||
'lora_model': True
|
||||
}
|
||||
|
||||
# Build the search predicate based on search options
|
||||
def matches_search(item):
|
||||
# Search in title if enabled
|
||||
if search_options.get('title', True):
|
||||
if fuzzy_match(str(item.get('title', '')), search):
|
||||
return True
|
||||
|
||||
# Search in tags if enabled
|
||||
if search_options.get('tags', True) and 'tags' in item:
|
||||
for tag in item['tags']:
|
||||
if fuzzy_match(tag, search):
|
||||
return True
|
||||
|
||||
# Search in lora file names if enabled
|
||||
if search_options.get('lora_name', True) and 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if fuzzy_match(str(lora.get('file_name', '')), search):
|
||||
return True
|
||||
|
||||
# Search in lora model names if enabled
|
||||
if search_options.get('lora_model', True) and 'loras' in item:
|
||||
for lora in item['loras']:
|
||||
if fuzzy_match(str(lora.get('modelName', '')), search):
|
||||
return True
|
||||
|
||||
# No match found
|
||||
return False
|
||||
|
||||
# Filter the data using the search predicate
|
||||
filtered_data = [item for item in filtered_data if matches_search(item)]
|
||||
|
||||
# Filter by tags
|
||||
if 'tags' in filters and filters['tags']:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(tag in item.get('tags', []) for tag in filters['tags'])
|
||||
]
|
||||
# Apply additional filters
|
||||
if filters:
|
||||
# Filter by base model
|
||||
if 'base_model' in filters and filters['base_model']:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item.get('base_model', '') in filters['base_model']
|
||||
]
|
||||
|
||||
# Filter by tags
|
||||
if 'tags' in filters and filters['tags']:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(tag in item.get('tags', []) for tag in filters['tags'])
|
||||
]
|
||||
|
||||
# Calculate pagination
|
||||
total_items = len(filtered_data)
|
||||
@@ -430,6 +450,74 @@ class RecipeScanner:
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
async def get_recipe_by_id(self, recipe_id: str) -> dict:
|
||||
"""Get a single recipe by ID with all metadata and formatted URLs
|
||||
|
||||
Args:
|
||||
recipe_id: The ID of the recipe to retrieve
|
||||
|
||||
Returns:
|
||||
Dict containing the recipe data or None if not found
|
||||
"""
|
||||
if not recipe_id:
|
||||
return None
|
||||
|
||||
# Get all recipes from cache
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Find the recipe with the specified ID
|
||||
recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None)
|
||||
|
||||
if not recipe:
|
||||
return None
|
||||
|
||||
# Format the recipe with all needed information
|
||||
formatted_recipe = {**recipe} # Copy all fields
|
||||
|
||||
# Format file path to URL
|
||||
if 'file_path' in formatted_recipe:
|
||||
formatted_recipe['file_url'] = self._format_file_url(formatted_recipe['file_path'])
|
||||
|
||||
# Format dates for display
|
||||
for date_field in ['created_date', 'modified']:
|
||||
if date_field in formatted_recipe:
|
||||
formatted_recipe[f"{date_field}_formatted"] = self._format_timestamp(formatted_recipe[date_field])
|
||||
|
||||
# Add lora metadata
|
||||
if 'loras' in formatted_recipe:
|
||||
for lora in formatted_recipe['loras']:
|
||||
if 'hash' in lora and lora['hash']:
|
||||
lora_hash = lora['hash'].lower()
|
||||
lora['inLibrary'] = self._lora_scanner.has_lora_hash(lora_hash)
|
||||
lora['preview_url'] = self._lora_scanner.get_preview_url_by_hash(lora_hash)
|
||||
lora['localPath'] = self._lora_scanner.get_lora_path_by_hash(lora_hash)
|
||||
|
||||
return formatted_recipe
|
||||
|
||||
def _format_file_url(self, file_path: str) -> str:
|
||||
"""Format file path as URL for serving in web UI"""
|
||||
if not file_path:
|
||||
return '/loras_static/images/no-preview.png'
|
||||
|
||||
try:
|
||||
# Format file path as a URL that will work with static file serving
|
||||
recipes_dir = os.path.join(config.loras_roots[0], "recipes").replace(os.sep, '/')
|
||||
if file_path.replace(os.sep, '/').startswith(recipes_dir):
|
||||
relative_path = os.path.relpath(file_path, config.loras_roots[0]).replace(os.sep, '/')
|
||||
return f"/loras_static/root1/preview/{relative_path}"
|
||||
|
||||
# If not in recipes dir, try to create a valid URL from the file name
|
||||
file_name = os.path.basename(file_path)
|
||||
return f"/loras_static/root1/preview/recipes/{file_name}"
|
||||
except Exception as e:
|
||||
logger.error(f"Error formatting file URL: {e}")
|
||||
return '/loras_static/images/no-preview.png'
|
||||
|
||||
def _format_timestamp(self, timestamp: float) -> str:
|
||||
"""Format timestamp for display"""
|
||||
from datetime import datetime
|
||||
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
|
||||
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||
version = "0.8.4"
|
||||
version = "0.8.5"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
84
static/css/components/filter-indicator.css
Normal file
84
static/css/components/filter-indicator.css
Normal file
@@ -0,0 +1,84 @@
|
||||
/* Filter indicator styles */
|
||||
.control-group .filter-active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 10px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--lora-accent);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.control-group .filter-active:hover {
|
||||
opacity: 0.92;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.control-group .filter-active:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.control-group .filter-active i.fa-filter {
|
||||
font-size: 0.9em;
|
||||
margin-right: 2px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.control-group .filter-active i.clear-filter {
|
||||
transition: transform 0.2s ease, background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
margin-left: 4px;
|
||||
border-radius: 50%;
|
||||
font-size: 0.85em;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.control-group .filter-active i.clear-filter:hover {
|
||||
transform: scale(1.2);
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.control-group .filter-active .lora-name {
|
||||
font-weight: 500;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Animation for filter indicator */
|
||||
@keyframes filterPulse {
|
||||
0% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
||||
50% { transform: scale(1.03); box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); }
|
||||
100% { transform: scale(1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); }
|
||||
}
|
||||
|
||||
.filter-active.animate {
|
||||
animation: filterPulse 0.6s ease;
|
||||
}
|
||||
|
||||
/* Make responsive */
|
||||
@media (max-width: 576px) {
|
||||
.control-group .filter-active {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.control-group .filter-active .lora-name {
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.control-group .filter-active:hover {
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
}
|
||||
@@ -863,7 +863,7 @@
|
||||
}
|
||||
|
||||
.model-description-content blockquote {
|
||||
border-left: 3px solid var(--lora-accent);
|
||||
border-left: 3px solid var (--lora-accent);
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
@@ -1280,4 +1280,47 @@
|
||||
font-size: 1.1em;
|
||||
color: var(--lora-accent);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.view-all-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
background-color: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.view-all-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Loading, error and empty states */
|
||||
.recipes-loading,
|
||||
.recipes-error,
|
||||
.recipes-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.recipes-loading i,
|
||||
.recipes-error i,
|
||||
.recipes-empty i {
|
||||
font-size: 32px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipes-error i {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
@@ -196,7 +196,7 @@ body.modal-open {
|
||||
}
|
||||
|
||||
.settings-modal {
|
||||
max-width: 500px;
|
||||
max-width: 650px; /* Further increased from 600px for more space */
|
||||
}
|
||||
|
||||
/* Settings Links */
|
||||
@@ -266,14 +266,22 @@ body.modal-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* API key input specific styles */
|
||||
.api-key-input {
|
||||
width: 100%; /* Take full width of parent */
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.api-key-input input {
|
||||
padding-right: 40px;
|
||||
width: 100%;
|
||||
padding: 6px 40px 6px 10px; /* Add left padding */
|
||||
height: 32px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.api-key-input .toggle-visibility {
|
||||
@@ -294,8 +302,10 @@ body.modal-open {
|
||||
.input-help {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
opacity: 0.7;
|
||||
margin-top: 8px; /* Space between control and help */
|
||||
line-height: 1.4;
|
||||
width: 100%; /* Full width */
|
||||
}
|
||||
|
||||
/* 统一各个 section 的样式 */
|
||||
@@ -341,8 +351,8 @@ body.modal-open {
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: var(--space-2);
|
||||
flex-direction: column; /* Changed to column for help text placement */
|
||||
margin-bottom: var(--space-3); /* Increased to provide more spacing between items */
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
@@ -355,35 +365,52 @@ body.modal-open {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
margin-bottom: var(--space-1);
|
||||
/* Control row with label and input together */
|
||||
.setting-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
margin-bottom: 0;
|
||||
width: 35%; /* Increased from 30% to prevent wrapping */
|
||||
flex-shrink: 0; /* Prevent shrinking */
|
||||
}
|
||||
|
||||
.setting-info label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap; /* Prevent label wrapping */
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-1);
|
||||
width: 60%; /* Decreased slightly from 65% */
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end; /* Right-align all controls */
|
||||
}
|
||||
|
||||
/* Select Control Styles */
|
||||
.select-control {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.select-control select {
|
||||
width: 100%;
|
||||
max-width: 100%; /* Increased from 200px */
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* Fix dark theme select dropdown text color */
|
||||
@@ -409,6 +436,7 @@ body.modal-open {
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
margin-left: auto; /* Push to right side */
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
@@ -458,15 +486,6 @@ input:checked + .toggle-slider:before {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
/* Update input help styles */
|
||||
.input-help {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Blur effect for NSFW content */
|
||||
.nsfw-blur {
|
||||
filter: blur(12px);
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
.recipe-tag-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recipe-tag {
|
||||
background: var(--lora-surface-hover);
|
||||
color: var(--lora-text-secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-tag:hover, .recipe-tag.active {
|
||||
background: var(--lora-primary);
|
||||
color: var(--lora-text-on-primary);
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
position: relative;
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.2s ease;
|
||||
aspect-ratio: 896/1152;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.recipe-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.recipe-card:focus-visible {
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.recipe-indicator {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--lora-primary);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.recipe-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.placeholder-message {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: var(--lora-surface-alt);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
.card-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--border-radius-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(oklch(0% 0 0 / 0.75), transparent 85%);
|
||||
backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.base-model-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-actions i {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.card-actions i:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent 15%, oklch(0% 0 0 / 0.75));
|
||||
backdrop-filter: blur(8px);
|
||||
color: white;
|
||||
padding: var(--space-1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
min-height: 32px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.lora-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.85em;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lora-count.ready {
|
||||
background: rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.lora-count.missing {
|
||||
background: rgba(231, 76, 60, 0.3);
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1400px) {
|
||||
.recipe-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.recipe-grid {
|
||||
grid-template-columns: minmax(260px, 1fr);
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,27 @@
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* View LoRAs button */
|
||||
.view-loras-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.view-loras-btn:hover {
|
||||
opacity: 1;
|
||||
background: var(--lora-surface);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#recipeLorasCount {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
@@ -420,6 +441,7 @@
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding-top: 4px; /* Add padding to prevent first item from being cut off when hovered */
|
||||
}
|
||||
|
||||
.recipe-lora-item {
|
||||
@@ -433,6 +455,14 @@
|
||||
will-change: transform;
|
||||
/* Create a new containing block for absolutely positioned descendants */
|
||||
transform: translateZ(0);
|
||||
cursor: pointer; /* Make it clear the item is clickable */
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-lora-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipe-lora-item.exists-locally {
|
||||
|
||||
@@ -38,6 +38,90 @@
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Action button styling */
|
||||
.control-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.control-group button {
|
||||
min-width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.control-group button:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.control-group button:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.control-group button i {
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.control-group button:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Active state for buttons that can be toggled */
|
||||
.control-group button.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Select dropdown styling */
|
||||
.control-group select {
|
||||
min-width: 100px;
|
||||
padding: 4px 26px 4px 10px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 6px center;
|
||||
background-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.control-group select:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background-color: var(--bg-color);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.control-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--lora-accent);
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-accent) / 0.15);
|
||||
}
|
||||
|
||||
/* Ensure hidden class works properly */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
@@ -86,12 +170,14 @@
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.toggle-folders-btn:hover {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.toggle-folders-btn i {
|
||||
@@ -101,8 +187,9 @@
|
||||
/* Icon-only button style */
|
||||
.icon-only {
|
||||
min-width: unset !important;
|
||||
width: 36px !important;
|
||||
width: 32px !important;
|
||||
padding: 0 !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
/* Rotate icon when folders are collapsed */
|
||||
@@ -133,16 +220,25 @@
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
margin: 2px;
|
||||
border: 1px solid #ccc;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
display: inline-block;
|
||||
line-height: 1.2;
|
||||
font-size: 14px;
|
||||
background-color: var(--card-bg);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
border-color: var(--lora-accent);
|
||||
background-color: oklch(var(--lora-accent) / 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.tag.active {
|
||||
background-color: #007bff;
|
||||
background-color: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Back to Top Button */
|
||||
@@ -155,7 +251,7 @@
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var (--text-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -165,6 +261,7 @@
|
||||
transform: translateY(10px);
|
||||
transition: all 0.3s ease;
|
||||
z-index: var(--z-overlay);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.back-to-top.visible {
|
||||
@@ -174,9 +271,10 @@
|
||||
}
|
||||
|
||||
.back-to-top:hover {
|
||||
background: var (--lora-accent);
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -203,19 +301,22 @@
|
||||
}
|
||||
|
||||
.toggle-folders-btn:hover {
|
||||
transform: none; /* 移动端下禁用hover效果 */
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
|
||||
.control-group button:hover {
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
|
||||
.control-group select:hover {
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
transform: none; /* Disable hover effects on mobile */
|
||||
}
|
||||
|
||||
.back-to-top {
|
||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
||||
}
|
||||
}
|
||||
|
||||
/* Standardize button widths in controls */
|
||||
.control-group button {
|
||||
min-width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
@import 'components/search-filter.css';
|
||||
@import 'components/bulk.css';
|
||||
@import 'components/shared.css';
|
||||
@import 'components/filter-indicator.css';
|
||||
|
||||
.initialization-notice {
|
||||
display: flex;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createLoraCard } from '../components/LoraCard.js';
|
||||
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||
import { showDeleteModal } from '../utils/modalUtils.js';
|
||||
import { toggleFolder } from '../utils/uiHelpers.js';
|
||||
import { getSessionItem } from '../utils/storageHelpers.js';
|
||||
|
||||
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||
const pageState = getCurrentPageState();
|
||||
@@ -57,6 +58,28 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for recipe-based filtering parameters from session storage
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
|
||||
console.log('Filter Lora Hash:', filterLoraHash);
|
||||
console.log('Filter Lora Hashes:', filterLoraHashes);
|
||||
|
||||
// Add hash filter parameter if present
|
||||
if (filterLoraHash) {
|
||||
params.append('lora_hash', filterLoraHash);
|
||||
}
|
||||
// Add multiple hashes filter if present
|
||||
else if (filterLoraHashes) {
|
||||
try {
|
||||
if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) {
|
||||
params.append('lora_hashes', filterLoraHashes.join(','));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing lora hashes from session storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/loras?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch loras: ${response.statusText}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { showLoraModal } from './LoraModal.js';
|
||||
import { showLoraModal } from './loraModal/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
|
||||
@@ -57,10 +57,15 @@ export function createLoraCard(lora) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
|
||||
// Check if autoplayOnHover is enabled for video previews
|
||||
const autoplayOnHover = state.global.settings.autoplayOnHover || false;
|
||||
const isVideo = previewUrl.endsWith('.mp4');
|
||||
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${previewUrl.endsWith('.mp4') ?
|
||||
`<video controls autoplay muted loop>
|
||||
${isVideo ?
|
||||
`<video ${videoAttrs}>
|
||||
<source src="${versionedPreviewUrl}" type="video/mp4">
|
||||
</video>` :
|
||||
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
|
||||
@@ -246,6 +251,26 @@ export function createLoraCard(lora) {
|
||||
actionGroup.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Add autoplayOnHover handlers for video elements if needed
|
||||
const videoElement = card.querySelector('video');
|
||||
if (videoElement && autoplayOnHover) {
|
||||
const cardPreview = card.querySelector('.card-preview');
|
||||
|
||||
// Remove autoplay attribute and pause initially
|
||||
videoElement.removeAttribute('autoplay');
|
||||
videoElement.pause();
|
||||
|
||||
// Add mouse events to trigger play/pause
|
||||
cardPreview.addEventListener('mouseenter', () => {
|
||||
videoElement.play();
|
||||
});
|
||||
|
||||
cardPreview.addEventListener('mouseleave', () => {
|
||||
videoElement.pause();
|
||||
videoElement.currentTime = 0;
|
||||
});
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Recipe Modal Component
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||
|
||||
class RecipeModal {
|
||||
constructor() {
|
||||
@@ -294,7 +295,7 @@ class RecipeModal {
|
||||
} else {
|
||||
// No generation parameters available
|
||||
if (promptElement) promptElement.textContent = 'No prompt information available';
|
||||
if (negativePromptElement) negativePromptElement.textContent = 'No negative prompt information available';
|
||||
if (negativePromptElement) promptElement.textContent = 'No negative prompt information available';
|
||||
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||
}
|
||||
|
||||
@@ -342,8 +343,15 @@ class RecipeModal {
|
||||
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||
|
||||
// Add click handler for missing LoRAs status
|
||||
// Add event listeners for buttons and status indicators
|
||||
setTimeout(() => {
|
||||
// Set up click handler for View LoRAs button
|
||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||
if (viewRecipeLorasBtn) {
|
||||
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
|
||||
}
|
||||
|
||||
// Add click handler for missing LoRAs status
|
||||
const missingStatus = document.querySelector('.recipe-status.missing');
|
||||
if (missingStatus && missingLorasCount > 0) {
|
||||
missingStatus.classList.add('clickable');
|
||||
@@ -433,6 +441,7 @@ class RecipeModal {
|
||||
// Add event listeners for reconnect functionality
|
||||
setTimeout(() => {
|
||||
this.setupReconnectButtons();
|
||||
this.setupLoraItemsClickable();
|
||||
}, 100);
|
||||
|
||||
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
||||
@@ -1007,6 +1016,65 @@ class RecipeModal {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// New method to navigate to the LoRAs page
|
||||
navigateToLorasPage(specificLoraIndex = null) {
|
||||
// Close the current modal
|
||||
modalManager.closeModal('recipeModal');
|
||||
|
||||
// Clear any previous filters first
|
||||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||||
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
removeSessionItem('filterRecipeName');
|
||||
removeSessionItem('viewLoraDetail');
|
||||
|
||||
if (specificLoraIndex !== null) {
|
||||
// If a specific LoRA index is provided, navigate to view just that one LoRA
|
||||
const lora = this.currentRecipe.loras[specificLoraIndex];
|
||||
if (lora && lora.hash) {
|
||||
// Set session storage to open the LoRA modal directly
|
||||
setSessionItem('recipe_to_lora_filterLoraHash', lora.hash.toLowerCase());
|
||||
setSessionItem('viewLoraDetail', 'true');
|
||||
setSessionItem('filterRecipeName', this.currentRecipe.title);
|
||||
}
|
||||
} else {
|
||||
// If no specific LoRA index is provided, show all LoRAs from this recipe
|
||||
// Collect all hashes from the recipe's LoRAs
|
||||
const loraHashes = this.currentRecipe.loras
|
||||
.filter(lora => lora.hash)
|
||||
.map(lora => lora.hash.toLowerCase());
|
||||
|
||||
if (loraHashes.length > 0) {
|
||||
// Store the LoRA hashes and recipe name in sessionStorage
|
||||
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
|
||||
setSessionItem('filterRecipeName', this.currentRecipe.title);
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to the LoRAs page
|
||||
window.location.href = '/loras';
|
||||
}
|
||||
|
||||
// New method to make LoRA items clickable
|
||||
setupLoraItemsClickable() {
|
||||
const loraItems = document.querySelectorAll('.recipe-lora-item');
|
||||
loraItems.forEach(item => {
|
||||
// Get the lora index from the data attribute
|
||||
const loraIndex = parseInt(item.dataset.loraIndex);
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
// If the click is on the reconnect container or badge, don't navigate
|
||||
if (e.target.closest('.lora-reconnect-container') ||
|
||||
e.target.closest('.deleted-badge') ||
|
||||
e.target.closest('.reconnect-tooltip')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to the LoRAs page with the specific LoRA index
|
||||
this.navigateToLorasPage(loraIndex);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { RecipeModal };
|
||||
export { RecipeModal };
|
||||
102
static/js/components/loraModal/ModelDescription.js
Normal file
102
static/js/components/loraModal/ModelDescription.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* ModelDescription.js
|
||||
* 处理LoRA模型描述相关的功能模块
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* 设置标签页切换功能
|
||||
*/
|
||||
export function setupTabSwitching() {
|
||||
const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn =>
|
||||
btn.classList.remove('active')
|
||||
);
|
||||
document.querySelectorAll('.tab-content .tab-pane').forEach(tab =>
|
||||
tab.classList.remove('active')
|
||||
);
|
||||
|
||||
// Add active class to clicked tab
|
||||
button.classList.add('active');
|
||||
const tabId = `${button.dataset.tab}-tab`;
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
|
||||
// If switching to description tab, make sure content is properly sized
|
||||
if (button.dataset.tab === 'description') {
|
||||
const descriptionContent = document.querySelector('.model-description-content');
|
||||
if (descriptionContent) {
|
||||
const hasContent = descriptionContent.innerHTML.trim() !== '';
|
||||
document.querySelector('.model-description-loading')?.classList.add('hidden');
|
||||
|
||||
// If no content, show a message
|
||||
if (!hasContent) {
|
||||
descriptionContent.innerHTML = '<div class="no-description">No model description available</div>';
|
||||
descriptionContent.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模型描述
|
||||
* @param {string} modelId - 模型ID
|
||||
* @param {string} filePath - 文件路径
|
||||
*/
|
||||
export async function loadModelDescription(modelId, filePath) {
|
||||
try {
|
||||
const descriptionContainer = document.querySelector('.model-description-content');
|
||||
const loadingElement = document.querySelector('.model-description-loading');
|
||||
|
||||
if (!descriptionContainer || !loadingElement) return;
|
||||
|
||||
// Show loading indicator
|
||||
loadingElement.classList.remove('hidden');
|
||||
descriptionContainer.classList.add('hidden');
|
||||
|
||||
// Try to get model description from API
|
||||
const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model description: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.description) {
|
||||
// Update the description content
|
||||
descriptionContainer.innerHTML = data.description;
|
||||
|
||||
// Process any links in the description to open in new tab
|
||||
const links = descriptionContainer.querySelectorAll('a');
|
||||
links.forEach(link => {
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
// Show the description and hide loading indicator
|
||||
descriptionContainer.classList.remove('hidden');
|
||||
loadingElement.classList.add('hidden');
|
||||
} else {
|
||||
throw new Error(data.error || 'No description available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading model description:', error);
|
||||
const loadingElement = document.querySelector('.model-description-loading');
|
||||
if (loadingElement) {
|
||||
loadingElement.innerHTML = `<div class="error-message">Failed to load model description. ${error.message}</div>`;
|
||||
}
|
||||
|
||||
// Show empty state message in the description container
|
||||
const descriptionContainer = document.querySelector('.model-description-content');
|
||||
if (descriptionContainer) {
|
||||
descriptionContainer.innerHTML = '<div class="no-description">No model description available</div>';
|
||||
descriptionContainer.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
493
static/js/components/loraModal/ModelMetadata.js
Normal file
493
static/js/components/loraModal/ModelMetadata.js
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* ModelMetadata.js
|
||||
* 处理LoRA模型元数据编辑相关的功能模块
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { BASE_MODELS } from '../../utils/constants.js';
|
||||
|
||||
/**
|
||||
* 保存模型元数据到服务器
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {Object} data - 要保存的数据
|
||||
* @returns {Promise} 保存操作的Promise
|
||||
*/
|
||||
export async function saveModelMetadata(filePath, data) {
|
||||
const response = await fetch('/loras/api/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置模型名称编辑功能
|
||||
*/
|
||||
export function setupModelNameEditing() {
|
||||
const modelNameContent = document.querySelector('.model-name-content');
|
||||
const editBtn = document.querySelector('.edit-model-name-btn');
|
||||
|
||||
if (!modelNameContent || !editBtn) return;
|
||||
|
||||
// Show edit button on hover
|
||||
const modelNameHeader = document.querySelector('.model-name-header');
|
||||
modelNameHeader.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
modelNameHeader.addEventListener('mouseleave', () => {
|
||||
if (!modelNameContent.getAttribute('data-editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
modelNameContent.setAttribute('data-editing', 'true');
|
||||
modelNameContent.focus();
|
||||
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
if (modelNameContent.childNodes.length > 0) {
|
||||
range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle focus out
|
||||
modelNameContent.addEventListener('blur', function() {
|
||||
this.removeAttribute('data-editing');
|
||||
editBtn.classList.remove('visible');
|
||||
|
||||
if (this.textContent.trim() === '') {
|
||||
// Restore original model name if empty
|
||||
const filePath = document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
this.textContent = loraCard.dataset.model_name;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle enter key
|
||||
modelNameContent.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const filePath = document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
saveModelName(filePath);
|
||||
this.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Limit model name length
|
||||
modelNameContent.addEventListener('input', function() {
|
||||
// Limit model name length
|
||||
if (this.textContent.length > 100) {
|
||||
this.textContent = this.textContent.substring(0, 100);
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.setStart(this.childNodes[0], 100);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
showToast('Model name is limited to 100 characters', 'warning');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模型名称
|
||||
* @param {string} filePath - 文件路径
|
||||
*/
|
||||
async function saveModelName(filePath) {
|
||||
const modelNameElement = document.querySelector('.model-name-content');
|
||||
const newModelName = modelNameElement.textContent.trim();
|
||||
|
||||
// Validate model name
|
||||
if (!newModelName) {
|
||||
showToast('Model name cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if model name is too long (limit to 100 characters)
|
||||
if (newModelName.length > 100) {
|
||||
showToast('Model name is too long (maximum 100 characters)', 'error');
|
||||
// Truncate the displayed text
|
||||
modelNameElement.textContent = newModelName.substring(0, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveModelMetadata(filePath, { model_name: newModelName });
|
||||
|
||||
// Update the corresponding lora card's dataset and display
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
loraCard.dataset.model_name = newModelName;
|
||||
const titleElement = loraCard.querySelector('.card-title');
|
||||
if (titleElement) {
|
||||
titleElement.textContent = newModelName;
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Model name updated successfully', 'success');
|
||||
|
||||
// Reload the page to reflect the sorted order
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
showToast('Failed to update model name', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置基础模型编辑功能
|
||||
*/
|
||||
export function setupBaseModelEditing() {
|
||||
const baseModelContent = document.querySelector('.base-model-content');
|
||||
const editBtn = document.querySelector('.edit-base-model-btn');
|
||||
|
||||
if (!baseModelContent || !editBtn) return;
|
||||
|
||||
// Show edit button on hover
|
||||
const baseModelDisplay = document.querySelector('.base-model-display');
|
||||
baseModelDisplay.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
baseModelDisplay.addEventListener('mouseleave', () => {
|
||||
if (!baseModelDisplay.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
baseModelDisplay.classList.add('editing');
|
||||
|
||||
// Store the original value to check for changes later
|
||||
const originalValue = baseModelContent.textContent.trim();
|
||||
|
||||
// Create dropdown selector to replace the base model content
|
||||
const currentValue = originalValue;
|
||||
const dropdown = document.createElement('select');
|
||||
dropdown.className = 'base-model-selector';
|
||||
|
||||
// Flag to track if a change was made
|
||||
let valueChanged = false;
|
||||
|
||||
// Add options from BASE_MODELS constants
|
||||
const baseModelCategories = {
|
||||
'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER],
|
||||
'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1],
|
||||
'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO],
|
||||
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
|
||||
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
|
||||
'Other Models': [
|
||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.UNKNOWN
|
||||
]
|
||||
};
|
||||
|
||||
// Create option groups for better organization
|
||||
Object.entries(baseModelCategories).forEach(([category, models]) => {
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = category;
|
||||
|
||||
models.forEach(model => {
|
||||
const option = document.createElement('option');
|
||||
option.value = model;
|
||||
option.textContent = model;
|
||||
option.selected = model === currentValue;
|
||||
group.appendChild(option);
|
||||
});
|
||||
|
||||
dropdown.appendChild(group);
|
||||
});
|
||||
|
||||
// Replace content with dropdown
|
||||
baseModelContent.style.display = 'none';
|
||||
baseModelDisplay.insertBefore(dropdown, editBtn);
|
||||
|
||||
// Hide edit button during editing
|
||||
editBtn.style.display = 'none';
|
||||
|
||||
// Focus the dropdown
|
||||
dropdown.focus();
|
||||
|
||||
// Handle dropdown change
|
||||
dropdown.addEventListener('change', function() {
|
||||
const selectedModel = this.value;
|
||||
baseModelContent.textContent = selectedModel;
|
||||
|
||||
// Mark that a change was made if the value differs from original
|
||||
if (selectedModel !== originalValue) {
|
||||
valueChanged = true;
|
||||
} else {
|
||||
valueChanged = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Function to save changes and exit edit mode
|
||||
const saveAndExit = function() {
|
||||
// Check if dropdown still exists and remove it
|
||||
if (dropdown && dropdown.parentNode === baseModelDisplay) {
|
||||
baseModelDisplay.removeChild(dropdown);
|
||||
}
|
||||
|
||||
// Show the content and edit button
|
||||
baseModelContent.style.display = '';
|
||||
editBtn.style.display = '';
|
||||
|
||||
// Remove editing class
|
||||
baseModelDisplay.classList.remove('editing');
|
||||
|
||||
// Only save if the value has actually changed
|
||||
if (valueChanged || baseModelContent.textContent.trim() !== originalValue) {
|
||||
// Get file path for saving
|
||||
const filePath = document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
|
||||
// Save the changes, passing the original value for comparison
|
||||
saveBaseModel(filePath, originalValue);
|
||||
}
|
||||
|
||||
// Remove this event listener
|
||||
document.removeEventListener('click', outsideClickHandler);
|
||||
};
|
||||
|
||||
// Handle outside clicks to save and exit
|
||||
const outsideClickHandler = function(e) {
|
||||
// If click is outside the dropdown and base model display
|
||||
if (!baseModelDisplay.contains(e.target)) {
|
||||
saveAndExit();
|
||||
}
|
||||
};
|
||||
|
||||
// Add delayed event listener for outside clicks
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', outsideClickHandler);
|
||||
}, 0);
|
||||
|
||||
// Also handle dropdown blur event
|
||||
dropdown.addEventListener('blur', function(e) {
|
||||
// Only save if the related target is not the edit button or inside the baseModelDisplay
|
||||
if (!baseModelDisplay.contains(e.relatedTarget)) {
|
||||
saveAndExit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存基础模型
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {string} originalValue - 原始值(用于比较)
|
||||
*/
|
||||
async function saveBaseModel(filePath, originalValue) {
|
||||
const baseModelElement = document.querySelector('.base-model-content');
|
||||
const newBaseModel = baseModelElement.textContent.trim();
|
||||
|
||||
// Only save if the value has actually changed
|
||||
if (newBaseModel === originalValue) {
|
||||
return; // No change, no need to save
|
||||
}
|
||||
|
||||
try {
|
||||
await saveModelMetadata(filePath, { base_model: newBaseModel });
|
||||
|
||||
// Update the corresponding lora card's dataset
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
loraCard.dataset.base_model = newBaseModel;
|
||||
}
|
||||
|
||||
showToast('Base model updated successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to update base model', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文件名编辑功能
|
||||
*/
|
||||
export function setupFileNameEditing() {
|
||||
const fileNameContent = document.querySelector('.file-name-content');
|
||||
const editBtn = document.querySelector('.edit-file-name-btn');
|
||||
|
||||
if (!fileNameContent || !editBtn) return;
|
||||
|
||||
// Show edit button on hover
|
||||
const fileNameWrapper = document.querySelector('.file-name-wrapper');
|
||||
fileNameWrapper.addEventListener('mouseenter', () => {
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
fileNameWrapper.addEventListener('mouseleave', () => {
|
||||
if (!fileNameWrapper.classList.contains('editing')) {
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle edit button click
|
||||
editBtn.addEventListener('click', () => {
|
||||
fileNameWrapper.classList.add('editing');
|
||||
fileNameContent.setAttribute('contenteditable', 'true');
|
||||
fileNameContent.focus();
|
||||
|
||||
// Store original value for comparison later
|
||||
fileNameContent.dataset.originalValue = fileNameContent.textContent.trim();
|
||||
|
||||
// Place cursor at the end
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
range.selectNodeContents(fileNameContent);
|
||||
range.collapse(false);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
|
||||
editBtn.classList.add('visible');
|
||||
});
|
||||
|
||||
// Handle keyboard events in edit mode
|
||||
fileNameContent.addEventListener('keydown', function(e) {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.blur(); // Trigger save on Enter
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
// Restore original value
|
||||
this.textContent = this.dataset.originalValue;
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle input validation
|
||||
fileNameContent.addEventListener('input', function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
// Replace invalid characters for filenames
|
||||
const invalidChars = /[\\/:*?"<>|]/g;
|
||||
if (invalidChars.test(this.textContent)) {
|
||||
const cursorPos = window.getSelection().getRangeAt(0).startOffset;
|
||||
this.textContent = this.textContent.replace(invalidChars, '');
|
||||
|
||||
// Restore cursor position
|
||||
const range = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
const newPos = Math.min(cursorPos, this.textContent.length);
|
||||
|
||||
if (this.firstChild) {
|
||||
range.setStart(this.firstChild, newPos);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
showToast('Invalid characters removed from filename', 'warning');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle focus out - save changes
|
||||
fileNameContent.addEventListener('blur', async function() {
|
||||
if (!this.getAttribute('contenteditable')) return;
|
||||
|
||||
const newFileName = this.textContent.trim();
|
||||
const originalValue = this.dataset.originalValue;
|
||||
|
||||
// Basic validation
|
||||
if (!newFileName) {
|
||||
// Restore original value if empty
|
||||
this.textContent = originalValue;
|
||||
showToast('File name cannot be empty', 'error');
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newFileName === originalValue) {
|
||||
// No changes, just exit edit mode
|
||||
exitEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the full file path
|
||||
const filePath = document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('.file-path').textContent + originalValue + '.safetensors';
|
||||
|
||||
// Call API to rename the file
|
||||
const response = await fetch('/api/rename_lora', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
new_file_name: newFileName
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showToast('File name updated successfully', 'success');
|
||||
|
||||
// Update the LoRA card with new file path
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
const newFilePath = filePath.replace(originalValue, newFileName);
|
||||
loraCard.dataset.filepath = newFilePath;
|
||||
}
|
||||
|
||||
// Reload the page after a short delay to reflect changes
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
throw new Error(result.error || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error renaming file:', error);
|
||||
this.textContent = originalValue; // Restore original file name
|
||||
showToast(`Failed to rename file: ${error.message}`, 'error');
|
||||
} finally {
|
||||
exitEditMode();
|
||||
}
|
||||
});
|
||||
|
||||
function exitEditMode() {
|
||||
fileNameContent.removeAttribute('contenteditable');
|
||||
fileNameWrapper.classList.remove('editing');
|
||||
editBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
68
static/js/components/loraModal/PresetTags.js
Normal file
68
static/js/components/loraModal/PresetTags.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* PresetTags.js
|
||||
* 处理LoRA模型预设参数标签相关的功能模块
|
||||
*/
|
||||
import { saveModelMetadata } from './ModelMetadata.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* 解析预设参数
|
||||
* @param {string} usageTips - 包含预设参数的JSON字符串
|
||||
* @returns {Object} 解析后的预设参数对象
|
||||
*/
|
||||
export function parsePresets(usageTips) {
|
||||
if (!usageTips) return {};
|
||||
try {
|
||||
return JSON.parse(usageTips);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染预设标签
|
||||
* @param {Object} presets - 预设参数对象
|
||||
* @returns {string} HTML内容
|
||||
*/
|
||||
export function renderPresetTags(presets) {
|
||||
return Object.entries(presets).map(([key, value]) => `
|
||||
<div class="preset-tag" data-key="${key}">
|
||||
<span>${formatPresetKey(key)}: ${value}</span>
|
||||
<i class="fas fa-times" onclick="removePreset('${key}')"></i>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化预设键名
|
||||
* @param {string} key - 预设键名
|
||||
* @returns {string} 格式化后的键名
|
||||
*/
|
||||
function formatPresetKey(key) {
|
||||
return key.split('_').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除预设参数
|
||||
* @param {string} key - 要移除的预设键名
|
||||
*/
|
||||
window.removePreset = async function(key) {
|
||||
const filePath = document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
delete currentPresets[key];
|
||||
const newPresetsJson = JSON.stringify(currentPresets);
|
||||
|
||||
await saveModelMetadata(filePath, {
|
||||
usage_tips: newPresetsJson
|
||||
});
|
||||
|
||||
loraCard.dataset.usage_tips = newPresetsJson;
|
||||
document.querySelector('.preset-tags').innerHTML = renderPresetTags(currentPresets);
|
||||
};
|
||||
234
static/js/components/loraModal/RecipeTab.js
Normal file
234
static/js/components/loraModal/RecipeTab.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* RecipeTab - Handles the recipes tab in the Lora Modal
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
|
||||
/**
|
||||
* Loads recipes that use the specified Lora and renders them in the tab
|
||||
* @param {string} loraName - The display name of the Lora
|
||||
* @param {string} sha256 - The SHA256 hash of the Lora
|
||||
*/
|
||||
export function loadRecipesForLora(loraName, sha256) {
|
||||
const recipeTab = document.getElementById('recipes-tab');
|
||||
if (!recipeTab) return;
|
||||
|
||||
// Show loading state
|
||||
recipeTab.innerHTML = `
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Fetch recipes that use this Lora by hash
|
||||
fetch(`/api/recipes/for-lora?hash=${encodeURIComponent(sha256.toLowerCase())}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load recipes');
|
||||
}
|
||||
|
||||
renderRecipes(recipeTab, data.recipes, loraName, sha256);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading recipes for Lora:', error);
|
||||
recipeTab.innerHTML = `
|
||||
<div class="recipes-error">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<p>Failed to load recipes. Please try again later.</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the recipe cards in the tab
|
||||
* @param {HTMLElement} tabElement - The tab element to render into
|
||||
* @param {Array} recipes - Array of recipe objects
|
||||
* @param {string} loraName - The display name of the Lora
|
||||
* @param {string} loraHash - The hash of the Lora
|
||||
*/
|
||||
function renderRecipes(tabElement, recipes, loraName, loraHash) {
|
||||
if (!recipes || recipes.length === 0) {
|
||||
tabElement.innerHTML = `
|
||||
<div class="recipes-empty">
|
||||
<i class="fas fa-book-open"></i>
|
||||
<p>No recipes found that use this Lora.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Create header with count and view all button
|
||||
const headerElement = document.createElement('div');
|
||||
headerElement.className = 'recipes-header';
|
||||
headerElement.innerHTML = `
|
||||
<h3>Found ${recipes.length} recipe${recipes.length > 1 ? 's' : ''} using this Lora</h3>
|
||||
<button class="view-all-btn" title="View all in Recipes page">
|
||||
<i class="fas fa-external-link-alt"></i> View All in Recipes
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add click handler for "View All" button
|
||||
headerElement.querySelector('.view-all-btn').addEventListener('click', () => {
|
||||
navigateToRecipesPage(loraName, loraHash);
|
||||
});
|
||||
|
||||
// Create grid container for recipe cards
|
||||
const cardGrid = document.createElement('div');
|
||||
cardGrid.className = 'card-grid';
|
||||
|
||||
// Create recipe cards matching the structure in recipes.html
|
||||
recipes.forEach(recipe => {
|
||||
// Get basic info
|
||||
const baseModel = recipe.base_model || '';
|
||||
const loras = recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
|
||||
// Ensure file_url exists, fallback to file_path if needed
|
||||
const imageUrl = recipe.file_url ||
|
||||
(recipe.file_path ? `/loras_static/root1/preview/${recipe.file_path.split('/').pop()}` :
|
||||
'/loras_static/images/no-preview.png');
|
||||
|
||||
// Create card element matching the structure in recipes.html
|
||||
const card = document.createElement('div');
|
||||
card.className = 'lora-card';
|
||||
card.dataset.filePath = recipe.file_path || '';
|
||||
card.dataset.title = recipe.title || '';
|
||||
card.dataset.created = recipe.created_date || '';
|
||||
card.dataset.id = recipe.id || '';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="recipe-indicator" title="Recipe">R</div>
|
||||
<div class="card-preview">
|
||||
<img src="${imageUrl}" alt="${recipe.title}" loading="lazy">
|
||||
<div class="card-header">
|
||||
<div class="base-model-wrapper">
|
||||
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="model-info">
|
||||
<span class="model-name">${recipe.title}</span>
|
||||
</div>
|
||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||
title="${getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners for action buttons
|
||||
card.querySelector('.fa-copy').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
copyRecipeSyntax(recipe.id);
|
||||
});
|
||||
|
||||
// Add click handler for the entire card
|
||||
card.addEventListener('click', () => {
|
||||
navigateToRecipeDetails(recipe.id);
|
||||
});
|
||||
|
||||
// Add card to grid
|
||||
cardGrid.appendChild(card);
|
||||
});
|
||||
|
||||
// Clear loading indicator and append content
|
||||
tabElement.innerHTML = '';
|
||||
tabElement.appendChild(headerElement);
|
||||
tabElement.appendChild(cardGrid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a descriptive title for the LoRA status indicator
|
||||
* @param {number} totalCount - Total number of LoRAs in recipe
|
||||
* @param {number} missingCount - Number of missing LoRAs
|
||||
* @returns {string} Status title text
|
||||
*/
|
||||
function getLoraStatusTitle(totalCount, missingCount) {
|
||||
if (totalCount === 0) return "No LoRAs in this recipe";
|
||||
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies recipe syntax to clipboard
|
||||
* @param {string} recipeId - The recipe ID
|
||||
*/
|
||||
function copyRecipeSyntax(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('Cannot copy recipe syntax: Missing recipe ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/recipe/${recipeId}/syntax`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success && data.syntax) {
|
||||
return navigator.clipboard.writeText(data.syntax);
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned');
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
showToast('Recipe syntax copied to clipboard', 'success');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
showToast('Failed to copy recipe syntax', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the recipes page with filter for the current Lora
|
||||
* @param {string} loraName - The Lora display name to filter by
|
||||
* @param {string} loraHash - The hash of the Lora to filter by
|
||||
* @param {boolean} createNew - Whether to open the create recipe dialog
|
||||
*/
|
||||
function navigateToRecipesPage(loraName, loraHash) {
|
||||
// Close the current modal
|
||||
if (window.modalManager) {
|
||||
modalManager.closeModal('loraModal');
|
||||
}
|
||||
|
||||
// Clear any previous filters first
|
||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the LoRA name and hash filter in sessionStorage
|
||||
setSessionItem('lora_to_recipe_filterLoraName', loraName);
|
||||
setSessionItem('lora_to_recipe_filterLoraHash', loraHash);
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates directly to a specific recipe's details
|
||||
* @param {string} recipeId - The recipe ID to view
|
||||
*/
|
||||
function navigateToRecipeDetails(recipeId) {
|
||||
// Close the current modal
|
||||
if (window.modalManager) {
|
||||
modalManager.closeModal('loraModal');
|
||||
}
|
||||
|
||||
// Clear any previous filters first
|
||||
removeSessionItem('filterLoraName');
|
||||
removeSessionItem('filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Store the recipe ID in sessionStorage to load on recipes page
|
||||
setSessionItem('viewRecipeId', recipeId);
|
||||
|
||||
// Directly navigate to recipes page
|
||||
window.location.href = '/loras/recipes';
|
||||
}
|
||||
501
static/js/components/loraModal/ShowcaseView.js
Normal file
501
static/js/components/loraModal/ShowcaseView.js
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* ShowcaseView.js
|
||||
* 处理LoRA模型展示内容(图片、视频)的功能模块
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
|
||||
/**
|
||||
* 渲染展示内容
|
||||
* @param {Array} images - 要展示的图片/视频数组
|
||||
* @returns {string} HTML内容
|
||||
*/
|
||||
export function renderShowcaseContent(images) {
|
||||
if (!images?.length) return '<div class="no-examples">No example images available</div>';
|
||||
|
||||
// Filter images based on SFW setting
|
||||
const showOnlySFW = state.settings.show_only_sfw;
|
||||
let filteredImages = images;
|
||||
let hiddenCount = 0;
|
||||
|
||||
if (showOnlySFW) {
|
||||
filteredImages = images.filter(img => {
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const isSfw = nsfwLevel < NSFW_LEVELS.R;
|
||||
if (!isSfw) hiddenCount++;
|
||||
return isSfw;
|
||||
});
|
||||
}
|
||||
|
||||
// Show message if no images are available after filtering
|
||||
if (filteredImages.length === 0) {
|
||||
return `
|
||||
<div class="no-examples">
|
||||
<p>All example images are filtered due to NSFW content settings</p>
|
||||
<p class="nsfw-filter-info">Your settings are currently set to show only safe-for-work content</p>
|
||||
<p>You can change this in Settings <i class="fas fa-cog"></i></p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Show hidden content notification if applicable
|
||||
const hiddenNotification = hiddenCount > 0 ?
|
||||
`<div class="nsfw-filter-notification">
|
||||
<i class="fas fa-eye-slash"></i> ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting
|
||||
</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="scroll-indicator" onclick="toggleShowcase(this)">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
<span>Scroll or click to show ${filteredImages.length} examples</span>
|
||||
</div>
|
||||
<div class="carousel collapsed">
|
||||
${hiddenNotification}
|
||||
<div class="carousel-container">
|
||||
${filteredImages.map(img => {
|
||||
// 计算适当的展示高度:
|
||||
// 1. 保持原始宽高比
|
||||
// 2. 限制最大高度为视窗高度的60%
|
||||
// 3. 确保最小高度为容器宽度的40%
|
||||
const aspectRatio = (img.height / img.width) * 100;
|
||||
const containerWidth = 800; // modal content的最大宽度
|
||||
const minHeightPercent = 40; // 最小高度为容器宽度的40%
|
||||
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||
const heightPercent = Math.max(
|
||||
minHeightPercent,
|
||||
Math.min(maxHeightPercent, aspectRatio)
|
||||
);
|
||||
|
||||
// Check if image should be blurred
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
|
||||
// Extract metadata from the image
|
||||
const meta = img.meta || {};
|
||||
const prompt = meta.prompt || '';
|
||||
const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
|
||||
const size = meta.Size || `${img.width}x${img.height}`;
|
||||
const seed = meta.seed || '';
|
||||
const model = meta.Model || '';
|
||||
const steps = meta.steps || '';
|
||||
const sampler = meta.sampler || '';
|
||||
const cfgScale = meta.cfgScale || '';
|
||||
const clipSkip = meta.clipSkip || '';
|
||||
|
||||
// Check if we have any meaningful generation parameters
|
||||
const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
|
||||
const hasPrompts = prompt || negativePrompt;
|
||||
|
||||
// If no metadata available, show a message
|
||||
if (!hasParams && !hasPrompts) {
|
||||
const metadataPanel = `
|
||||
<div class="image-metadata-panel">
|
||||
<div class="metadata-content">
|
||||
<div class="no-metadata-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>No generation parameters available</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (img.type === 'video') {
|
||||
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
|
||||
}
|
||||
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
|
||||
}
|
||||
|
||||
// Create a data attribute with the prompt for copying instead of trying to handle it in the onclick
|
||||
// This avoids issues with quotes and special characters
|
||||
const promptIndex = Math.random().toString(36).substring(2, 15);
|
||||
const negPromptIndex = Math.random().toString(36).substring(2, 15);
|
||||
|
||||
// Create parameter tags HTML
|
||||
const paramTags = `
|
||||
<div class="params-tags">
|
||||
${size ? `<div class="param-tag"><span class="param-name">Size:</span><span class="param-value">${size}</span></div>` : ''}
|
||||
${seed ? `<div class="param-tag"><span class="param-name">Seed:</span><span class="param-value">${seed}</span></div>` : ''}
|
||||
${model ? `<div class="param-tag"><span class="param-name">Model:</span><span class="param-value">${model}</span></div>` : ''}
|
||||
${steps ? `<div class="param-tag"><span class="param-name">Steps:</span><span class="param-value">${steps}</span></div>` : ''}
|
||||
${sampler ? `<div class="param-tag"><span class="param-name">Sampler:</span><span class="param-value">${sampler}</span></div>` : ''}
|
||||
${cfgScale ? `<div class="param-tag"><span class="param-name">CFG:</span><span class="param-value">${cfgScale}</span></div>` : ''}
|
||||
${clipSkip ? `<div class="param-tag"><span class="param-name">Clip Skip:</span><span class="param-value">${clipSkip}</span></div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Metadata panel HTML
|
||||
const metadataPanel = `
|
||||
<div class="image-metadata-panel">
|
||||
<div class="metadata-content">
|
||||
${hasParams ? paramTags : ''}
|
||||
${!hasParams && !hasPrompts ? `
|
||||
<div class="no-metadata-message">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<span>No generation parameters available</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${prompt ? `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Prompt:</span>
|
||||
<div class="metadata-prompt-wrapper">
|
||||
<div class="metadata-prompt">${prompt}</div>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${promptIndex}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-prompt" id="prompt-${promptIndex}" style="display:none;">${prompt}</div>
|
||||
` : ''}
|
||||
${negativePrompt ? `
|
||||
<div class="metadata-row prompt-row">
|
||||
<span class="metadata-label">Negative Prompt:</span>
|
||||
<div class="metadata-prompt-wrapper">
|
||||
<div class="metadata-prompt">${negativePrompt}</div>
|
||||
<button class="copy-prompt-btn" data-prompt-index="${negPromptIndex}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden-prompt" id="prompt-${negPromptIndex}" style="display:none;">${negativePrompt}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (img.type === 'video') {
|
||||
return generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
|
||||
}
|
||||
return generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel);
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成视频包装HTML
|
||||
*/
|
||||
function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) {
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<video controls autoplay muted loop crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer" data-src="${img.url}"
|
||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||
<source data-src="${img.url}" type="video/mp4">
|
||||
Your browser does not support video playback
|
||||
</video>
|
||||
${shouldBlur ? `
|
||||
<div class="nsfw-overlay">
|
||||
<div class="nsfw-warning">
|
||||
<p>${nsfwText}</p>
|
||||
<button class="show-content-btn">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${metadataPanel}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图片包装HTML
|
||||
*/
|
||||
function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel) {
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
` : ''}
|
||||
<img data-src="${img.url}"
|
||||
alt="Preview"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
width="${img.width}"
|
||||
height="${img.height}"
|
||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||
${shouldBlur ? `
|
||||
<div class="nsfw-overlay">
|
||||
<div class="nsfw-warning">
|
||||
<p>${nsfwText}</p>
|
||||
<button class="show-content-btn">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${metadataPanel}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换展示区域的显示状态
|
||||
*/
|
||||
export function toggleShowcase(element) {
|
||||
const carousel = element.nextElementSibling;
|
||||
const isCollapsed = carousel.classList.contains('collapsed');
|
||||
const indicator = element.querySelector('span');
|
||||
const icon = element.querySelector('i');
|
||||
|
||||
carousel.classList.toggle('collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||
indicator.textContent = `Scroll or click to hide examples`;
|
||||
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
|
||||
initLazyLoading(carousel);
|
||||
|
||||
// Initialize NSFW content blur toggle handlers
|
||||
initNsfwBlurHandlers(carousel);
|
||||
|
||||
// Initialize metadata panel interaction handlers
|
||||
initMetadataPanelHandlers(carousel);
|
||||
} else {
|
||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||
indicator.textContent = `Scroll or click to show ${count} examples`;
|
||||
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
|
||||
|
||||
// Make sure any open metadata panels get closed
|
||||
const carouselContainer = carousel.querySelector('.carousel-container');
|
||||
if (carouselContainer) {
|
||||
carouselContainer.style.height = '0';
|
||||
setTimeout(() => {
|
||||
carouselContainer.style.height = '';
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化元数据面板交互处理
|
||||
*/
|
||||
function initMetadataPanelHandlers(container) {
|
||||
// Find all media wrappers
|
||||
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||
|
||||
mediaWrappers.forEach(wrapper => {
|
||||
// Get the metadata panel
|
||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||
if (!metadataPanel) return;
|
||||
|
||||
// Prevent events from the metadata panel from bubbling
|
||||
metadataPanel.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Handle copy prompt button clicks
|
||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||
copyBtns.forEach(copyBtn => {
|
||||
const promptIndex = copyBtn.dataset.promptIndex;
|
||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation(); // Prevent bubbling
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(promptElement.textContent);
|
||||
showToast('Prompt copied to clipboard', 'success');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent scrolling in the metadata panel from scrolling the whole modal
|
||||
metadataPanel.addEventListener('wheel', (e) => {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化模糊切换处理
|
||||
*/
|
||||
function initNsfwBlurHandlers(container) {
|
||||
// Handle toggle blur buttons
|
||||
const toggleButtons = container.querySelectorAll('.toggle-blur-btn');
|
||||
toggleButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const wrapper = btn.closest('.media-wrapper');
|
||||
const media = wrapper.querySelector('img, video');
|
||||
const isBlurred = media.classList.toggle('blurred');
|
||||
const icon = btn.querySelector('i');
|
||||
|
||||
// Update the icon based on blur state
|
||||
if (isBlurred) {
|
||||
icon.className = 'fas fa-eye';
|
||||
} else {
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Toggle the overlay visibility
|
||||
const overlay = wrapper.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = isBlurred ? 'flex' : 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle "Show" buttons in overlays
|
||||
const showButtons = container.querySelectorAll('.show-content-btn');
|
||||
showButtons.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const wrapper = btn.closest('.media-wrapper');
|
||||
const media = wrapper.querySelector('img, video');
|
||||
media.classList.remove('blurred');
|
||||
|
||||
// Update the toggle button icon
|
||||
const toggleBtn = wrapper.querySelector('.toggle-blur-btn');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
|
||||
}
|
||||
|
||||
// Hide the overlay
|
||||
const overlay = wrapper.querySelector('.nsfw-overlay');
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化延迟加载
|
||||
*/
|
||||
function initLazyLoading(container) {
|
||||
const lazyElements = container.querySelectorAll('.lazy');
|
||||
|
||||
const lazyLoad = (element) => {
|
||||
if (element.tagName.toLowerCase() === 'video') {
|
||||
element.src = element.dataset.src;
|
||||
element.querySelector('source').src = element.dataset.src;
|
||||
element.load();
|
||||
} else {
|
||||
element.src = element.dataset.src;
|
||||
}
|
||||
element.classList.remove('lazy');
|
||||
};
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
lazyLoad(entry.target);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lazyElements.forEach(element => observer.observe(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置展示区域的滚动处理
|
||||
*/
|
||||
export function setupShowcaseScroll() {
|
||||
// Add event listener to document for wheel events
|
||||
document.addEventListener('wheel', (event) => {
|
||||
// Find the active modal content
|
||||
const modalContent = document.querySelector('#loraModal .modal-content');
|
||||
if (!modalContent) return;
|
||||
|
||||
const showcase = modalContent.querySelector('.showcase-section');
|
||||
if (!showcase) return;
|
||||
|
||||
const carousel = showcase.querySelector('.carousel');
|
||||
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
||||
|
||||
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
||||
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
||||
|
||||
if (isNearBottom) {
|
||||
toggleShowcase(scrollIndicator);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Use MutationObserver instead of deprecated DOMNodeInserted
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === 'childList' && mutation.addedNodes.length) {
|
||||
// Check if loraModal content was added
|
||||
const loraModal = document.getElementById('loraModal');
|
||||
if (loraModal && loraModal.querySelector('.modal-content')) {
|
||||
setupBackToTopButton(loraModal.querySelector('.modal-content'));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing the document body for changes
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Also try to set up the button immediately in case the modal is already open
|
||||
const modalContent = document.querySelector('#loraModal .modal-content');
|
||||
if (modalContent) {
|
||||
setupBackToTopButton(modalContent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置返回顶部按钮
|
||||
*/
|
||||
function setupBackToTopButton(modalContent) {
|
||||
// Remove any existing scroll listeners to avoid duplicates
|
||||
modalContent.onscroll = null;
|
||||
|
||||
// Add new scroll listener
|
||||
modalContent.addEventListener('scroll', () => {
|
||||
const backToTopBtn = modalContent.querySelector('.back-to-top');
|
||||
if (backToTopBtn) {
|
||||
if (modalContent.scrollTop > 300) {
|
||||
backToTopBtn.classList.add('visible');
|
||||
} else {
|
||||
backToTopBtn.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger a scroll event to check initial position
|
||||
modalContent.dispatchEvent(new Event('scroll'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到顶部
|
||||
*/
|
||||
export function scrollToTop(button) {
|
||||
const modalContent = button.closest('.modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
345
static/js/components/loraModal/TriggerWords.js
Normal file
345
static/js/components/loraModal/TriggerWords.js
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* TriggerWords.js
|
||||
* 处理LoRA模型触发词相关的功能模块
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { saveModelMetadata } from './ModelMetadata.js';
|
||||
|
||||
/**
|
||||
* 渲染触发词
|
||||
* @param {Array} words - 触发词数组
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {string} HTML内容
|
||||
*/
|
||||
export function renderTriggerWords(words, filePath) {
|
||||
if (!words.length) return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="trigger-words-content">
|
||||
<span class="no-trigger-words">No trigger word needed</span>
|
||||
<div class="trigger-words-tags" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="trigger-words-edit-controls" style="display:none;">
|
||||
<button class="add-trigger-word-btn" title="Add a trigger word">
|
||||
<i class="fas fa-plus"></i> Add
|
||||
</button>
|
||||
<button class="save-trigger-words-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="add-trigger-word-form" style="display:none;">
|
||||
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
|
||||
<button class="confirm-add-trigger-word-btn">Add</button>
|
||||
<button class="cancel-add-trigger-word-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="trigger-words-content">
|
||||
<div class="trigger-words-tags">
|
||||
${words.map(word => `
|
||||
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')">
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="delete-trigger-word-btn" style="display:none;" onclick="event.stopPropagation();">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="trigger-words-edit-controls" style="display:none;">
|
||||
<button class="add-trigger-word-btn" title="Add a trigger word">
|
||||
<i class="fas fa-plus"></i> Add
|
||||
</button>
|
||||
<button class="save-trigger-words-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="add-trigger-word-form" style="display:none;">
|
||||
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
|
||||
<button class="confirm-add-trigger-word-btn">Add</button>
|
||||
<button class="cancel-add-trigger-word-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置触发词编辑模式
|
||||
*/
|
||||
export function setupTriggerWordsEditMode() {
|
||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||
if (!editBtn) return;
|
||||
|
||||
editBtn.addEventListener('click', function() {
|
||||
const triggerWordsSection = this.closest('.trigger-words');
|
||||
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
|
||||
|
||||
// Toggle edit mode UI elements
|
||||
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||
const editControls = triggerWordsSection.querySelector('.trigger-words-edit-controls');
|
||||
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
|
||||
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
|
||||
|
||||
if (isEditMode) {
|
||||
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
|
||||
this.title = "Cancel editing";
|
||||
editControls.style.display = 'flex';
|
||||
|
||||
// If we have no trigger words yet, hide the "No trigger word needed" text
|
||||
// and show the empty tags container
|
||||
if (noTriggerWords) {
|
||||
noTriggerWords.style.display = 'none';
|
||||
if (tagsContainer) tagsContainer.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Disable click-to-copy and show delete buttons
|
||||
triggerWordTags.forEach(tag => {
|
||||
tag.onclick = null;
|
||||
tag.querySelector('.trigger-word-copy').style.display = 'none';
|
||||
tag.querySelector('.delete-trigger-word-btn').style.display = 'block';
|
||||
});
|
||||
} else {
|
||||
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
|
||||
this.title = "Edit trigger words";
|
||||
editControls.style.display = 'none';
|
||||
|
||||
// If we have no trigger words, show the "No trigger word needed" text
|
||||
// and hide the empty tags container
|
||||
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
|
||||
if (noTriggerWords && currentTags.length === 0) {
|
||||
noTriggerWords.style.display = '';
|
||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Restore original state
|
||||
triggerWordTags.forEach(tag => {
|
||||
const word = tag.dataset.word;
|
||||
tag.onclick = () => copyTriggerWord(word);
|
||||
tag.querySelector('.trigger-word-copy').style.display = 'flex';
|
||||
tag.querySelector('.delete-trigger-word-btn').style.display = 'none';
|
||||
});
|
||||
|
||||
// Hide add form if open
|
||||
triggerWordsSection.querySelector('.add-trigger-word-form').style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Set up add trigger word button
|
||||
const addBtn = document.querySelector('.add-trigger-word-btn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', function() {
|
||||
const triggerWordsSection = this.closest('.trigger-words');
|
||||
const addForm = triggerWordsSection.querySelector('.add-trigger-word-form');
|
||||
addForm.style.display = 'flex';
|
||||
addForm.querySelector('input').focus();
|
||||
});
|
||||
}
|
||||
|
||||
// Set up confirm and cancel add buttons
|
||||
const confirmAddBtn = document.querySelector('.confirm-add-trigger-word-btn');
|
||||
const cancelAddBtn = document.querySelector('.cancel-add-trigger-word-btn');
|
||||
const triggerWordInput = document.querySelector('.new-trigger-word-input');
|
||||
|
||||
if (confirmAddBtn && triggerWordInput) {
|
||||
confirmAddBtn.addEventListener('click', function() {
|
||||
addNewTriggerWord(triggerWordInput.value);
|
||||
});
|
||||
|
||||
// Add keydown event to input
|
||||
triggerWordInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewTriggerWord(this.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelAddBtn) {
|
||||
cancelAddBtn.addEventListener('click', function() {
|
||||
const addForm = this.closest('.add-trigger-word-form');
|
||||
addForm.style.display = 'none';
|
||||
addForm.querySelector('input').value = '';
|
||||
});
|
||||
}
|
||||
|
||||
// Set up save button
|
||||
const saveBtn = document.querySelector('.save-trigger-words-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', saveTriggerWords);
|
||||
}
|
||||
|
||||
// Set up delete buttons
|
||||
document.querySelectorAll('.delete-trigger-word-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const tag = this.closest('.trigger-word-tag');
|
||||
tag.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加新触发词
|
||||
* @param {string} word - 要添加的触发词
|
||||
*/
|
||||
function addNewTriggerWord(word) {
|
||||
word = word.trim();
|
||||
if (!word) return;
|
||||
|
||||
const triggerWordsSection = document.querySelector('.trigger-words');
|
||||
let tagsContainer = document.querySelector('.trigger-words-tags');
|
||||
|
||||
// Ensure tags container exists and is visible
|
||||
if (tagsContainer) {
|
||||
tagsContainer.style.display = 'flex';
|
||||
} else {
|
||||
// Create tags container if it doesn't exist
|
||||
const contentDiv = triggerWordsSection.querySelector('.trigger-words-content');
|
||||
if (contentDiv) {
|
||||
tagsContainer = document.createElement('div');
|
||||
tagsContainer.className = 'trigger-words-tags';
|
||||
contentDiv.appendChild(tagsContainer);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tagsContainer) return;
|
||||
|
||||
// Hide "no trigger words" message if it exists
|
||||
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
|
||||
if (noTriggerWordsMsg) {
|
||||
noTriggerWordsMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
// Validation: Check length
|
||||
if (word.split(/\s+/).length > 30) {
|
||||
showToast('Trigger word should not exceed 30 words', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check total number
|
||||
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
|
||||
if (currentTags.length >= 10) {
|
||||
showToast('Maximum 10 trigger words allowed', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validation: Check for duplicates
|
||||
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
|
||||
if (existingWords.includes(word)) {
|
||||
showToast('This trigger word already exists', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new tag
|
||||
const newTag = document.createElement('div');
|
||||
newTag.className = 'trigger-word-tag';
|
||||
newTag.dataset.word = word;
|
||||
newTag.innerHTML = `
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy" style="display:none;">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="delete-trigger-word-btn" onclick="event.stopPropagation();">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
// Add event listener to delete button
|
||||
const deleteBtn = newTag.querySelector('.delete-trigger-word-btn');
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
newTag.remove();
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(newTag);
|
||||
|
||||
// Clear and hide the input form
|
||||
const triggerWordInput = document.querySelector('.new-trigger-word-input');
|
||||
triggerWordInput.value = '';
|
||||
document.querySelector('.add-trigger-word-form').style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存触发词
|
||||
*/
|
||||
async function saveTriggerWords() {
|
||||
const filePath = document.querySelector('.edit-trigger-words-btn').dataset.filePath;
|
||||
const triggerWordTags = document.querySelectorAll('.trigger-word-tag');
|
||||
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
|
||||
|
||||
try {
|
||||
// Special format for updating nested civitai.trainedWords
|
||||
await saveModelMetadata(filePath, {
|
||||
civitai: { trainedWords: words }
|
||||
});
|
||||
|
||||
// Update UI
|
||||
const editBtn = document.querySelector('.edit-trigger-words-btn');
|
||||
editBtn.click(); // Exit edit mode
|
||||
|
||||
// Update the LoRA card's dataset
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
try {
|
||||
// Create a proper structure for civitai data
|
||||
let civitaiData = {};
|
||||
|
||||
// Parse existing data if available
|
||||
if (loraCard.dataset.meta) {
|
||||
civitaiData = JSON.parse(loraCard.dataset.meta);
|
||||
}
|
||||
|
||||
// Update trainedWords property
|
||||
civitaiData.trainedWords = words;
|
||||
|
||||
// Update the meta dataset attribute with the full civitai data
|
||||
loraCard.dataset.meta = JSON.stringify(civitaiData);
|
||||
} catch (e) {
|
||||
console.error('Error updating civitai data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// If we saved an empty array and there's a no-trigger-words element, show it
|
||||
const noTriggerWords = document.querySelector('.no-trigger-words');
|
||||
const tagsContainer = document.querySelector('.trigger-words-tags');
|
||||
if (words.length === 0 && noTriggerWords) {
|
||||
noTriggerWords.style.display = '';
|
||||
if (tagsContainer) tagsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
showToast('Trigger words updated successfully', 'success');
|
||||
} catch (error) {
|
||||
console.error('Error saving trigger words:', error);
|
||||
showToast('Failed to update trigger words', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制触发词到剪贴板
|
||||
* @param {string} word - 要复制的触发词
|
||||
*/
|
||||
window.copyTriggerWord = async function(word) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(word);
|
||||
showToast('Trigger word copied', 'success');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
};
|
||||
302
static/js/components/loraModal/index.js
Normal file
302
static/js/components/loraModal/index.js
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* LoraModal - 主入口点
|
||||
*
|
||||
* 将原始的LoraModal.js拆分成多个功能模块后的主入口文件
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js';
|
||||
import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
|
||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { loadRecipesForLora } from './RecipeTab.js'; // Add import for recipe tab
|
||||
import {
|
||||
setupModelNameEditing,
|
||||
setupBaseModelEditing,
|
||||
setupFileNameEditing,
|
||||
saveModelMetadata
|
||||
} from './ModelMetadata.js';
|
||||
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
|
||||
|
||||
/**
|
||||
* 显示LoRA模型弹窗
|
||||
* @param {Object} lora - LoRA模型数据
|
||||
*/
|
||||
export function showLoraModal(lora) {
|
||||
const escapedWords = lora.civitai?.trainedWords?.length ?
|
||||
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
const content = `
|
||||
<div class="modal-content">
|
||||
<button class="close" onclick="modalManager.closeModal('loraModal')">×</button>
|
||||
<header class="modal-header">
|
||||
<div class="model-name-header">
|
||||
<h2 class="model-name-content" contenteditable="true" spellcheck="false">${lora.model_name}</h2>
|
||||
<button class="edit-model-name-btn" title="Edit model name">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
${renderCompactTags(lora.tags || [])}
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="info-section">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<label>Version</label>
|
||||
<span>${lora.civitai.name || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>File Name</label>
|
||||
<div class="file-name-wrapper">
|
||||
<span id="file-name" class="file-name-content">${lora.file_name || 'N/A'}</span>
|
||||
<button class="edit-file-name-btn" title="Edit file name">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item location-size">
|
||||
<div class="location-wrapper">
|
||||
<label>Location</label>
|
||||
<span class="file-path">${lora.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item base-size">
|
||||
<div class="base-wrapper">
|
||||
<label>Base Model</label>
|
||||
<div class="base-model-display">
|
||||
<span class="base-model-content">${lora.base_model || 'N/A'}</span>
|
||||
<button class="edit-base-model-btn" title="Edit base model">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="size-wrapper">
|
||||
<label>Size</label>
|
||||
<span>${formatFileSize(lora.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item usage-tips">
|
||||
<label>Usage Tips</label>
|
||||
<div class="editable-field">
|
||||
<div class="preset-controls">
|
||||
<select id="preset-selector">
|
||||
<option value="">Add preset parameter...</option>
|
||||
<option value="strength_min">Strength Min</option>
|
||||
<option value="strength_max">Strength Max</option>
|
||||
<option value="strength">Strength</option>
|
||||
<option value="clip_skip">Clip Skip</option>
|
||||
</select>
|
||||
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
|
||||
<button class="add-preset-btn">Add</button>
|
||||
</div>
|
||||
<div class="preset-tags">
|
||||
${renderPresetTags(parsePresets(lora.usage_tips))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderTriggerWords(escapedWords, lora.file_path)}
|
||||
<div class="info-item notes">
|
||||
<label>Additional Notes</label>
|
||||
<div class="editable-field">
|
||||
<div class="notes-content" contenteditable="true" spellcheck="false">${lora.notes || 'Add your notes here...'}</div>
|
||||
<button class="save-btn" onclick="saveNotes('${lora.file_path}')">
|
||||
<i class="fas fa-save"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item full-width">
|
||||
<label>About this version</label>
|
||||
<div class="description-text">${lora.description || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="showcase-section" data-lora-id="${lora.civitai?.modelId || ''}">
|
||||
<div class="showcase-tabs">
|
||||
<button class="tab-btn active" data-tab="showcase">Examples</button>
|
||||
<button class="tab-btn" data-tab="description">Model Description</button>
|
||||
<button class="tab-btn" data-tab="recipes">Recipes</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<div id="showcase-tab" class="tab-pane active">
|
||||
${renderShowcaseContent(lora.civitai?.images)}
|
||||
</div>
|
||||
|
||||
<div id="description-tab" class="tab-pane">
|
||||
<div class="model-description-container">
|
||||
<div class="model-description-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading model description...
|
||||
</div>
|
||||
<div class="model-description-content">
|
||||
${lora.modelDescription || ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recipes-tab" class="tab-pane">
|
||||
<div class="recipes-loading">
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading recipes...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="back-to-top" onclick="scrollToTop(this)">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
modalManager.showModal('loraModal', content);
|
||||
setupEditableFields();
|
||||
setupShowcaseScroll();
|
||||
setupTabSwitching();
|
||||
setupTagTooltip();
|
||||
setupTriggerWordsEditMode();
|
||||
setupModelNameEditing();
|
||||
setupBaseModelEditing();
|
||||
setupFileNameEditing();
|
||||
|
||||
// If we have a model ID but no description, fetch it
|
||||
if (lora.civitai?.modelId && !lora.modelDescription) {
|
||||
loadModelDescription(lora.civitai.modelId, lora.file_path);
|
||||
}
|
||||
|
||||
// Load recipes for this Lora
|
||||
loadRecipesForLora(lora.model_name, lora.sha256);
|
||||
}
|
||||
|
||||
// Copy file name function
|
||||
window.copyFileName = async function(fileName) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileName);
|
||||
showToast('File name copied', 'success');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Add save note function
|
||||
window.saveNotes = async function(filePath) {
|
||||
const content = document.querySelector('.notes-content').textContent;
|
||||
try {
|
||||
await saveModelMetadata(filePath, { notes: content });
|
||||
|
||||
// Update the corresponding lora card's dataset
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (loraCard) {
|
||||
loraCard.dataset.notes = content;
|
||||
}
|
||||
|
||||
showToast('Notes saved successfully', 'success');
|
||||
} catch (error) {
|
||||
showToast('Failed to save notes', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
function setupEditableFields() {
|
||||
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
|
||||
|
||||
editableFields.forEach(field => {
|
||||
field.addEventListener('focus', function() {
|
||||
if (this.textContent === 'Add your notes here...') {
|
||||
this.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
field.addEventListener('blur', function() {
|
||||
if (this.textContent.trim() === '') {
|
||||
if (this.classList.contains('notes-content')) {
|
||||
this.textContent = 'Add your notes here...';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const presetSelector = document.getElementById('preset-selector');
|
||||
const presetValue = document.getElementById('preset-value');
|
||||
const addPresetBtn = document.querySelector('.add-preset-btn');
|
||||
const presetTags = document.querySelector('.preset-tags');
|
||||
|
||||
presetSelector.addEventListener('change', function() {
|
||||
const selected = this.value;
|
||||
if (selected) {
|
||||
presetValue.style.display = 'inline-block';
|
||||
presetValue.min = selected.includes('strength') ? -10 : 0;
|
||||
presetValue.max = selected.includes('strength') ? 10 : 10;
|
||||
presetValue.step = 0.5;
|
||||
if (selected === 'clip_skip') {
|
||||
presetValue.type = 'number';
|
||||
presetValue.step = 1;
|
||||
}
|
||||
// Add auto-focus
|
||||
setTimeout(() => presetValue.focus(), 0);
|
||||
} else {
|
||||
presetValue.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
addPresetBtn.addEventListener('click', async function() {
|
||||
const key = presetSelector.value;
|
||||
const value = presetValue.value;
|
||||
|
||||
if (!key || !value) return;
|
||||
|
||||
const filePath = document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
|
||||
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
|
||||
|
||||
currentPresets[key] = parseFloat(value);
|
||||
const newPresetsJson = JSON.stringify(currentPresets);
|
||||
|
||||
await saveModelMetadata(filePath, {
|
||||
usage_tips: newPresetsJson
|
||||
});
|
||||
|
||||
loraCard.dataset.usage_tips = newPresetsJson;
|
||||
presetTags.innerHTML = renderPresetTags(currentPresets);
|
||||
|
||||
presetSelector.value = '';
|
||||
presetValue.value = '';
|
||||
presetValue.style.display = 'none';
|
||||
});
|
||||
|
||||
// Add keydown event listeners for notes
|
||||
const notesContent = document.querySelector('.notes-content');
|
||||
if (notesContent) {
|
||||
notesContent.addEventListener('keydown', async function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) {
|
||||
// Allow shift+enter for new line
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const filePath = document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('.file-path').textContent +
|
||||
document.querySelector('#loraModal .modal-content')
|
||||
.querySelector('#file-name').textContent + '.safetensors';
|
||||
await saveNotes(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add keydown event for preset value
|
||||
presetValue.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addPresetBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export functions for global access
|
||||
export { toggleShowcase, scrollToTop };
|
||||
73
static/js/components/loraModal/utils.js
Normal file
73
static/js/components/loraModal/utils.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* utils.js
|
||||
* LoraModal组件的辅助函数集合
|
||||
*/
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 字节数
|
||||
* @returns {string} 格式化后的文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (!bytes) return 'N/A';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染紧凑标签
|
||||
* @param {Array} tags - 标签数组
|
||||
* @returns {string} HTML内容
|
||||
*/
|
||||
export function renderCompactTags(tags) {
|
||||
if (!tags || tags.length === 0) return '';
|
||||
|
||||
// Display up to 5 tags, with a tooltip indicator if there are more
|
||||
const visibleTags = tags.slice(0, 5);
|
||||
const remainingCount = Math.max(0, tags.length - 5);
|
||||
|
||||
return `
|
||||
<div class="model-tags-container">
|
||||
<div class="model-tags-compact">
|
||||
${visibleTags.map(tag => `<span class="model-tag-compact">${tag}</span>`).join('')}
|
||||
${remainingCount > 0 ?
|
||||
`<span class="model-tag-more" data-count="${remainingCount}">+${remainingCount}</span>` :
|
||||
''}
|
||||
</div>
|
||||
${tags.length > 0 ?
|
||||
`<div class="model-tags-tooltip">
|
||||
<div class="tooltip-content">
|
||||
${tags.map(tag => `<span class="tooltip-tag">${tag}</span>`).join('')}
|
||||
</div>
|
||||
</div>` :
|
||||
''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置标签提示功能
|
||||
*/
|
||||
export function setupTagTooltip() {
|
||||
const tagsContainer = document.querySelector('.model-tags-container');
|
||||
const tooltip = document.querySelector('.model-tags-tooltip');
|
||||
|
||||
if (tagsContainer && tooltip) {
|
||||
tagsContainer.addEventListener('mouseenter', () => {
|
||||
tooltip.classList.add('visible');
|
||||
});
|
||||
|
||||
tagsContainer.addEventListener('mouseleave', () => {
|
||||
tooltip.classList.remove('visible');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { LoadingManager } from './managers/LoadingManager.js';
|
||||
import { modalManager } from './managers/ModalManager.js';
|
||||
import { updateService } from './managers/UpdateService.js';
|
||||
import { HeaderManager } from './components/Header.js';
|
||||
import { SettingsManager } from './managers/SettingsManager.js';
|
||||
import { settingsManager } from './managers/SettingsManager.js';
|
||||
import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||
@@ -26,7 +26,7 @@ export class AppCore {
|
||||
modalManager.initialize();
|
||||
updateService.initialize();
|
||||
window.modalManager = modalManager;
|
||||
window.settingsManager = new SettingsManager();
|
||||
window.settingsManager = settingsManager;
|
||||
|
||||
// Initialize UI components
|
||||
window.headerManager = new HeaderManager();
|
||||
@@ -76,4 +76,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
export const appCore = new AppCore();
|
||||
|
||||
// Export common utilities for global use
|
||||
export { showToast, lazyLoadImages, initializeInfiniteScroll };
|
||||
export { showToast, lazyLoadImages, initializeInfiniteScroll };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { appCore } from './core.js';
|
||||
import { state } from './state/index.js';
|
||||
import { showLoraModal, toggleShowcase, scrollToTop } from './components/LoraModal.js';
|
||||
import { showLoraModal, toggleShowcase, scrollToTop } from './components/loraModal/index.js';
|
||||
import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js';
|
||||
import {
|
||||
restoreFolderFilter,
|
||||
@@ -17,7 +17,7 @@ import { LoraContextMenu } from './components/ContextMenu.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
import { updateCardsForBulkMode } from './components/LoraCard.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { setStorageItem, getStorageItem } from './utils/storageHelpers.js';
|
||||
import { setStorageItem, getStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
|
||||
// Initialize the LoRA page
|
||||
class LoraPageManager {
|
||||
@@ -69,6 +69,9 @@ class LoraPageManager {
|
||||
initFolderTagsVisibility();
|
||||
new LoraContextMenu();
|
||||
|
||||
// Check for custom filters from recipe page navigation
|
||||
this.checkCustomFilters();
|
||||
|
||||
// Initialize cards for current bulk mode state (should be false initially)
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
@@ -79,6 +82,87 @@ class LoraPageManager {
|
||||
appCore.initializePageFeatures();
|
||||
}
|
||||
|
||||
// Check for custom filter parameters in session storage
|
||||
checkCustomFilters() {
|
||||
const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash');
|
||||
const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
const filterRecipeName = getSessionItem('filterRecipeName');
|
||||
const viewLoraDetail = getSessionItem('viewLoraDetail');
|
||||
|
||||
console.log("Checking custom filters...");
|
||||
console.log("filterLoraHash:", filterLoraHash);
|
||||
console.log("filterLoraHashes:", filterLoraHashes);
|
||||
console.log("filterRecipeName:", filterRecipeName);
|
||||
console.log("viewLoraDetail:", viewLoraDetail);
|
||||
|
||||
if ((filterLoraHash || filterLoraHashes) && filterRecipeName) {
|
||||
// Found custom filter parameters, set up the custom filter
|
||||
|
||||
// Show the filter indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const filterText = indicator.querySelector('.customFilterText');
|
||||
|
||||
if (indicator && filterText) {
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
// Set text content with recipe name
|
||||
const filterType = filterLoraHash && viewLoraDetail ? "Viewing LoRA from" : "Viewing LoRAs from";
|
||||
const displayText = `${filterType}: ${filterRecipeName}`;
|
||||
|
||||
filterText.textContent = this._truncateText(displayText, 30);
|
||||
filterText.setAttribute('title', displayText);
|
||||
|
||||
// Add click handler for the clear button
|
||||
const clearBtn = indicator.querySelector('.clear-filter');
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', this.clearCustomFilter);
|
||||
}
|
||||
|
||||
// Add pulse animation
|
||||
const filterElement = indicator.querySelector('.filter-active');
|
||||
if (filterElement) {
|
||||
filterElement.classList.add('animate');
|
||||
setTimeout(() => filterElement.classList.remove('animate'), 600);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're viewing a specific LoRA detail, set up to open the modal
|
||||
if (filterLoraHash && viewLoraDetail) {
|
||||
// Store this to fetch after initial load completes
|
||||
state.pendingLoraHash = filterLoraHash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to truncate text with ellipsis
|
||||
_truncateText(text, maxLength) {
|
||||
return text.length > maxLength ? text.substring(0, maxLength - 3) + '...' : text;
|
||||
}
|
||||
|
||||
// Clear the custom filter and reload the page
|
||||
clearCustomFilter = async () => {
|
||||
console.log("Clearing custom filter...");
|
||||
// Remove filter parameters from session storage
|
||||
removeSessionItem('recipe_to_lora_filterLoraHash');
|
||||
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
||||
removeSessionItem('filterRecipeName');
|
||||
removeSessionItem('viewLoraDetail');
|
||||
|
||||
// Hide the filter indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Reset state
|
||||
if (state.pendingLoraHash) {
|
||||
delete state.pendingLoraHash;
|
||||
}
|
||||
|
||||
// Reload the loras
|
||||
await resetAndReload();
|
||||
}
|
||||
|
||||
loadSortPreference() {
|
||||
const savedSort = getStorageItem('loras_sort');
|
||||
if (savedSort) {
|
||||
|
||||
@@ -65,6 +65,12 @@ export class SettingsManager {
|
||||
// Sync with state (backend will set this via template)
|
||||
state.global.settings.show_only_sfw = showOnlySFWCheckbox.checked;
|
||||
}
|
||||
|
||||
// Set video autoplay on hover setting
|
||||
const autoplayOnHoverCheckbox = document.getElementById('autoplayOnHover');
|
||||
if (autoplayOnHoverCheckbox) {
|
||||
autoplayOnHoverCheckbox.checked = state.global.settings.autoplayOnHover || false;
|
||||
}
|
||||
|
||||
// Load default lora root
|
||||
await this.loadLoraRoots();
|
||||
@@ -120,11 +126,170 @@ export class SettingsManager {
|
||||
this.isOpen = !this.isOpen;
|
||||
}
|
||||
|
||||
// Auto-save methods for different control types
|
||||
|
||||
// For toggle switches
|
||||
async saveToggleSetting(elementId, settingKey) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const value = element.checked;
|
||||
|
||||
// Update frontend state
|
||||
if (settingKey === 'blur_mature_content') {
|
||||
state.global.settings.blurMatureContent = value;
|
||||
} else if (settingKey === 'show_only_sfw') {
|
||||
state.global.settings.show_only_sfw = value;
|
||||
} else if (settingKey === 'autoplay_on_hover') {
|
||||
state.global.settings.autoplayOnHover = value;
|
||||
} else {
|
||||
// For any other settings that might be added in the future
|
||||
state.global.settings[settingKey] = value;
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('settings', state.global.settings);
|
||||
|
||||
try {
|
||||
// For backend settings, make API call
|
||||
if (['show_only_sfw', 'blur_mature_content', 'autoplay_on_hover'].includes(settingKey)) {
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save setting');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
}
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
if (settingKey === 'show_only_sfw') {
|
||||
this.reloadContent();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// For select dropdowns
|
||||
async saveSelectSetting(elementId, settingKey) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const value = element.value;
|
||||
|
||||
// Update frontend state
|
||||
if (settingKey === 'default_lora_root') {
|
||||
state.global.settings.default_loras_root = value;
|
||||
} else {
|
||||
// For any other settings that might be added in the future
|
||||
state.global.settings[settingKey] = value;
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
setStorageItem('settings', state.global.settings);
|
||||
|
||||
try {
|
||||
// For backend settings, make API call
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save setting');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// For input fields
|
||||
async saveInputSetting(elementId, settingKey) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const value = element.value;
|
||||
|
||||
// For API key or other inputs that need to be saved on backend
|
||||
try {
|
||||
// Check if value has changed from existing value
|
||||
const currentValue = state.global.settings[settingKey] || '';
|
||||
if (value === currentValue) {
|
||||
return; // No change, exit early
|
||||
}
|
||||
|
||||
// Update state
|
||||
state.global.settings[settingKey] = value;
|
||||
|
||||
// Save to localStorage if appropriate
|
||||
if (!settingKey.includes('api_key')) { // Don't store API keys in localStorage for security
|
||||
setStorageItem('settings', state.global.settings);
|
||||
}
|
||||
|
||||
// For backend settings, make API call
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save setting');
|
||||
}
|
||||
|
||||
showToast(`Settings updated: ${settingKey.replace(/_/g, ' ')}`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
showToast('Failed to save setting: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async reloadContent() {
|
||||
if (this.currentPage === 'loras') {
|
||||
// Reload the loras without updating folders
|
||||
await resetAndReload(false);
|
||||
} else if (this.currentPage === 'recipes') {
|
||||
// Reload the recipes without updating folders
|
||||
await window.recipeManager.loadRecipes();
|
||||
} else if (this.currentPage === 'checkpoints') {
|
||||
// Reload the checkpoints without updating folders
|
||||
await window.checkpointsManager.loadCheckpoints();
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
// Get frontend settings from UI
|
||||
const blurMatureContent = document.getElementById('blurMatureContent').checked;
|
||||
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
||||
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
||||
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
|
||||
|
||||
// Get backend settings
|
||||
const apiKey = document.getElementById('civitaiApiKey').value;
|
||||
@@ -133,6 +298,7 @@ export class SettingsManager {
|
||||
state.global.settings.blurMatureContent = blurMatureContent;
|
||||
state.global.settings.show_only_sfw = showOnlySFW;
|
||||
state.global.settings.default_loras_root = defaultLoraRoot;
|
||||
state.global.settings.autoplayOnHover = autoplayOnHover;
|
||||
|
||||
// Save settings to localStorage
|
||||
setStorageItem('settings', state.global.settings);
|
||||
@@ -186,11 +352,42 @@ export class SettingsManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Apply autoplay setting to existing videos in card previews
|
||||
const autoplayOnHover = state.global.settings.autoplayOnHover;
|
||||
document.querySelectorAll('.card-preview video').forEach(video => {
|
||||
// Remove previous event listeners by cloning and replacing the element
|
||||
const videoParent = video.parentElement;
|
||||
const videoClone = video.cloneNode(true);
|
||||
|
||||
if (autoplayOnHover) {
|
||||
// Pause video initially and set up mouse events for hover playback
|
||||
videoClone.removeAttribute('autoplay');
|
||||
videoClone.pause();
|
||||
|
||||
// Add mouse events to the parent element
|
||||
videoParent.onmouseenter = () => videoClone.play();
|
||||
videoParent.onmouseleave = () => {
|
||||
videoClone.pause();
|
||||
videoClone.currentTime = 0;
|
||||
};
|
||||
} else {
|
||||
// Use default autoplay behavior
|
||||
videoClone.setAttribute('autoplay', '');
|
||||
videoParent.onmouseenter = null;
|
||||
videoParent.onmouseleave = null;
|
||||
}
|
||||
|
||||
videoParent.replaceChild(videoClone, video);
|
||||
});
|
||||
|
||||
// For show_only_sfw, there's no immediate action needed as it affects content loading
|
||||
// The setting will take effect on next reload
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const settingsManager = new SettingsManager();
|
||||
|
||||
// Helper function for toggling API key visibility
|
||||
export function toggleApiKeyVisibility(button) {
|
||||
const input = button.parentElement.querySelector('input');
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RecipeCard } from './components/RecipeCard.js';
|
||||
import { RecipeModal } from './components/RecipeModal.js';
|
||||
import { getCurrentPageState } from './state/index.js';
|
||||
import { toggleApiKeyVisibility } from './managers/SettingsManager.js';
|
||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||
|
||||
class RecipeManager {
|
||||
constructor() {
|
||||
@@ -20,6 +21,14 @@ class RecipeManager {
|
||||
// Add state tracking for infinite scroll
|
||||
this.pageState.isLoading = false;
|
||||
this.pageState.hasMore = true;
|
||||
|
||||
// Custom filter state
|
||||
this.customFilter = {
|
||||
active: false,
|
||||
loraName: null,
|
||||
loraHash: null,
|
||||
recipeId: null
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
@@ -29,6 +38,9 @@ class RecipeManager {
|
||||
// Set default search options if not already defined
|
||||
this._initSearchOptions();
|
||||
|
||||
// Check for custom filter parameters in session storage
|
||||
this._checkCustomFilter();
|
||||
|
||||
// Load initial set of recipes
|
||||
await this.loadRecipes();
|
||||
|
||||
@@ -58,6 +70,100 @@ class RecipeManager {
|
||||
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
|
||||
}
|
||||
|
||||
_checkCustomFilter() {
|
||||
// Check for Lora filter
|
||||
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
||||
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
||||
|
||||
// Check for specific recipe ID
|
||||
const viewRecipeId = getSessionItem('viewRecipeId');
|
||||
|
||||
// Set custom filter if any parameter is present
|
||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
||||
this.customFilter = {
|
||||
active: true,
|
||||
loraName: filterLoraName,
|
||||
loraHash: filterLoraHash,
|
||||
recipeId: viewRecipeId
|
||||
};
|
||||
|
||||
// Show custom filter indicator
|
||||
this._showCustomFilterIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
_showCustomFilterIndicator() {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const textElement = document.getElementById('customFilterText');
|
||||
|
||||
if (!indicator || !textElement) return;
|
||||
|
||||
// Update text based on filter type
|
||||
let filterText = '';
|
||||
|
||||
if (this.customFilter.recipeId) {
|
||||
filterText = 'Viewing specific recipe';
|
||||
} else if (this.customFilter.loraName) {
|
||||
// Format with Lora name
|
||||
const loraName = this.customFilter.loraName;
|
||||
const displayName = loraName.length > 25 ?
|
||||
loraName.substring(0, 22) + '...' :
|
||||
loraName;
|
||||
|
||||
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
||||
} else {
|
||||
filterText = 'Filtered recipes';
|
||||
}
|
||||
|
||||
// Update indicator text and show it
|
||||
textElement.innerHTML = filterText;
|
||||
// Add title attribute to show the lora name as a tooltip
|
||||
if (this.customFilter.loraName) {
|
||||
textElement.setAttribute('title', this.customFilter.loraName);
|
||||
}
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
// Add pulse animation
|
||||
const filterElement = indicator.querySelector('.filter-active');
|
||||
if (filterElement) {
|
||||
filterElement.classList.add('animate');
|
||||
setTimeout(() => filterElement.classList.remove('animate'), 600);
|
||||
}
|
||||
|
||||
// Add click handler for clear filter button
|
||||
const clearFilterBtn = indicator.querySelector('.clear-filter');
|
||||
if (clearFilterBtn) {
|
||||
clearFilterBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // Prevent button click from triggering
|
||||
this._clearCustomFilter();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_clearCustomFilter() {
|
||||
// Reset custom filter
|
||||
this.customFilter = {
|
||||
active: false,
|
||||
loraName: null,
|
||||
loraHash: null,
|
||||
recipeId: null
|
||||
};
|
||||
|
||||
// Hide indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Clear any session storage items
|
||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
// Reload recipes without custom filter
|
||||
this.loadRecipes();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
// Sort select
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
@@ -83,6 +189,12 @@ class RecipeManager {
|
||||
if (grid) grid.innerHTML = '';
|
||||
}
|
||||
|
||||
// If we have a specific recipe ID to load
|
||||
if (this.customFilter.active && this.customFilter.recipeId) {
|
||||
await this._loadSpecificRecipe(this.customFilter.recipeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams({
|
||||
page: this.pageState.currentPage,
|
||||
@@ -90,28 +202,38 @@ class RecipeManager {
|
||||
sort_by: this.pageState.sortBy
|
||||
});
|
||||
|
||||
// Add search filter if present
|
||||
if (this.pageState.filters.search) {
|
||||
params.append('search', this.pageState.filters.search);
|
||||
// Add custom filter for Lora if present
|
||||
if (this.customFilter.active && this.customFilter.loraHash) {
|
||||
params.append('lora_hash', this.customFilter.loraHash);
|
||||
|
||||
// Add search option parameters
|
||||
if (this.pageState.searchOptions) {
|
||||
params.append('search_title', this.pageState.searchOptions.title.toString());
|
||||
params.append('search_tags', this.pageState.searchOptions.tags.toString());
|
||||
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
|
||||
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
|
||||
params.append('fuzzy', 'true');
|
||||
// Skip other filters when using custom filter
|
||||
params.append('bypass_filters', 'true');
|
||||
} else {
|
||||
// Normal filtering logic
|
||||
|
||||
// Add search filter if present
|
||||
if (this.pageState.filters.search) {
|
||||
params.append('search', this.pageState.filters.search);
|
||||
|
||||
// Add search option parameters
|
||||
if (this.pageState.searchOptions) {
|
||||
params.append('search_title', this.pageState.searchOptions.title.toString());
|
||||
params.append('search_tags', this.pageState.searchOptions.tags.toString());
|
||||
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
|
||||
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
|
||||
params.append('fuzzy', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
// Add base model filters
|
||||
if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) {
|
||||
params.append('base_models', this.pageState.filters.baseModel.join(','));
|
||||
}
|
||||
|
||||
// Add tag filters
|
||||
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
|
||||
params.append('tags', this.pageState.filters.tags.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Add base model filters
|
||||
if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) {
|
||||
params.append('base_models', this.pageState.filters.baseModel.join(','));
|
||||
}
|
||||
|
||||
// Add tag filters
|
||||
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
|
||||
params.append('tags', this.pageState.filters.tags.join(','));
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
@@ -139,6 +261,46 @@ class RecipeManager {
|
||||
}
|
||||
}
|
||||
|
||||
async _loadSpecificRecipe(recipeId) {
|
||||
try {
|
||||
// Fetch specific recipe by ID
|
||||
const response = await fetch(`/api/recipe/${recipeId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const recipe = await response.json();
|
||||
|
||||
// Create a data structure that matches the expected format
|
||||
const recipeData = {
|
||||
items: [recipe],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
total_pages: 1
|
||||
};
|
||||
|
||||
// Update grid with single recipe
|
||||
this.updateRecipesGrid(recipeData, true);
|
||||
|
||||
// Pagination not needed for single recipe
|
||||
this.pageState.hasMore = false;
|
||||
|
||||
// Show recipe details modal
|
||||
setTimeout(() => {
|
||||
this.showRecipeDetails(recipe);
|
||||
}, 300);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading specific recipe:', error);
|
||||
appCore.showToast('Failed to load recipe details', 'error');
|
||||
|
||||
// Clear the filter and show all recipes
|
||||
this._clearCustomFilter();
|
||||
}
|
||||
}
|
||||
|
||||
updateRecipesGrid(data, resetGrid = true) {
|
||||
const grid = document.getElementById('recipeGrid');
|
||||
if (!grid) return;
|
||||
|
||||
@@ -69,6 +69,53 @@ export function removeStorageItem(key) {
|
||||
localStorage.removeItem(key); // Also remove legacy key
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item from sessionStorage with namespace support
|
||||
* @param {string} key - The key without prefix
|
||||
* @param {any} defaultValue - Default value if key doesn't exist
|
||||
* @returns {any} The stored value or defaultValue
|
||||
*/
|
||||
export function getSessionItem(key, defaultValue = null) {
|
||||
// Try with prefix
|
||||
const prefixedValue = sessionStorage.getItem(STORAGE_PREFIX + key);
|
||||
|
||||
if (prefixedValue !== null) {
|
||||
// If it's a JSON string, parse it
|
||||
try {
|
||||
return JSON.parse(prefixedValue);
|
||||
} catch (e) {
|
||||
return prefixedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Return default value if key doesn't exist
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an item in sessionStorage with namespace prefix
|
||||
* @param {string} key - The key without prefix
|
||||
* @param {any} value - The value to store
|
||||
*/
|
||||
export function setSessionItem(key, value) {
|
||||
const prefixedKey = STORAGE_PREFIX + key;
|
||||
|
||||
// Convert objects and arrays to JSON strings
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
sessionStorage.setItem(prefixedKey, JSON.stringify(value));
|
||||
} else {
|
||||
sessionStorage.setItem(prefixedKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from sessionStorage with namespace prefix
|
||||
* @param {string} key - The key without prefix
|
||||
*/
|
||||
export function removeSessionItem(key) {
|
||||
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate all existing localStorage items to use the prefix
|
||||
* This should be called once during application initialization
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<button onclick="refreshLoras()"><i class="fas fa-sync"></i> Refresh</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button onclick="fetchCivitai()" class="secondary" title="Fetch from Civitai"><i class="fas fa-download"></i> Fetch</button>
|
||||
<button onclick="fetchCivitai()" title="Fetch from Civitai"><i class="fas fa-download"></i> Fetch</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button onclick="downloadManager.showDownloadModal()" title="Download from URL">
|
||||
@@ -31,6 +31,12 @@
|
||||
<i class="fas fa-th-large"></i> Bulk
|
||||
</button>
|
||||
</div>
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-folders-container">
|
||||
<button class="toggle-folders-btn icon-only" onclick="toggleFolderTags()" title="Toggle folder tags">
|
||||
|
||||
@@ -17,16 +17,24 @@
|
||||
<button class="close" onclick="modalManager.closeModal('settingsModal')">×</button>
|
||||
<h2>Settings</h2>
|
||||
<div class="settings-form">
|
||||
<div class="input-group">
|
||||
<label for="civitaiApiKey">Civitai API Key:</label>
|
||||
<div class="api-key-input">
|
||||
<input type="password"
|
||||
id="civitaiApiKey"
|
||||
placeholder="Enter your Civitai API key"
|
||||
value="{{ settings.get('civitai_api_key', '') }}" />
|
||||
<button class="toggle-visibility" onclick="toggleApiKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<div class="setting-item api-key-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="civitaiApiKey">Civitai API Key:</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<div class="api-key-input">
|
||||
<input type="password"
|
||||
id="civitaiApiKey"
|
||||
placeholder="Enter your Civitai API key"
|
||||
value="{{ settings.get('civitai_api_key', '') }}"
|
||||
onblur="settingsManager.saveInputSetting('civitaiApiKey', 'civitai_api_key')"
|
||||
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
|
||||
<button class="toggle-visibility" onclick="toggleApiKeyVisibility(this)">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Used for authentication when downloading models from Civitai
|
||||
@@ -37,32 +45,61 @@
|
||||
<h3>Content Filtering</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label for="blurMatureContent">Blur NSFW Content</label>
|
||||
<div class="input-help">
|
||||
Blur mature (NSFW) content preview images
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="blurMatureContent">Blur NSFW Content</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="blurMatureContent" checked
|
||||
onchange="settingsManager.saveToggleSetting('blurMatureContent', 'blur_mature_content')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="blurMatureContent" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="input-help">
|
||||
Blur mature (NSFW) content preview images
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label for="showOnlySFW">Show Only SFW Results</label>
|
||||
<div class="input-help">
|
||||
Filter out all NSFW content when browsing and searching
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="showOnlySFW">Show Only SFW Results</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="showOnlySFW" value="{{ settings.get('show_only_sfw', False) }}" {% if settings.get('show_only_sfw', False) %}checked{% endif %}
|
||||
onchange="settingsManager.saveToggleSetting('showOnlySFW', 'show_only_sfw')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="showOnlySFW" value="{{ settings.get('show_only_sfw', False) }}" {% if settings.get('show_only_sfw', False) %}checked{% endif %}>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<div class="input-help">
|
||||
Filter out all NSFW content when browsing and searching
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Video Settings Section -->
|
||||
<div class="settings-section">
|
||||
<h3>Video Settings</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="autoplayOnHover">Autoplay Videos on Hover</label>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="autoplayOnHover"
|
||||
onchange="settingsManager.saveToggleSetting('autoplayOnHover', 'autoplay_on_hover')">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Only play video previews when hovering over them
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,14 +109,16 @@
|
||||
<h3>Folder Settings</h3>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<label for="defaultLoraRoot">Default LoRA Root</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="defaultLoraRoot">
|
||||
<option value="">No Default</option>
|
||||
<!-- Options will be loaded dynamically -->
|
||||
</select>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="defaultLoraRoot">Default LoRA Root</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')">
|
||||
<option value="">No Default</option>
|
||||
<!-- Options will be loaded dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Set the default LoRA root directory for downloads, imports and moves
|
||||
@@ -87,18 +126,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="primary-btn" onclick="settingsManager.saveSettings()">Save</button>
|
||||
</div>
|
||||
<!-- Add the new links section -->
|
||||
<div class="settings-links">
|
||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager" target="_blank" class="settings-link" title="GitHub Repository">
|
||||
<i class="fab fa-github"></i>
|
||||
</a>
|
||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/issues/new" target="_blank" class="settings-link" title="Report Issue">
|
||||
<i class="fas fa-bug"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
<h3>Resources</h3>
|
||||
<div class="recipe-section-actions">
|
||||
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
|
||||
@@ -32,6 +32,13 @@
|
||||
<div title="Import recipes" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> Import</button>
|
||||
</div>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">Filtered by LoRA</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user