mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 22:22:11 -03:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64c9e4aeca | ||
|
|
08b90e8767 | ||
|
|
0206613f9e | ||
|
|
ae0629628e | ||
|
|
785b2e7287 | ||
|
|
43e3d0552e | ||
|
|
801aa2e876 | ||
|
|
bddc7a438d | ||
|
|
b8c78a68e7 | ||
|
|
49219f4447 | ||
|
|
59b1abb719 | ||
|
|
3e2cfb552b | ||
|
|
779be1b8d0 | ||
|
|
faf74de238 | ||
|
|
50a51c2e79 | ||
|
|
d31e641496 | ||
|
|
f2d36f5be9 | ||
|
|
0b55f61fac | ||
|
|
4156dcbafd | ||
|
|
36e6ac2362 | ||
|
|
9613199152 | ||
|
|
14328d7496 | ||
|
|
6af12d1acc | ||
|
|
9b44e49879 | ||
|
|
afee18f146 |
11
README.md
11
README.md
@@ -20,6 +20,17 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
|||||||
|
|
||||||
## Release Notes
|
## 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
|
||||||
|
* **Bug Fixes & Stability** - Resolved various issues for improved reliability
|
||||||
|
|
||||||
### v0.8.3
|
### v0.8.3
|
||||||
* **Enhanced Workflow Parser** - Rebuilt workflow analysis engine with improved support for ComfyUI core nodes and easier extensibility
|
* **Enhanced Workflow Parser** - Rebuilt workflow analysis engine with improved support for ComfyUI core nodes and easier extensibility
|
||||||
* **Improved Recipe System** - Refined the experimental Save Recipe functionality with better workflow integration
|
* **Improved Recipe System** - Refined the experimental Save Recipe functionality with better workflow integration
|
||||||
|
|||||||
11
py/config.py
11
py/config.py
@@ -85,6 +85,17 @@ class Config:
|
|||||||
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
mapped_path = normalized_path.replace(target_path, link_path, 1)
|
||||||
return mapped_path
|
return mapped_path
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def map_link_to_path(self, link_path: str) -> str:
|
||||||
|
"""将符号链接路径映射回实际路径"""
|
||||||
|
normalized_link = os.path.normpath(link_path).replace(os.sep, '/')
|
||||||
|
# 检查路径是否包含在任何映射的目标路径中
|
||||||
|
for target_path, link_path in self._path_mappings.items():
|
||||||
|
if normalized_link.startswith(target_path):
|
||||||
|
# 如果路径以目标路径开头,则替换为实际路径
|
||||||
|
mapped_path = normalized_link.replace(target_path, link_path, 1)
|
||||||
|
return mapped_path
|
||||||
|
return link_path
|
||||||
|
|
||||||
def _init_lora_paths(self) -> List[str]:
|
def _init_lora_paths(self) -> List[str]:
|
||||||
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
"""Initialize and validate LoRA paths from ComfyUI settings"""
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class SaveImage:
|
|||||||
"file_format": (["png", "jpeg", "webp"],),
|
"file_format": (["png", "jpeg", "webp"],),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
|
"custom_prompt": ("STRING", {"default": "", "forceInput": True}),
|
||||||
"lossless_webp": ("BOOLEAN", {"default": True}),
|
"lossless_webp": ("BOOLEAN", {"default": True}),
|
||||||
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
|
"quality": ("INT", {"default": 100, "min": 1, "max": 100}),
|
||||||
"embed_workflow": ("BOOLEAN", {"default": False}),
|
"embed_workflow": ("BOOLEAN", {"default": False}),
|
||||||
@@ -60,7 +61,7 @@ class SaveImage:
|
|||||||
return item.get('sha256')
|
return item.get('sha256')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def format_metadata(self, parsed_workflow):
|
async def format_metadata(self, parsed_workflow, custom_prompt=None):
|
||||||
"""Format metadata in the requested format similar to userComment example"""
|
"""Format metadata in the requested format similar to userComment example"""
|
||||||
if not parsed_workflow:
|
if not parsed_workflow:
|
||||||
return ""
|
return ""
|
||||||
@@ -69,6 +70,10 @@ class SaveImage:
|
|||||||
prompt = parsed_workflow.get('prompt', '')
|
prompt = parsed_workflow.get('prompt', '')
|
||||||
negative_prompt = parsed_workflow.get('negative_prompt', '')
|
negative_prompt = parsed_workflow.get('negative_prompt', '')
|
||||||
|
|
||||||
|
# Override prompt with custom_prompt if provided
|
||||||
|
if custom_prompt:
|
||||||
|
prompt = custom_prompt
|
||||||
|
|
||||||
# Extract loras from the prompt if present
|
# Extract loras from the prompt if present
|
||||||
loras_text = parsed_workflow.get('loras', '')
|
loras_text = parsed_workflow.get('loras', '')
|
||||||
lora_hashes = {}
|
lora_hashes = {}
|
||||||
@@ -240,7 +245,8 @@ class SaveImage:
|
|||||||
return filename
|
return filename
|
||||||
|
|
||||||
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None,
|
def save_images(self, images, filename_prefix, file_format, prompt=None, extra_pnginfo=None,
|
||||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
|
||||||
|
custom_prompt=None):
|
||||||
"""Save images with metadata"""
|
"""Save images with metadata"""
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
@@ -252,43 +258,45 @@ class SaveImage:
|
|||||||
parsed_workflow = {}
|
parsed_workflow = {}
|
||||||
|
|
||||||
# Get or create metadata asynchronously
|
# Get or create metadata asynchronously
|
||||||
metadata = asyncio.run(self.format_metadata(parsed_workflow))
|
metadata = asyncio.run(self.format_metadata(parsed_workflow, custom_prompt))
|
||||||
|
|
||||||
# Process filename_prefix with pattern substitution
|
# Process filename_prefix with pattern substitution
|
||||||
filename_prefix = self.format_filename(filename_prefix, parsed_workflow)
|
filename_prefix = self.format_filename(filename_prefix, parsed_workflow)
|
||||||
|
|
||||||
# Process each image
|
# Get initial save path info once for the batch
|
||||||
|
full_output_folder, filename, counter, subfolder, processed_prefix = folder_paths.get_save_image_path(
|
||||||
|
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create directory if it doesn't exist
|
||||||
|
if not os.path.exists(full_output_folder):
|
||||||
|
os.makedirs(full_output_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Process each image with incrementing counter
|
||||||
for i, image in enumerate(images):
|
for i, image in enumerate(images):
|
||||||
# Convert the tensor image to numpy array
|
# Convert the tensor image to numpy array
|
||||||
img = 255. * image.cpu().numpy()
|
img = 255. * image.cpu().numpy()
|
||||||
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
img = Image.fromarray(np.clip(img, 0, 255).astype(np.uint8))
|
||||||
|
|
||||||
# Create directory if filename_prefix contains path separators
|
|
||||||
output_path = os.path.join(self.output_dir, filename_prefix)
|
|
||||||
if not os.path.exists(os.path.dirname(output_path)):
|
|
||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
||||||
|
|
||||||
# Use folder_paths.get_save_image_path for better counter handling
|
|
||||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
|
|
||||||
filename_prefix, self.output_dir, img.width, img.height
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate filename with counter if needed
|
# Generate filename with counter if needed
|
||||||
|
base_filename = filename
|
||||||
if add_counter_to_filename:
|
if add_counter_to_filename:
|
||||||
filename += f"_{counter:05}"
|
# Use counter + i to ensure unique filenames for all images in batch
|
||||||
|
current_counter = counter + i
|
||||||
|
base_filename += f"_{current_counter:05}"
|
||||||
|
|
||||||
# Set file extension and prepare saving parameters
|
# Set file extension and prepare saving parameters
|
||||||
if file_format == "png":
|
if file_format == "png":
|
||||||
file = filename + ".png"
|
file = base_filename + ".png"
|
||||||
file_extension = ".png"
|
file_extension = ".png"
|
||||||
save_kwargs = {"optimize": True, "compress_level": self.compress_level}
|
save_kwargs = {"optimize": True, "compress_level": self.compress_level}
|
||||||
pnginfo = PngImagePlugin.PngInfo()
|
pnginfo = PngImagePlugin.PngInfo()
|
||||||
elif file_format == "jpeg":
|
elif file_format == "jpeg":
|
||||||
file = filename + ".jpg"
|
file = base_filename + ".jpg"
|
||||||
file_extension = ".jpg"
|
file_extension = ".jpg"
|
||||||
save_kwargs = {"quality": quality, "optimize": True}
|
save_kwargs = {"quality": quality, "optimize": True}
|
||||||
elif file_format == "webp":
|
elif file_format == "webp":
|
||||||
file = filename + ".webp"
|
file = base_filename + ".webp"
|
||||||
file_extension = ".webp"
|
file_extension = ".webp"
|
||||||
save_kwargs = {"quality": quality, "lossless": lossless_webp}
|
save_kwargs = {"quality": quality, "lossless": lossless_webp}
|
||||||
|
|
||||||
@@ -338,13 +346,17 @@ class SaveImage:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
|
def process_image(self, images, filename_prefix="ComfyUI", file_format="png", prompt=None, extra_pnginfo=None,
|
||||||
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True):
|
lossless_webp=True, quality=100, embed_workflow=False, add_counter_to_filename=True,
|
||||||
|
custom_prompt=""):
|
||||||
"""Process and save image with metadata"""
|
"""Process and save image with metadata"""
|
||||||
# Make sure the output directory exists
|
# Make sure the output directory exists
|
||||||
os.makedirs(self.output_dir, exist_ok=True)
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
|
||||||
# Convert single image to list for consistent processing
|
# Ensure images is always a list of images
|
||||||
images = [images[0]] if len(images.shape) == 3 else [img for img in images]
|
if len(images.shape) == 3: # Single image (height, width, channels)
|
||||||
|
images = [images]
|
||||||
|
else: # Multiple images (batch, height, width, channels)
|
||||||
|
images = [img for img in images]
|
||||||
|
|
||||||
# Save all images
|
# Save all images
|
||||||
results = self.save_images(
|
results = self.save_images(
|
||||||
@@ -356,7 +368,8 @@ class SaveImage:
|
|||||||
lossless_webp,
|
lossless_webp,
|
||||||
quality,
|
quality,
|
||||||
embed_workflow,
|
embed_workflow,
|
||||||
add_counter_to_filename
|
add_counter_to_filename,
|
||||||
|
custom_prompt if custom_prompt.strip() else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return (images,)
|
return (images,)
|
||||||
@@ -132,13 +132,9 @@ class ApiRoutes:
|
|||||||
page = int(request.query.get('page', '1'))
|
page = int(request.query.get('page', '1'))
|
||||||
page_size = int(request.query.get('page_size', '20'))
|
page_size = int(request.query.get('page_size', '20'))
|
||||||
sort_by = request.query.get('sort_by', 'name')
|
sort_by = request.query.get('sort_by', 'name')
|
||||||
folder = request.query.get('folder')
|
folder = request.query.get('folder', None)
|
||||||
search = request.query.get('search', '').lower()
|
search = request.query.get('search', None)
|
||||||
fuzzy = request.query.get('fuzzy', 'false').lower() == 'true'
|
fuzzy_search = 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()]
|
|
||||||
|
|
||||||
# Parse search options
|
# Parse search options
|
||||||
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
|
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'
|
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
|
||||||
recursive = request.query.get('recursive', 'false').lower() == 'true'
|
recursive = request.query.get('recursive', 'false').lower() == 'true'
|
||||||
|
|
||||||
# Validate parameters
|
# Get filter parameters
|
||||||
if page < 1 or page_size < 1 or page_size > 100:
|
base_models = request.query.get('base_models', None)
|
||||||
return web.json_response({
|
tags = request.query.get('tags', None)
|
||||||
'error': 'Invalid pagination parameters'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
if sort_by not in ['date', 'name']:
|
# New parameters for recipe filtering
|
||||||
return web.json_response({
|
lora_hash = request.query.get('lora_hash', None)
|
||||||
'error': 'Invalid sort parameter'
|
lora_hashes = request.query.get('lora_hashes', None)
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
# Parse tags filter parameter
|
# Parse filter parameters
|
||||||
tags = request.query.get('tags', '').split(',')
|
filters = {}
|
||||||
tags = [tag.strip() for tag in tags if tag.strip()]
|
if base_models:
|
||||||
|
filters['base_model'] = base_models.split(',')
|
||||||
|
if tags:
|
||||||
|
filters['tags'] = tags.split(',')
|
||||||
|
|
||||||
# Get paginated data with search and filters
|
# Add search options to filters
|
||||||
result = await self.scanner.get_paginated_data(
|
search_options = {
|
||||||
page=page,
|
'filename': search_filename,
|
||||||
page_size=page_size,
|
'modelname': search_modelname,
|
||||||
sort_by=sort_by,
|
'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,
|
folder=folder,
|
||||||
search=search,
|
search=search,
|
||||||
fuzzy=fuzzy,
|
fuzzy_search=fuzzy_search,
|
||||||
base_models=base_models, # Pass base models filter
|
base_models=filters.get('base_model', None),
|
||||||
tags=tags, # Add tags parameter
|
tags=filters.get('tags', None),
|
||||||
search_options={
|
search_options=search_options,
|
||||||
'filename': search_filename,
|
hash_filters=hash_filters
|
||||||
'modelname': search_modelname,
|
|
||||||
'tags': search_tags,
|
|
||||||
'recursive': recursive
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format the response data
|
|
||||||
formatted_items = [
|
|
||||||
self._format_lora_response(item)
|
|
||||||
for item in result['items']
|
|
||||||
]
|
|
||||||
|
|
||||||
# Get all available folders from cache
|
# Get all available folders from cache
|
||||||
cache = await self.scanner.get_cached_data()
|
cache = await self.scanner.get_cached_data()
|
||||||
|
|
||||||
return web.json_response({
|
# Convert output to match expected format
|
||||||
'items': formatted_items,
|
result = {
|
||||||
'total': result['total'],
|
'items': [self._format_lora_response(lora) for lora in data['items']],
|
||||||
'page': result['page'],
|
'folders': cache.folders,
|
||||||
'page_size': result['page_size'],
|
'total': data['total'],
|
||||||
'total_pages': result['total_pages'],
|
'page': data['page'],
|
||||||
'folders': cache.folders
|
'page_size': data['page_size'],
|
||||||
})
|
'total_pages': data['total_pages']
|
||||||
|
}
|
||||||
|
|
||||||
|
return web.json_response(result)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in get_loras: {str(e)}", exc_info=True)
|
logger.error(f"Error retrieving loras: {e}", exc_info=True)
|
||||||
return web.json_response({
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
'error': 'Internal server error'
|
|
||||||
}, status=500)
|
|
||||||
|
|
||||||
def _format_lora_response(self, lora: Dict) -> Dict:
|
def _format_lora_response(self, lora: Dict) -> Dict:
|
||||||
"""Format LoRA data for API response"""
|
"""Format LoRA data for API response"""
|
||||||
@@ -667,12 +669,28 @@ class ApiRoutes:
|
|||||||
"""Handle model move request"""
|
"""Handle model move request"""
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
file_path = data.get('file_path')
|
file_path = data.get('file_path') # full path of the model file, e.g. /path/to/model.safetensors
|
||||||
target_path = data.get('target_path')
|
target_path = data.get('target_path') # folder path to move the model to, e.g. /path/to/target_folder
|
||||||
|
|
||||||
if not file_path or not target_path:
|
if not file_path or not target_path:
|
||||||
return web.Response(text='File path and target path are required', status=400)
|
return web.Response(text='File path and target path are required', status=400)
|
||||||
|
|
||||||
|
# Check if source and destination are the same
|
||||||
|
source_dir = os.path.dirname(file_path)
|
||||||
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
|
logger.info(f"Source and target directories are the same: {source_dir}")
|
||||||
|
return web.json_response({'success': True, 'message': 'Source and target directories are the same'})
|
||||||
|
|
||||||
|
# Check if target file already exists
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||||
|
|
||||||
|
if os.path.exists(target_file_path):
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f"Target file already exists: {target_file_path}"
|
||||||
|
}, status=409) # 409 Conflict
|
||||||
|
|
||||||
# Call scanner to handle the move operation
|
# Call scanner to handle the move operation
|
||||||
success = await self.scanner.move_model(file_path, target_path)
|
success = await self.scanner.move_model(file_path, target_path)
|
||||||
|
|
||||||
@@ -815,39 +833,64 @@ class ApiRoutes:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting lora Civitai URL: {e}", exc_info=True)
|
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:
|
async def move_models_bulk(self, request: web.Request) -> web.Response:
|
||||||
"""Handle bulk model move request"""
|
"""Handle bulk model move request"""
|
||||||
try:
|
try:
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
file_paths = data.get('file_paths', [])
|
file_paths = data.get('file_paths', []) # list of full paths of the model files, e.g. ["/path/to/model1.safetensors", "/path/to/model2.safetensors"]
|
||||||
target_path = data.get('target_path')
|
target_path = data.get('target_path') # folder path to move the models to, e.g. "/path/to/target_folder"
|
||||||
|
|
||||||
if not file_paths or not target_path:
|
if not file_paths or not target_path:
|
||||||
return web.Response(text='File paths and target path are required', status=400)
|
return web.Response(text='File paths and target path are required', status=400)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for file_path in file_paths:
|
for file_path in file_paths:
|
||||||
|
# Check if source and destination are the same
|
||||||
|
source_dir = os.path.dirname(file_path)
|
||||||
|
if os.path.normpath(source_dir) == os.path.normpath(target_path):
|
||||||
|
results.append({
|
||||||
|
"path": file_path,
|
||||||
|
"success": True,
|
||||||
|
"message": "Source and target directories are the same"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if target file already exists
|
||||||
|
file_name = os.path.basename(file_path)
|
||||||
|
target_file_path = os.path.join(target_path, file_name).replace(os.sep, '/')
|
||||||
|
|
||||||
|
if os.path.exists(target_file_path):
|
||||||
|
results.append({
|
||||||
|
"path": file_path,
|
||||||
|
"success": False,
|
||||||
|
"message": f"Target file already exists: {target_file_path}"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Try to move the model
|
||||||
success = await self.scanner.move_model(file_path, target_path)
|
success = await self.scanner.move_model(file_path, target_path)
|
||||||
results.append({"path": file_path, "success": success})
|
results.append({
|
||||||
|
"path": file_path,
|
||||||
|
"success": success,
|
||||||
|
"message": "Success" if success else "Failed to move model"
|
||||||
|
})
|
||||||
|
|
||||||
# Count successes
|
# Count successes and failures
|
||||||
success_count = sum(1 for r in results if r["success"])
|
success_count = sum(1 for r in results if r["success"])
|
||||||
|
failure_count = len(results) - success_count
|
||||||
|
|
||||||
if success_count == len(file_paths):
|
return web.json_response({
|
||||||
return web.json_response({
|
'success': True,
|
||||||
'success': True,
|
'message': f'Moved {success_count} of {len(file_paths)} models',
|
||||||
'message': f'Successfully moved {success_count} models'
|
'results': results,
|
||||||
})
|
'success_count': success_count,
|
||||||
elif success_count > 0:
|
'failure_count': failure_count
|
||||||
return web.json_response({
|
})
|
||||||
'success': True,
|
|
||||||
'message': f'Moved {success_count} of {len(file_paths)} models',
|
|
||||||
'results': results
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return web.Response(text='Failed to move any models', status=500)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||||
@@ -962,7 +1005,7 @@ class ApiRoutes:
|
|||||||
'base_models': base_models
|
'base_models': base_models
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving base models: {e}", exc_info=True)
|
logger.error(f"Error retrieving base models: {e}")
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
'success': False,
|
'success': False,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class LoraRoutes:
|
|||||||
settings=settings, # Pass settings to template
|
settings=settings, # Pass settings to template
|
||||||
request=request # Pass the request object to the template
|
request=request # Pass the request object to the template
|
||||||
)
|
)
|
||||||
logger.info(f"Loras page loaded successfully with {len(cache.raw_data)} items")
|
logger.debug(f"Loras page loaded successfully with {len(cache.raw_data)} items")
|
||||||
except Exception as cache_error:
|
except Exception as cache_error:
|
||||||
logger.error(f"Error loading cache data: {cache_error}")
|
logger.error(f"Error loading cache data: {cache_error}")
|
||||||
# 如果获取缓存失败,也显示初始化页面
|
# 如果获取缓存失败,也显示初始化页面
|
||||||
|
|||||||
@@ -53,10 +53,16 @@ class RecipeRoutes:
|
|||||||
# Add new endpoint for updating recipe metadata (name and tags)
|
# Add new endpoint for updating recipe metadata (name and tags)
|
||||||
app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe)
|
app.router.add_put('/api/recipe/{recipe_id}/update', routes.update_recipe)
|
||||||
|
|
||||||
|
# Add new endpoint for reconnecting deleted LoRAs
|
||||||
|
app.router.add_post('/api/recipe/lora/reconnect', routes.reconnect_lora)
|
||||||
|
|
||||||
# Start cache initialization
|
# Start cache initialization
|
||||||
app.on_startup.append(routes._init_cache)
|
app.on_startup.append(routes._init_cache)
|
||||||
|
|
||||||
app.router.add_post('/api/recipes/save-from-widget', routes.save_recipe_from_widget)
|
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):
|
async def _init_cache(self, app):
|
||||||
"""Initialize cache on startup"""
|
"""Initialize cache on startup"""
|
||||||
@@ -95,6 +101,9 @@ class RecipeRoutes:
|
|||||||
base_models = request.query.get('base_models', None)
|
base_models = request.query.get('base_models', None)
|
||||||
tags = request.query.get('tags', None)
|
tags = request.query.get('tags', None)
|
||||||
|
|
||||||
|
# New parameter: get LoRA hash filter
|
||||||
|
lora_hash = request.query.get('lora_hash', None)
|
||||||
|
|
||||||
# Parse filter parameters
|
# Parse filter parameters
|
||||||
filters = {}
|
filters = {}
|
||||||
if base_models:
|
if base_models:
|
||||||
@@ -110,14 +119,15 @@ class RecipeRoutes:
|
|||||||
'lora_model': search_lora_model
|
'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(
|
result = await self.recipe_scanner.get_paginated_data(
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
search=search,
|
search=search,
|
||||||
filters=filters,
|
filters=filters,
|
||||||
search_options=search_options
|
search_options=search_options,
|
||||||
|
lora_hash=lora_hash
|
||||||
)
|
)
|
||||||
|
|
||||||
# Format the response data with static URLs for file paths
|
# Format the response data with static URLs for file paths
|
||||||
@@ -145,20 +155,14 @@ class RecipeRoutes:
|
|||||||
"""Get detailed information about a specific recipe"""
|
"""Get detailed information about a specific recipe"""
|
||||||
try:
|
try:
|
||||||
recipe_id = request.match_info['recipe_id']
|
recipe_id = request.match_info['recipe_id']
|
||||||
|
|
||||||
# Get all recipes from cache
|
|
||||||
cache = await self.recipe_scanner.get_cached_data()
|
|
||||||
|
|
||||||
# Find the specific recipe
|
# Use the new get_recipe_by_id method from recipe_scanner
|
||||||
recipe = next((r for r in cache.raw_data if str(r.get('id', '')) == recipe_id), None)
|
recipe = await self.recipe_scanner.get_recipe_by_id(recipe_id)
|
||||||
|
|
||||||
if not recipe:
|
if not recipe:
|
||||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
# Format recipe data
|
return web.json_response(recipe)
|
||||||
formatted_recipe = self._format_recipe_data(recipe)
|
|
||||||
|
|
||||||
return web.json_response(formatted_recipe)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error retrieving recipe details: {e}", exc_info=True)
|
logger.error(f"Error retrieving recipe details: {e}", exc_info=True)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
@@ -762,7 +766,7 @@ class RecipeRoutes:
|
|||||||
return web.json_response({"error": "Invalid workflow JSON"}, status=400)
|
return web.json_response({"error": "Invalid workflow JSON"}, status=400)
|
||||||
|
|
||||||
if not workflow_json:
|
if not workflow_json:
|
||||||
return web.json_response({"error": "Missing required workflow_json field"}, status=400)
|
return web.json_response({"error": "Missing workflow JSON"}, status=400)
|
||||||
|
|
||||||
# Find the latest image in the temp directory
|
# Find the latest image in the temp directory
|
||||||
temp_dir = config.temp_directory
|
temp_dir = config.temp_directory
|
||||||
@@ -1021,3 +1025,159 @@ class RecipeRoutes:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating recipe: {e}", exc_info=True)
|
logger.error(f"Error updating recipe: {e}", exc_info=True)
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||||
|
"""Reconnect a deleted LoRA in a recipe to a local LoRA file"""
|
||||||
|
try:
|
||||||
|
# Parse request data
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['recipe_id', 'lora_data', 'target_name']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data:
|
||||||
|
return web.json_response({
|
||||||
|
"error": f"Missing required field: {field}"
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
recipe_id = data['recipe_id']
|
||||||
|
lora_data = data['lora_data']
|
||||||
|
target_name = data['target_name']
|
||||||
|
|
||||||
|
# Get recipe scanner
|
||||||
|
scanner = self.recipe_scanner
|
||||||
|
lora_scanner = scanner._lora_scanner
|
||||||
|
|
||||||
|
# Check if recipe exists
|
||||||
|
recipe_path = os.path.join(scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
||||||
|
if not os.path.exists(recipe_path):
|
||||||
|
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||||
|
|
||||||
|
# Find target LoRA by name
|
||||||
|
target_lora = await lora_scanner.get_lora_info_by_name(target_name)
|
||||||
|
if not target_lora:
|
||||||
|
return web.json_response({"error": f"Local LoRA not found with name: {target_name}"}, status=404)
|
||||||
|
|
||||||
|
# Load recipe data
|
||||||
|
with open(recipe_path, 'r', encoding='utf-8') as f:
|
||||||
|
recipe_data = json.load(f)
|
||||||
|
|
||||||
|
# Find the deleted LoRA in the recipe
|
||||||
|
found = False
|
||||||
|
updated_lora = None
|
||||||
|
|
||||||
|
# Identification can be by hash, modelVersionId, or modelName
|
||||||
|
for i, lora in enumerate(recipe_data.get('loras', [])):
|
||||||
|
match_found = False
|
||||||
|
|
||||||
|
# Try to match by available identifiers
|
||||||
|
if 'hash' in lora and 'hash' in lora_data and lora['hash'] == lora_data['hash']:
|
||||||
|
match_found = True
|
||||||
|
elif 'modelVersionId' in lora and 'modelVersionId' in lora_data and lora['modelVersionId'] == lora_data['modelVersionId']:
|
||||||
|
match_found = True
|
||||||
|
elif 'modelName' in lora and 'modelName' in lora_data and lora['modelName'] == lora_data['modelName']:
|
||||||
|
match_found = True
|
||||||
|
|
||||||
|
if match_found:
|
||||||
|
# Update LoRA data
|
||||||
|
lora['isDeleted'] = False
|
||||||
|
lora['file_name'] = target_name
|
||||||
|
|
||||||
|
# Update with information from the target LoRA
|
||||||
|
if 'sha256' in target_lora:
|
||||||
|
lora['hash'] = target_lora['sha256'].lower()
|
||||||
|
if target_lora.get("civitai"):
|
||||||
|
lora['modelName'] = target_lora['civitai']['model']['name']
|
||||||
|
lora['modelVersionName'] = target_lora['civitai']['name']
|
||||||
|
lora['modelVersionId'] = target_lora['civitai']['id']
|
||||||
|
|
||||||
|
# Keep original fields for identification
|
||||||
|
|
||||||
|
# Mark as found and store updated lora
|
||||||
|
found = True
|
||||||
|
updated_lora = dict(lora) # Make a copy for response
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404)
|
||||||
|
|
||||||
|
# Save updated recipe
|
||||||
|
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
updated_lora['inLibrary'] = True
|
||||||
|
updated_lora['preview_url'] = target_lora['preview_url']
|
||||||
|
updated_lora['localPath'] = target_lora['file_path']
|
||||||
|
|
||||||
|
# Update in cache if it exists
|
||||||
|
if scanner._cache is not None:
|
||||||
|
for cache_item in scanner._cache.raw_data:
|
||||||
|
if cache_item.get('id') == recipe_id:
|
||||||
|
# Replace loras array with updated version
|
||||||
|
cache_item['loras'] = recipe_data['loras']
|
||||||
|
|
||||||
|
# Resort the cache
|
||||||
|
asyncio.create_task(scanner._cache.resort())
|
||||||
|
break
|
||||||
|
|
||||||
|
# Update EXIF metadata if image exists
|
||||||
|
image_path = recipe_data.get('file_path')
|
||||||
|
if image_path and os.path.exists(image_path):
|
||||||
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
ExifUtils.append_recipe_metadata(image_path, recipe_data)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"updated_lora": updated_lora
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -75,17 +75,10 @@ class DownloadManager:
|
|||||||
file_size = file_info.get('sizeKB', 0) * 1024
|
file_size = file_info.get('sizeKB', 0) * 1024
|
||||||
|
|
||||||
# 4. 通知文件监控系统 - 使用规范化路径和文件大小
|
# 4. 通知文件监控系统 - 使用规范化路径和文件大小
|
||||||
if self.file_monitor and self.file_monitor.handler:
|
self.file_monitor.handler.add_ignore_path(
|
||||||
# Add both the normalized path and potential alternative paths
|
save_path.replace(os.sep, '/'),
|
||||||
normalized_path = save_path.replace(os.sep, '/')
|
file_size
|
||||||
self.file_monitor.handler.add_ignore_path(normalized_path, file_size)
|
)
|
||||||
|
|
||||||
# Also add the path with file extension variations (.safetensors)
|
|
||||||
if not normalized_path.endswith('.safetensors'):
|
|
||||||
safetensors_path = os.path.splitext(normalized_path)[0] + '.safetensors'
|
|
||||||
self.file_monitor.handler.add_ignore_path(safetensors_path, file_size)
|
|
||||||
|
|
||||||
logger.debug(f"Added download path to ignore list: {normalized_path} (size: {file_size} bytes)")
|
|
||||||
|
|
||||||
# 5. 准备元数据
|
# 5. 准备元数据
|
||||||
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ from operator import itemgetter
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from watchdog.observers import Observer
|
from watchdog.observers import Observer
|
||||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileDeletedEvent
|
from watchdog.events import FileSystemEventHandler
|
||||||
from typing import List
|
from typing import List, Dict, Set
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
from .lora_scanner import LoraScanner
|
from .lora_scanner import LoraScanner
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -20,91 +21,167 @@ class LoraFileHandler(FileSystemEventHandler):
|
|||||||
self.pending_changes = set() # 待处理的变更
|
self.pending_changes = set() # 待处理的变更
|
||||||
self.lock = Lock() # 线程安全锁
|
self.lock = Lock() # 线程安全锁
|
||||||
self.update_task = None # 异步更新任务
|
self.update_task = None # 异步更新任务
|
||||||
self._ignore_paths = {} # Change to dictionary to store expiration times
|
self._ignore_paths = set() # Add ignore paths set
|
||||||
self._min_ignore_timeout = 5 # minimum timeout in seconds
|
self._min_ignore_timeout = 5 # minimum timeout in seconds
|
||||||
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
|
self._download_speed = 1024 * 1024 # assume 1MB/s as base speed
|
||||||
|
|
||||||
|
# Track modified files with timestamps for debouncing
|
||||||
|
self.modified_files: Dict[str, float] = {}
|
||||||
|
self.debounce_timer = None
|
||||||
|
self.debounce_delay = 3.0 # seconds to wait after last modification
|
||||||
|
|
||||||
|
# Track files that are already scheduled for processing
|
||||||
|
self.scheduled_files: Set[str] = set()
|
||||||
|
|
||||||
def _should_ignore(self, path: str) -> bool:
|
def _should_ignore(self, path: str) -> bool:
|
||||||
"""Check if path should be ignored"""
|
"""Check if path should be ignored"""
|
||||||
real_path = os.path.realpath(path) # Resolve any symbolic links
|
real_path = os.path.realpath(path) # Resolve any symbolic links
|
||||||
normalized_path = real_path.replace(os.sep, '/')
|
return real_path.replace(os.sep, '/') in self._ignore_paths
|
||||||
|
|
||||||
# Also check with backslashes for Windows compatibility
|
|
||||||
alt_path = real_path.replace('/', '\\')
|
|
||||||
|
|
||||||
# 使用传入的事件循环而不是尝试获取当前线程的事件循环
|
|
||||||
current_time = self.loop.time()
|
|
||||||
|
|
||||||
# Check if path is in ignore list and not expired
|
|
||||||
if normalized_path in self._ignore_paths and self._ignore_paths[normalized_path] > current_time:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Also check alternative path format
|
|
||||||
if alt_path in self._ignore_paths and self._ignore_paths[alt_path] > current_time:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def add_ignore_path(self, path: str, file_size: int = 0):
|
def add_ignore_path(self, path: str, file_size: int = 0):
|
||||||
"""Add path to ignore list with dynamic timeout based on file size"""
|
"""Add path to ignore list with dynamic timeout based on file size"""
|
||||||
real_path = os.path.realpath(path) # Resolve any symbolic links
|
real_path = os.path.realpath(path) # Resolve any symbolic links
|
||||||
normalized_path = real_path.replace(os.sep, '/')
|
self._ignore_paths.add(real_path.replace(os.sep, '/'))
|
||||||
|
|
||||||
# Calculate timeout based on file size
|
# Short timeout (e.g. 5 seconds) is sufficient to ignore the CREATE event
|
||||||
# For small files, use minimum timeout
|
timeout = 5
|
||||||
# For larger files, estimate download time + buffer
|
|
||||||
if file_size > 0:
|
|
||||||
# Estimate download time in seconds (size / speed) + buffer
|
|
||||||
estimated_time = (file_size / self._download_speed) + 10
|
|
||||||
timeout = max(self._min_ignore_timeout, estimated_time)
|
|
||||||
else:
|
|
||||||
timeout = self._min_ignore_timeout
|
|
||||||
|
|
||||||
current_time = self.loop.time()
|
|
||||||
expiration_time = current_time + timeout
|
|
||||||
|
|
||||||
# Store both normalized and alternative path formats
|
|
||||||
self._ignore_paths[normalized_path] = expiration_time
|
|
||||||
|
|
||||||
# Also store with backslashes for Windows compatibility
|
|
||||||
alt_path = real_path.replace('/', '\\')
|
|
||||||
self._ignore_paths[alt_path] = expiration_time
|
|
||||||
|
|
||||||
logger.debug(f"Added ignore path: {normalized_path} (expires in {timeout:.1f}s)")
|
|
||||||
|
|
||||||
self.loop.call_later(
|
self.loop.call_later(
|
||||||
timeout,
|
timeout,
|
||||||
self._remove_ignore_path,
|
self._ignore_paths.discard,
|
||||||
normalized_path
|
real_path.replace(os.sep, '/')
|
||||||
)
|
)
|
||||||
|
|
||||||
def _remove_ignore_path(self, path: str):
|
|
||||||
"""Remove path from ignore list after timeout"""
|
|
||||||
if path in self._ignore_paths:
|
|
||||||
del self._ignore_paths[path]
|
|
||||||
logger.debug(f"Removed ignore path: {path}")
|
|
||||||
|
|
||||||
# Also remove alternative path format
|
|
||||||
alt_path = path.replace('/', '\\')
|
|
||||||
if alt_path in self._ignore_paths:
|
|
||||||
del self._ignore_paths[alt_path]
|
|
||||||
|
|
||||||
def on_created(self, event):
|
def on_created(self, event):
|
||||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
if event.is_directory:
|
||||||
return
|
return
|
||||||
if self._should_ignore(event.src_path):
|
|
||||||
|
# Handle safetensors files directly
|
||||||
|
if event.src_path.endswith('.safetensors'):
|
||||||
|
if self._should_ignore(event.src_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
# We'll process this file directly and ignore subsequent modifications
|
||||||
|
# to prevent duplicate processing
|
||||||
|
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||||
|
if normalized_path not in self.scheduled_files:
|
||||||
|
logger.info(f"LoRA file created: {event.src_path}")
|
||||||
|
self.scheduled_files.add(normalized_path)
|
||||||
|
self._schedule_update('add', event.src_path)
|
||||||
|
|
||||||
|
# Ignore modifications for a short period after creation
|
||||||
|
# This helps avoid duplicate processing
|
||||||
|
self.loop.call_later(
|
||||||
|
self.debounce_delay * 2,
|
||||||
|
self.scheduled_files.discard,
|
||||||
|
normalized_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# For browser downloads, we'll catch them when they're renamed to .safetensors
|
||||||
|
|
||||||
|
def on_modified(self, event):
|
||||||
|
if event.is_directory:
|
||||||
return
|
return
|
||||||
logger.info(f"LoRA file created: {event.src_path}")
|
|
||||||
self._schedule_update('add', event.src_path)
|
# Only process safetensors files
|
||||||
|
if event.src_path.endswith('.safetensors'):
|
||||||
|
if self._should_ignore(event.src_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||||
|
|
||||||
|
# Skip if this file is already scheduled for processing
|
||||||
|
if normalized_path in self.scheduled_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update the timestamp for this file
|
||||||
|
self.modified_files[normalized_path] = time.time()
|
||||||
|
|
||||||
|
# Cancel any existing timer
|
||||||
|
if self.debounce_timer:
|
||||||
|
self.debounce_timer.cancel()
|
||||||
|
|
||||||
|
# Set a new timer to process modified files after debounce period
|
||||||
|
self.debounce_timer = self.loop.call_later(
|
||||||
|
self.debounce_delay,
|
||||||
|
self.loop.call_soon_threadsafe,
|
||||||
|
self._process_modified_files
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_modified_files(self):
|
||||||
|
"""Process files that have been modified after debounce period"""
|
||||||
|
current_time = time.time()
|
||||||
|
files_to_process = []
|
||||||
|
|
||||||
|
# Find files that haven't been modified for debounce_delay seconds
|
||||||
|
for file_path, last_modified in list(self.modified_files.items()):
|
||||||
|
if current_time - last_modified >= self.debounce_delay:
|
||||||
|
# Only process if not already scheduled
|
||||||
|
if file_path not in self.scheduled_files:
|
||||||
|
files_to_process.append(file_path)
|
||||||
|
self.scheduled_files.add(file_path)
|
||||||
|
|
||||||
|
# Auto-remove from scheduled list after reasonable time
|
||||||
|
self.loop.call_later(
|
||||||
|
self.debounce_delay * 2,
|
||||||
|
self.scheduled_files.discard,
|
||||||
|
file_path
|
||||||
|
)
|
||||||
|
|
||||||
|
del self.modified_files[file_path]
|
||||||
|
|
||||||
|
# Process stable files
|
||||||
|
for file_path in files_to_process:
|
||||||
|
logger.info(f"Processing modified LoRA file: {file_path}")
|
||||||
|
self._schedule_update('add', file_path)
|
||||||
|
|
||||||
def on_deleted(self, event):
|
def on_deleted(self, event):
|
||||||
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
if event.is_directory or not event.src_path.endswith('.safetensors'):
|
||||||
return
|
return
|
||||||
if self._should_ignore(event.src_path):
|
if self._should_ignore(event.src_path):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Remove from scheduled files if present
|
||||||
|
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||||
|
self.scheduled_files.discard(normalized_path)
|
||||||
|
|
||||||
logger.info(f"LoRA file deleted: {event.src_path}")
|
logger.info(f"LoRA file deleted: {event.src_path}")
|
||||||
self._schedule_update('remove', event.src_path)
|
self._schedule_update('remove', event.src_path)
|
||||||
|
|
||||||
|
def on_moved(self, event):
|
||||||
|
"""Handle file move/rename events"""
|
||||||
|
|
||||||
|
# If destination is a safetensors file, treat it as a new file
|
||||||
|
if event.dest_path.endswith('.safetensors'):
|
||||||
|
if self._should_ignore(event.dest_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
normalized_path = os.path.realpath(event.dest_path).replace(os.sep, '/')
|
||||||
|
|
||||||
|
# Only process if not already scheduled
|
||||||
|
if normalized_path not in self.scheduled_files:
|
||||||
|
logger.info(f"LoRA file renamed/moved to: {event.dest_path}")
|
||||||
|
self.scheduled_files.add(normalized_path)
|
||||||
|
self._schedule_update('add', event.dest_path)
|
||||||
|
|
||||||
|
# Auto-remove from scheduled list after reasonable time
|
||||||
|
self.loop.call_later(
|
||||||
|
self.debounce_delay * 2,
|
||||||
|
self.scheduled_files.discard,
|
||||||
|
normalized_path
|
||||||
|
)
|
||||||
|
|
||||||
|
# If source was a safetensors file, treat it as deleted
|
||||||
|
if event.src_path.endswith('.safetensors'):
|
||||||
|
if self._should_ignore(event.src_path):
|
||||||
|
return
|
||||||
|
|
||||||
|
normalized_path = os.path.realpath(event.src_path).replace(os.sep, '/')
|
||||||
|
self.scheduled_files.discard(normalized_path)
|
||||||
|
|
||||||
|
logger.info(f"LoRA file moved/renamed from: {event.src_path}")
|
||||||
|
self._schedule_update('remove', event.src_path)
|
||||||
|
|
||||||
def _schedule_update(self, action: str, file_path: str): #file_path is a real path
|
def _schedule_update(self, action: str, file_path: str): #file_path is a real path
|
||||||
"""Schedule a cache update"""
|
"""Schedule a cache update"""
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -141,6 +218,12 @@ class LoraFileHandler(FileSystemEventHandler):
|
|||||||
for action, file_path in changes:
|
for action, file_path in changes:
|
||||||
try:
|
try:
|
||||||
if action == 'add':
|
if action == 'add':
|
||||||
|
# Check if file already exists in cache
|
||||||
|
existing = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
|
||||||
|
if existing:
|
||||||
|
logger.info(f"File {file_path} already in cache, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
# Scan new file
|
# Scan new file
|
||||||
lora_data = await self.scanner.scan_single_lora(file_path)
|
lora_data = await self.scanner.scan_single_lora(file_path)
|
||||||
if lora_data:
|
if lora_data:
|
||||||
|
|||||||
@@ -136,9 +136,9 @@ class LoraScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
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,
|
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
|
"""Get paginated and filtered lora data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -147,10 +147,11 @@ class LoraScanner:
|
|||||||
sort_by: Sort method ('name' or 'date')
|
sort_by: Sort method ('name' or 'date')
|
||||||
folder: Filter by folder path
|
folder: Filter by folder path
|
||||||
search: Search term
|
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
|
base_models: List of base models to filter by
|
||||||
tags: List of tags to filter by
|
tags: List of tags to filter by
|
||||||
search_options: Dictionary with search options (filename, modelname, tags, recursive)
|
search_options: Dictionary with search options (filename, modelname, tags, recursive)
|
||||||
|
hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes)
|
||||||
"""
|
"""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
@@ -160,90 +161,108 @@ class LoraScanner:
|
|||||||
'filename': True,
|
'filename': True,
|
||||||
'modelname': True,
|
'modelname': True,
|
||||||
'tags': False,
|
'tags': False,
|
||||||
'recursive': False
|
'recursive': False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get the base data set
|
# Get the base data set
|
||||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
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
|
# Apply SFW filtering if enabled
|
||||||
if settings.get('show_only_sfw', False):
|
if settings.get('show_only_sfw', False):
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
lora for lora in filtered_data
|
||||||
if not item.get('preview_nsfw_level') or item.get('preview_nsfw_level') < NSFW_LEVELS['R']
|
if not lora.get('preview_nsfw_level') or lora.get('preview_nsfw_level') < NSFW_LEVELS['R']
|
||||||
]
|
]
|
||||||
|
|
||||||
# Apply folder filtering
|
# Apply folder filtering
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
if search_options.get('recursive', False):
|
if search_options.get('recursive', False):
|
||||||
# Recursive mode: match all paths starting with this folder
|
# Recursive folder filtering - include all subfolders
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
lora for lora in filtered_data
|
||||||
if item['folder'].startswith(folder + '/') or item['folder'] == folder
|
if lora['folder'].startswith(folder)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
# Non-recursive mode: match exact folder
|
# Exact folder filtering
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
lora for lora in filtered_data
|
||||||
if item['folder'] == folder
|
if lora['folder'] == folder
|
||||||
]
|
]
|
||||||
|
|
||||||
# Apply base model filtering
|
# Apply base model filtering
|
||||||
if base_models and len(base_models) > 0:
|
if base_models and len(base_models) > 0:
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
lora for lora in filtered_data
|
||||||
if item.get('base_model') in base_models
|
if lora.get('base_model') in base_models
|
||||||
]
|
]
|
||||||
|
|
||||||
# Apply tag filtering
|
# Apply tag filtering
|
||||||
if tags and len(tags) > 0:
|
if tags and len(tags) > 0:
|
||||||
filtered_data = [
|
filtered_data = [
|
||||||
item for item in filtered_data
|
lora for lora in filtered_data
|
||||||
if any(tag in item.get('tags', []) for tag in tags)
|
if any(tag in lora.get('tags', []) for tag in tags)
|
||||||
]
|
]
|
||||||
|
|
||||||
# Apply search filtering
|
# Apply search filtering
|
||||||
if search:
|
if search:
|
||||||
search_results = []
|
search_results = []
|
||||||
for item in filtered_data:
|
search_opts = search_options or {}
|
||||||
# Check filename if enabled
|
|
||||||
if search_options.get('filename', True):
|
for lora in filtered_data:
|
||||||
if fuzzy:
|
# Search by file name
|
||||||
if fuzzy_match(item.get('file_name', ''), search):
|
if search_opts.get('filename', True):
|
||||||
search_results.append(item)
|
if fuzzy_match(lora.get('file_name', ''), search):
|
||||||
continue
|
search_results.append(lora)
|
||||||
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)
|
|
||||||
continue
|
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
|
filtered_data = search_results
|
||||||
|
|
||||||
# Calculate pagination
|
# Calculate pagination
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class RecipeScanner:
|
|||||||
logger.error(f"Error getting base model for lora: {e}")
|
logger.error(f"Error getting base model for lora: {e}")
|
||||||
return None
|
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
|
"""Get paginated and filtered recipe data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -340,69 +340,89 @@ class RecipeScanner:
|
|||||||
search: Search term
|
search: Search term
|
||||||
filters: Dictionary of filters to apply
|
filters: Dictionary of filters to apply
|
||||||
search_options: Dictionary of search options 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()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
# Get base dataset
|
# Get base dataset
|
||||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||||
|
|
||||||
# Apply search filter
|
# Special case: Filter by LoRA hash (takes precedence if bypass_filters is True)
|
||||||
if search:
|
if lora_hash:
|
||||||
# Default search options if none provided
|
# Filter recipes that contain this LoRA hash
|
||||||
if not search_options:
|
filtered_data = [
|
||||||
search_options = {
|
item for item in filtered_data
|
||||||
'title': True,
|
if 'loras' in item and any(
|
||||||
'tags': True,
|
lora.get('hash', '').lower() == lora_hash.lower()
|
||||||
'lora_name': True,
|
for lora in item['loras']
|
||||||
'lora_model': True
|
)
|
||||||
}
|
]
|
||||||
|
|
||||||
# Build the search predicate based on search options
|
if bypass_filters:
|
||||||
def matches_search(item):
|
# Skip other filters if bypass_filters is True
|
||||||
# Search in title if enabled
|
pass
|
||||||
if search_options.get('title', True):
|
# Otherwise continue with normal filtering after applying LoRA hash filter
|
||||||
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)]
|
|
||||||
|
|
||||||
# Apply additional filters
|
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
||||||
if filters:
|
if not (lora_hash and bypass_filters):
|
||||||
# Filter by base model
|
# Apply search filter
|
||||||
if 'base_model' in filters and filters['base_model']:
|
if search:
|
||||||
filtered_data = [
|
# Default search options if none provided
|
||||||
item for item in filtered_data
|
if not search_options:
|
||||||
if item.get('base_model', '') in filters['base_model']
|
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
|
# Apply additional filters
|
||||||
if 'tags' in filters and filters['tags']:
|
if filters:
|
||||||
filtered_data = [
|
# Filter by base model
|
||||||
item for item in filtered_data
|
if 'base_model' in filters and filters['base_model']:
|
||||||
if any(tag in item.get('tags', []) for tag in filters['tags'])
|
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
|
# Calculate pagination
|
||||||
total_items = len(filtered_data)
|
total_items = len(filtered_data)
|
||||||
@@ -430,6 +450,74 @@ class RecipeScanner:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result
|
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:
|
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
|
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||||
|
|||||||
@@ -75,3 +75,31 @@ class LoraMetadata:
|
|||||||
self.modified = os.path.getmtime(file_path)
|
self.modified = os.path.getmtime(file_path)
|
||||||
self.file_path = file_path.replace(os.sep, '/')
|
self.file_path = file_path.replace(os.sep, '/')
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CheckpointMetadata:
|
||||||
|
"""Represents the metadata structure for a Checkpoint model"""
|
||||||
|
file_name: str # The filename without extension
|
||||||
|
model_name: str # The checkpoint's name defined by the creator
|
||||||
|
file_path: str # Full path to the model file
|
||||||
|
size: int # File size in bytes
|
||||||
|
modified: float # Last modified timestamp
|
||||||
|
sha256: str # SHA256 hash of the file
|
||||||
|
base_model: str # Base model type (SD1.5/SD2.1/SDXL/etc.)
|
||||||
|
preview_url: str # Preview image URL
|
||||||
|
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||||
|
model_type: str = "checkpoint" # Model type (checkpoint, inpainting, etc.)
|
||||||
|
notes: str = "" # Additional notes
|
||||||
|
from_civitai: bool = True # Whether from Civitai
|
||||||
|
civitai: Optional[Dict] = None # Civitai API data if available
|
||||||
|
tags: List[str] = None # Model tags
|
||||||
|
modelDescription: str = "" # Full model description
|
||||||
|
|
||||||
|
# Additional checkpoint-specific fields
|
||||||
|
resolution: Optional[str] = None # Native resolution (e.g., 512x512, 1024x1024)
|
||||||
|
vae_included: bool = False # Whether VAE is included in the checkpoint
|
||||||
|
architecture: str = "" # Model architecture (if known)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.tags is None:
|
||||||
|
self.tags = []
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def transform_lora_loader(inputs: Dict) -> Dict:
|
|||||||
"loras": " ".join(lora_texts)
|
"loras": " ".join(lora_texts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if "clip" in inputs:
|
if "clip" in inputs and isinstance(inputs["clip"], dict):
|
||||||
result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
|
result["clip_skip"] = inputs["clip"].get("clip_skip", "-1")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||||
version = "0.8.3"
|
version = "0.8.5"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"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 {
|
.model-description-content blockquote {
|
||||||
border-left: 3px solid var(--lora-accent);
|
border-left: 3px solid var (--lora-accent);
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
@@ -1280,4 +1280,47 @@
|
|||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
color: var(--lora-accent);
|
color: var(--lora-accent);
|
||||||
opacity: 0.8;
|
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 {
|
.settings-modal {
|
||||||
max-width: 500px;
|
max-width: 650px; /* Further increased from 600px for more space */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Links */
|
/* Settings Links */
|
||||||
@@ -266,14 +266,22 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* API key input specific styles */
|
||||||
.api-key-input {
|
.api-key-input {
|
||||||
|
width: 100%; /* Take full width of parent */
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.api-key-input input {
|
.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 {
|
.api-key-input .toggle-visibility {
|
||||||
@@ -294,8 +302,10 @@ body.modal-open {
|
|||||||
.input-help {
|
.input-help {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
opacity: 0.8;
|
opacity: 0.7;
|
||||||
margin-top: 4px;
|
margin-top: 8px; /* Space between control and help */
|
||||||
|
line-height: 1.4;
|
||||||
|
width: 100%; /* Full width */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 统一各个 section 的样式 */
|
/* 统一各个 section 的样式 */
|
||||||
@@ -341,8 +351,8 @@ body.modal-open {
|
|||||||
|
|
||||||
.setting-item {
|
.setting-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column; /* Changed to column for help text placement */
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-3); /* Increased to provide more spacing between items */
|
||||||
padding: var(--space-1);
|
padding: var(--space-1);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
}
|
}
|
||||||
@@ -355,35 +365,52 @@ body.modal-open {
|
|||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-info {
|
/* Control row with label and input together */
|
||||||
margin-bottom: var(--space-1);
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-info {
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 35%; /* Increased from 30% to prevent wrapping */
|
||||||
|
flex-shrink: 0; /* Prevent shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
.setting-info label {
|
.setting-info label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin-bottom: 0;
|
||||||
|
white-space: nowrap; /* Prevent label wrapping */
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-control {
|
.setting-control {
|
||||||
width: 100%;
|
width: 60%; /* Decreased slightly from 65% */
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end; /* Right-align all controls */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Select Control Styles */
|
/* Select Control Styles */
|
||||||
.select-control {
|
.select-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-control select {
|
.select-control select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%; /* Increased from 200px */
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
background-color: var(--lora-surface);
|
background-color: var(--lora-surface);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
font-size: 0.95em;
|
font-size: 0.95em;
|
||||||
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix dark theme select dropdown text color */
|
/* Fix dark theme select dropdown text color */
|
||||||
@@ -409,6 +436,7 @@ body.modal-open {
|
|||||||
width: 50px;
|
width: 50px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
margin-left: auto; /* Push to right side */
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-switch input {
|
.toggle-switch input {
|
||||||
@@ -458,15 +486,6 @@ input:checked + .toggle-slider:before {
|
|||||||
width: 22px;
|
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 */
|
/* Blur effect for NSFW content */
|
||||||
.nsfw-blur {
|
.nsfw-blur {
|
||||||
filter: blur(12px);
|
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);
|
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 {
|
#recipeLorasCount {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -420,6 +441,7 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
padding-top: 4px; /* Add padding to prevent first item from being cut off when hovered */
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-lora-item {
|
.recipe-lora-item {
|
||||||
@@ -433,6 +455,14 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
/* Create a new containing block for absolutely positioned descendants */
|
/* Create a new containing block for absolutely positioned descendants */
|
||||||
transform: translateZ(0);
|
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 {
|
.recipe-lora-item.exists-locally {
|
||||||
@@ -584,7 +614,7 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Deleted badge */
|
/* Deleted badge with reconnect functionality */
|
||||||
.deleted-badge {
|
.deleted-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -603,6 +633,138 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Add reconnect functionality styles */
|
||||||
|
.deleted-badge.reconnectable {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-badge.reconnectable:hover {
|
||||||
|
background-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-badge .reconnect-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
display: none;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: var(--z-overlay);
|
||||||
|
width: max-content;
|
||||||
|
max-width: 200px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: normal;
|
||||||
|
top: calc(100% + 5px);
|
||||||
|
left: 0;
|
||||||
|
margin-left: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-badge.reconnectable:hover .reconnect-tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* LoRA reconnect container */
|
||||||
|
.lora-reconnect-container {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--lora-surface);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lora-reconnect-container.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-instructions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-instructions p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-instructions small {
|
||||||
|
color: var(--text-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-instructions code {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .reconnect-instructions code {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-input {
|
||||||
|
width: calc(100% - 20px);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-cancel-btn,
|
||||||
|
.reconnect-confirm-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: var(--border-radius-xs);
|
||||||
|
font-size: 0.85em;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-cancel-btn {
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-confirm-btn {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-cancel-btn:hover {
|
||||||
|
background: var(--lora-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reconnect-confirm-btn:hover {
|
||||||
|
background: color-mix(in oklch, var(--lora-accent), black 10%);
|
||||||
|
}
|
||||||
|
|
||||||
/* Recipe status partial state */
|
/* Recipe status partial state */
|
||||||
.recipe-status.partial {
|
.recipe-status.partial {
|
||||||
background: rgba(127, 127, 127, 0.1);
|
background: rgba(127, 127, 127, 0.1);
|
||||||
|
|||||||
@@ -38,6 +38,90 @@
|
|||||||
flex-wrap: nowrap;
|
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 */
|
/* Ensure hidden class works properly */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -86,12 +170,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-folders-btn:hover {
|
.toggle-folders-btn:hover {
|
||||||
background: var(--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-folders-btn i {
|
.toggle-folders-btn i {
|
||||||
@@ -101,8 +187,9 @@
|
|||||||
/* Icon-only button style */
|
/* Icon-only button style */
|
||||||
.icon-only {
|
.icon-only {
|
||||||
min-width: unset !important;
|
min-width: unset !important;
|
||||||
width: 36px !important;
|
width: 32px !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
height: 32px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rotate icon when folders are collapsed */
|
/* Rotate icon when folders are collapsed */
|
||||||
@@ -133,16 +220,25 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
font-size: 14px;
|
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 {
|
.tag.active {
|
||||||
background-color: #007bff;
|
background-color: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Back to Top Button */
|
/* Back to Top Button */
|
||||||
@@ -155,7 +251,7 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
color: var (--text-color);
|
color: var(--text-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -165,6 +261,7 @@
|
|||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
z-index: var(--z-overlay);
|
z-index: var(--z-overlay);
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-to-top.visible {
|
.back-to-top.visible {
|
||||||
@@ -174,9 +271,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-to-top:hover {
|
.back-to-top:hover {
|
||||||
background: var (--lora-accent);
|
background: var(--lora-accent);
|
||||||
color: white;
|
color: white;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -203,19 +301,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toggle-folders-btn:hover {
|
.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 {
|
.back-to-top {
|
||||||
bottom: 60px; /* Give some extra space from bottom on mobile */
|
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/search-filter.css';
|
||||||
@import 'components/bulk.css';
|
@import 'components/bulk.css';
|
||||||
@import 'components/shared.css';
|
@import 'components/shared.css';
|
||||||
|
@import 'components/filter-indicator.css';
|
||||||
|
|
||||||
.initialization-notice {
|
.initialization-notice {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createLoraCard } from '../components/LoraCard.js';
|
|||||||
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||||
import { showDeleteModal } from '../utils/modalUtils.js';
|
import { showDeleteModal } from '../utils/modalUtils.js';
|
||||||
import { toggleFolder } from '../utils/uiHelpers.js';
|
import { toggleFolder } from '../utils/uiHelpers.js';
|
||||||
|
import { getSessionItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
|
||||||
const pageState = getCurrentPageState();
|
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}`);
|
const response = await fetch(`/api/loras?${params}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch loras: ${response.statusText}`);
|
throw new Error(`Failed to fetch loras: ${response.statusText}`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state } from '../state/index.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 { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||||
|
|
||||||
@@ -57,10 +57,15 @@ export function createLoraCard(lora) {
|
|||||||
nsfwText = "R-rated Content";
|
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 = `
|
card.innerHTML = `
|
||||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||||
${previewUrl.endsWith('.mp4') ?
|
${isVideo ?
|
||||||
`<video controls autoplay muted loop>
|
`<video ${videoAttrs}>
|
||||||
<source src="${versionedPreviewUrl}" type="video/mp4">
|
<source src="${versionedPreviewUrl}" type="video/mp4">
|
||||||
</video>` :
|
</video>` :
|
||||||
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
|
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
|
||||||
@@ -246,6 +251,26 @@ export function createLoraCard(lora) {
|
|||||||
actionGroup.style.display = 'none';
|
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;
|
return card;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Recipe Modal Component
|
// Recipe Modal Component
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
|
||||||
|
|
||||||
class RecipeModal {
|
class RecipeModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -31,6 +32,16 @@ class RecipeModal {
|
|||||||
!event.target.closest('.edit-icon')) {
|
!event.target.closest('.edit-icon')) {
|
||||||
this.saveTagsEdit();
|
this.saveTagsEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle reconnect input
|
||||||
|
const reconnectContainers = document.querySelectorAll('.lora-reconnect-container');
|
||||||
|
reconnectContainers.forEach(container => {
|
||||||
|
if (container.classList.contains('active') &&
|
||||||
|
!container.contains(event.target) &&
|
||||||
|
!event.target.closest('.deleted-badge.reconnectable')) {
|
||||||
|
this.hideReconnectInput(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +295,7 @@ class RecipeModal {
|
|||||||
} else {
|
} else {
|
||||||
// No generation parameters available
|
// No generation parameters available
|
||||||
if (promptElement) promptElement.textContent = 'No prompt information 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>';
|
if (otherParamsElement) otherParamsElement.innerHTML = '<div class="no-params">No parameters available</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,8 +343,15 @@ class RecipeModal {
|
|||||||
|
|
||||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
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(() => {
|
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');
|
const missingStatus = document.querySelector('.recipe-status.missing');
|
||||||
if (missingStatus && missingLorasCount > 0) {
|
if (missingStatus && missingLorasCount > 0) {
|
||||||
missingStatus.classList.add('clickable');
|
missingStatus.classList.add('clickable');
|
||||||
@@ -358,8 +376,9 @@ class RecipeModal {
|
|||||||
</div>`;
|
</div>`;
|
||||||
} else if (isDeleted) {
|
} else if (isDeleted) {
|
||||||
localStatus = `
|
localStatus = `
|
||||||
<div class="deleted-badge">
|
<div class="deleted-badge reconnectable" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||||
<i class="fas fa-trash-alt"></i> Deleted
|
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
||||||
|
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
localStatus = `
|
localStatus = `
|
||||||
@@ -387,7 +406,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="${loraItemClass}">
|
<div class="${loraItemClass}" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||||
<div class="recipe-lora-thumbnail">
|
<div class="recipe-lora-thumbnail">
|
||||||
${previewMedia}
|
${previewMedia}
|
||||||
</div>
|
</div>
|
||||||
@@ -401,11 +420,30 @@ class RecipeModal {
|
|||||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lora-reconnect-container" data-lora-index="${recipe.loras.indexOf(lora)}">
|
||||||
|
<div class="reconnect-instructions">
|
||||||
|
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||||||
|
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
||||||
|
</div>
|
||||||
|
<div class="reconnect-form">
|
||||||
|
<input type="text" class="reconnect-input" placeholder="Enter LoRA name or syntax">
|
||||||
|
<div class="reconnect-actions">
|
||||||
|
<button class="reconnect-cancel-btn">Cancel</button>
|
||||||
|
<button class="reconnect-confirm-btn">Reconnect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// 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)
|
// Generate recipe syntax for copy button (this is now a placeholder, actual syntax will be fetched from the API)
|
||||||
this.recipeLorasSyntax = '';
|
this.recipeLorasSyntax = '';
|
||||||
|
|
||||||
@@ -829,6 +867,214 @@ class RecipeModal {
|
|||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New methods for reconnecting LoRAs
|
||||||
|
setupReconnectButtons() {
|
||||||
|
// Add event listeners to all deleted badges
|
||||||
|
const deletedBadges = document.querySelectorAll('.deleted-badge.reconnectable');
|
||||||
|
deletedBadges.forEach(badge => {
|
||||||
|
badge.addEventListener('mouseenter', () => {
|
||||||
|
badge.querySelector('.badge-text').innerHTML = 'Reconnect';
|
||||||
|
});
|
||||||
|
|
||||||
|
badge.addEventListener('mouseleave', () => {
|
||||||
|
badge.querySelector('.badge-text').innerHTML = '<i class="fas fa-trash-alt"></i> Deleted';
|
||||||
|
});
|
||||||
|
|
||||||
|
badge.addEventListener('click', (e) => {
|
||||||
|
const loraIndex = badge.getAttribute('data-lora-index');
|
||||||
|
this.showReconnectInput(loraIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners to reconnect cancel buttons
|
||||||
|
const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn');
|
||||||
|
cancelButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const container = button.closest('.lora-reconnect-container');
|
||||||
|
this.hideReconnectInput(container);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listeners to reconnect confirm buttons
|
||||||
|
const confirmButtons = document.querySelectorAll('.reconnect-confirm-btn');
|
||||||
|
confirmButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
const container = button.closest('.lora-reconnect-container');
|
||||||
|
const input = container.querySelector('.reconnect-input');
|
||||||
|
const loraIndex = container.getAttribute('data-lora-index');
|
||||||
|
this.reconnectLora(loraIndex, input.value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add keydown handlers to reconnect inputs
|
||||||
|
const reconnectInputs = document.querySelectorAll('.reconnect-input');
|
||||||
|
reconnectInputs.forEach(input => {
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const container = input.closest('.lora-reconnect-container');
|
||||||
|
const loraIndex = container.getAttribute('data-lora-index');
|
||||||
|
this.reconnectLora(loraIndex, input.value);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
const container = input.closest('.lora-reconnect-container');
|
||||||
|
this.hideReconnectInput(container);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showReconnectInput(loraIndex) {
|
||||||
|
// Hide any currently active reconnect containers
|
||||||
|
document.querySelectorAll('.lora-reconnect-container.active').forEach(active => {
|
||||||
|
active.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the reconnect container for this lora
|
||||||
|
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
|
||||||
|
if (container) {
|
||||||
|
container.classList.add('active');
|
||||||
|
const input = container.querySelector('.reconnect-input');
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideReconnectInput(container) {
|
||||||
|
if (container && container.classList.contains('active')) {
|
||||||
|
container.classList.remove('active');
|
||||||
|
const input = container.querySelector('.reconnect-input');
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconnectLora(loraIndex, inputValue) {
|
||||||
|
if (!inputValue || !inputValue.trim()) {
|
||||||
|
showToast('Please enter a LoRA name or syntax', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse input value to extract file_name
|
||||||
|
let loraSyntaxMatch = inputValue.match(/<lora:([^:>]+)(?::[^>]+)?>/);
|
||||||
|
let fileName = loraSyntaxMatch ? loraSyntaxMatch[1] : inputValue.trim();
|
||||||
|
|
||||||
|
// Remove any file extension if present
|
||||||
|
fileName = fileName.replace(/\.\w+$/, '');
|
||||||
|
|
||||||
|
// Get the deleted lora data
|
||||||
|
const deletedLora = this.currentRecipe.loras[loraIndex];
|
||||||
|
if (!deletedLora) {
|
||||||
|
showToast('Error: Could not find the LoRA in the recipe', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loadingManager.showSimpleLoading('Reconnecting LoRA...');
|
||||||
|
|
||||||
|
// Call API to reconnect the LoRA
|
||||||
|
const response = await fetch('/api/recipe/lora/reconnect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipe_id: this.recipeId,
|
||||||
|
lora_data: deletedLora,
|
||||||
|
target_name: fileName
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Hide the reconnect input
|
||||||
|
const container = document.querySelector(`.lora-reconnect-container[data-lora-index="${loraIndex}"]`);
|
||||||
|
this.hideReconnectInput(container);
|
||||||
|
|
||||||
|
// Update the current recipe with the updated lora data
|
||||||
|
this.currentRecipe.loras[loraIndex] = result.updated_lora;
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showToast('LoRA reconnected successfully', 'success');
|
||||||
|
|
||||||
|
// Refresh modal to show updated content
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showRecipeDetails(this.currentRecipe);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Refresh recipes list
|
||||||
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.recipeManager.loadRecipes(true);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(`Error: ${result.error}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reconnecting LoRA:', error);
|
||||||
|
showToast(`Error reconnecting LoRA: ${error.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
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 { modalManager } from './managers/ModalManager.js';
|
||||||
import { updateService } from './managers/UpdateService.js';
|
import { updateService } from './managers/UpdateService.js';
|
||||||
import { HeaderManager } from './components/Header.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 { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js';
|
||||||
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
|
||||||
import { migrateStorageItems } from './utils/storageHelpers.js';
|
import { migrateStorageItems } from './utils/storageHelpers.js';
|
||||||
@@ -26,7 +26,7 @@ export class AppCore {
|
|||||||
modalManager.initialize();
|
modalManager.initialize();
|
||||||
updateService.initialize();
|
updateService.initialize();
|
||||||
window.modalManager = modalManager;
|
window.modalManager = modalManager;
|
||||||
window.settingsManager = new SettingsManager();
|
window.settingsManager = settingsManager;
|
||||||
|
|
||||||
// Initialize UI components
|
// Initialize UI components
|
||||||
window.headerManager = new HeaderManager();
|
window.headerManager = new HeaderManager();
|
||||||
@@ -76,4 +76,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
export const appCore = new AppCore();
|
export const appCore = new AppCore();
|
||||||
|
|
||||||
// Export common utilities for global use
|
// Export common utilities for global use
|
||||||
export { showToast, lazyLoadImages, initializeInfiniteScroll };
|
export { showToast, lazyLoadImages, initializeInfiniteScroll };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { state } from './state/index.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 { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js';
|
||||||
import {
|
import {
|
||||||
restoreFolderFilter,
|
restoreFolderFilter,
|
||||||
@@ -17,7 +17,7 @@ import { LoraContextMenu } from './components/ContextMenu.js';
|
|||||||
import { moveManager } from './managers/MoveManager.js';
|
import { moveManager } from './managers/MoveManager.js';
|
||||||
import { updateCardsForBulkMode } from './components/LoraCard.js';
|
import { updateCardsForBulkMode } from './components/LoraCard.js';
|
||||||
import { bulkManager } from './managers/BulkManager.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
|
// Initialize the LoRA page
|
||||||
class LoraPageManager {
|
class LoraPageManager {
|
||||||
@@ -69,6 +69,9 @@ class LoraPageManager {
|
|||||||
initFolderTagsVisibility();
|
initFolderTagsVisibility();
|
||||||
new LoraContextMenu();
|
new LoraContextMenu();
|
||||||
|
|
||||||
|
// Check for custom filters from recipe page navigation
|
||||||
|
this.checkCustomFilters();
|
||||||
|
|
||||||
// Initialize cards for current bulk mode state (should be false initially)
|
// Initialize cards for current bulk mode state (should be false initially)
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
@@ -79,6 +82,87 @@ class LoraPageManager {
|
|||||||
appCore.initializePageFeatures();
|
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() {
|
loadSortPreference() {
|
||||||
const savedSort = getStorageItem('loras_sort');
|
const savedSort = getStorageItem('loras_sort');
|
||||||
if (savedSort) {
|
if (savedSort) {
|
||||||
|
|||||||
@@ -173,11 +173,20 @@ class MoveManager {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (result && result.error) {
|
||||||
|
throw new Error(result.error);
|
||||||
|
}
|
||||||
throw new Error('Failed to move model');
|
throw new Error('Failed to move model');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('Model moved successfully', 'success');
|
if (result && result.message) {
|
||||||
|
showToast(result.message, 'info');
|
||||||
|
} else {
|
||||||
|
showToast('Model moved successfully', 'success');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveBulkModels(filePaths, targetPath) {
|
async moveBulkModels(filePaths, targetPath) {
|
||||||
@@ -202,11 +211,44 @@ class MoveManager {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to move models');
|
throw new Error('Failed to move models');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(`Successfully moved ${movedPaths.length} models`, 'success');
|
// Display results with more details
|
||||||
|
if (result.success) {
|
||||||
|
if (result.failure_count > 0) {
|
||||||
|
// Some files failed to move
|
||||||
|
showToast(`Moved ${result.success_count} models, ${result.failure_count} failed`, 'warning');
|
||||||
|
|
||||||
|
// Log details about failures
|
||||||
|
console.log('Move operation results:', result.results);
|
||||||
|
|
||||||
|
// Get list of failed files with reasons
|
||||||
|
const failedFiles = result.results
|
||||||
|
.filter(r => !r.success)
|
||||||
|
.map(r => {
|
||||||
|
const fileName = r.path.substring(r.path.lastIndexOf('/') + 1);
|
||||||
|
return `${fileName}: ${r.message}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show first few failures in a toast
|
||||||
|
if (failedFiles.length > 0) {
|
||||||
|
const failureMessage = failedFiles.length <= 3
|
||||||
|
? failedFiles.join('\n')
|
||||||
|
: failedFiles.slice(0, 3).join('\n') + `\n(and ${failedFiles.length - 3} more)`;
|
||||||
|
|
||||||
|
showToast(`Failed moves:\n${failureMessage}`, 'warning', 6000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// All files moved successfully
|
||||||
|
showToast(`Successfully moved ${result.success_count} models`, 'success');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Failed to move models');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export class SettingsManager {
|
|||||||
// Sync with state (backend will set this via template)
|
// Sync with state (backend will set this via template)
|
||||||
state.global.settings.show_only_sfw = showOnlySFWCheckbox.checked;
|
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
|
// Load default lora root
|
||||||
await this.loadLoraRoots();
|
await this.loadLoraRoots();
|
||||||
@@ -120,11 +126,170 @@ export class SettingsManager {
|
|||||||
this.isOpen = !this.isOpen;
|
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() {
|
async saveSettings() {
|
||||||
// Get frontend settings from UI
|
// Get frontend settings from UI
|
||||||
const blurMatureContent = document.getElementById('blurMatureContent').checked;
|
const blurMatureContent = document.getElementById('blurMatureContent').checked;
|
||||||
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
||||||
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
||||||
|
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
|
||||||
|
|
||||||
// Get backend settings
|
// Get backend settings
|
||||||
const apiKey = document.getElementById('civitaiApiKey').value;
|
const apiKey = document.getElementById('civitaiApiKey').value;
|
||||||
@@ -133,6 +298,7 @@ export class SettingsManager {
|
|||||||
state.global.settings.blurMatureContent = blurMatureContent;
|
state.global.settings.blurMatureContent = blurMatureContent;
|
||||||
state.global.settings.show_only_sfw = showOnlySFW;
|
state.global.settings.show_only_sfw = showOnlySFW;
|
||||||
state.global.settings.default_loras_root = defaultLoraRoot;
|
state.global.settings.default_loras_root = defaultLoraRoot;
|
||||||
|
state.global.settings.autoplayOnHover = autoplayOnHover;
|
||||||
|
|
||||||
// Save settings to localStorage
|
// Save settings to localStorage
|
||||||
setStorageItem('settings', state.global.settings);
|
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
|
// For show_only_sfw, there's no immediate action needed as it affects content loading
|
||||||
// The setting will take effect on next reload
|
// The setting will take effect on next reload
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
export const settingsManager = new SettingsManager();
|
||||||
|
|
||||||
// Helper function for toggling API key visibility
|
// Helper function for toggling API key visibility
|
||||||
export function toggleApiKeyVisibility(button) {
|
export function toggleApiKeyVisibility(button) {
|
||||||
const input = button.parentElement.querySelector('input');
|
const input = button.parentElement.querySelector('input');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RecipeCard } from './components/RecipeCard.js';
|
|||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { getCurrentPageState } from './state/index.js';
|
import { getCurrentPageState } from './state/index.js';
|
||||||
import { toggleApiKeyVisibility } from './managers/SettingsManager.js';
|
import { toggleApiKeyVisibility } from './managers/SettingsManager.js';
|
||||||
|
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -20,6 +21,14 @@ class RecipeManager {
|
|||||||
// Add state tracking for infinite scroll
|
// Add state tracking for infinite scroll
|
||||||
this.pageState.isLoading = false;
|
this.pageState.isLoading = false;
|
||||||
this.pageState.hasMore = true;
|
this.pageState.hasMore = true;
|
||||||
|
|
||||||
|
// Custom filter state
|
||||||
|
this.customFilter = {
|
||||||
|
active: false,
|
||||||
|
loraName: null,
|
||||||
|
loraHash: null,
|
||||||
|
recipeId: null
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
@@ -29,6 +38,9 @@ class RecipeManager {
|
|||||||
// Set default search options if not already defined
|
// Set default search options if not already defined
|
||||||
this._initSearchOptions();
|
this._initSearchOptions();
|
||||||
|
|
||||||
|
// Check for custom filter parameters in session storage
|
||||||
|
this._checkCustomFilter();
|
||||||
|
|
||||||
// Load initial set of recipes
|
// Load initial set of recipes
|
||||||
await this.loadRecipes();
|
await this.loadRecipes();
|
||||||
|
|
||||||
@@ -58,6 +70,100 @@ class RecipeManager {
|
|||||||
window.toggleApiKeyVisibility = toggleApiKeyVisibility;
|
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() {
|
initEventListeners() {
|
||||||
// Sort select
|
// Sort select
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
@@ -83,6 +189,12 @@ class RecipeManager {
|
|||||||
if (grid) grid.innerHTML = '';
|
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
|
// Build query parameters
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: this.pageState.currentPage,
|
page: this.pageState.currentPage,
|
||||||
@@ -90,28 +202,38 @@ class RecipeManager {
|
|||||||
sort_by: this.pageState.sortBy
|
sort_by: this.pageState.sortBy
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add search filter if present
|
// Add custom filter for Lora if present
|
||||||
if (this.pageState.filters.search) {
|
if (this.customFilter.active && this.customFilter.loraHash) {
|
||||||
params.append('search', this.pageState.filters.search);
|
params.append('lora_hash', this.customFilter.loraHash);
|
||||||
|
|
||||||
// Add search option parameters
|
// Skip other filters when using custom filter
|
||||||
if (this.pageState.searchOptions) {
|
params.append('bypass_filters', 'true');
|
||||||
params.append('search_title', this.pageState.searchOptions.title.toString());
|
} else {
|
||||||
params.append('search_tags', this.pageState.searchOptions.tags.toString());
|
// Normal filtering logic
|
||||||
params.append('search_lora_name', this.pageState.searchOptions.loraName.toString());
|
|
||||||
params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString());
|
// Add search filter if present
|
||||||
params.append('fuzzy', 'true');
|
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
|
// 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) {
|
updateRecipesGrid(data, resetGrid = true) {
|
||||||
const grid = document.getElementById('recipeGrid');
|
const grid = document.getElementById('recipeGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|||||||
@@ -69,6 +69,53 @@ export function removeStorageItem(key) {
|
|||||||
localStorage.removeItem(key); // Also remove legacy 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
|
* Migrate all existing localStorage items to use the prefix
|
||||||
* This should be called once during application initialization
|
* This should be called once during application initialization
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<button onclick="refreshLoras()"><i class="fas fa-sync"></i> Refresh</button>
|
<button onclick="refreshLoras()"><i class="fas fa-sync"></i> Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<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>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button onclick="downloadManager.showDownloadModal()" title="Download from URL">
|
<button onclick="downloadManager.showDownloadModal()" title="Download from URL">
|
||||||
@@ -31,6 +31,12 @@
|
|||||||
<i class="fas fa-th-large"></i> Bulk
|
<i class="fas fa-th-large"></i> Bulk
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
<div class="toggle-folders-container">
|
<div class="toggle-folders-container">
|
||||||
<button class="toggle-folders-btn icon-only" onclick="toggleFolderTags()" title="Toggle folder tags">
|
<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>
|
<button class="close" onclick="modalManager.closeModal('settingsModal')">×</button>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<div class="settings-form">
|
<div class="settings-form">
|
||||||
<div class="input-group">
|
<div class="setting-item api-key-item">
|
||||||
<label for="civitaiApiKey">Civitai API Key:</label>
|
<div class="setting-row">
|
||||||
<div class="api-key-input">
|
<div class="setting-info">
|
||||||
<input type="password"
|
<label for="civitaiApiKey">Civitai API Key:</label>
|
||||||
id="civitaiApiKey"
|
</div>
|
||||||
placeholder="Enter your Civitai API key"
|
<div class="setting-control">
|
||||||
value="{{ settings.get('civitai_api_key', '') }}" />
|
<div class="api-key-input">
|
||||||
<button class="toggle-visibility" onclick="toggleApiKeyVisibility(this)">
|
<input type="password"
|
||||||
<i class="fas fa-eye"></i>
|
id="civitaiApiKey"
|
||||||
</button>
|
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>
|
||||||
<div class="input-help">
|
<div class="input-help">
|
||||||
Used for authentication when downloading models from Civitai
|
Used for authentication when downloading models from Civitai
|
||||||
@@ -37,32 +45,61 @@
|
|||||||
<h3>Content Filtering</h3>
|
<h3>Content Filtering</h3>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info">
|
<div class="setting-row">
|
||||||
<label for="blurMatureContent">Blur NSFW Content</label>
|
<div class="setting-info">
|
||||||
<div class="input-help">
|
<label for="blurMatureContent">Blur NSFW Content</label>
|
||||||
Blur mature (NSFW) content preview images
|
</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>
|
</div>
|
||||||
<div class="setting-control">
|
<div class="input-help">
|
||||||
<label class="toggle-switch">
|
Blur mature (NSFW) content preview images
|
||||||
<input type="checkbox" id="blurMatureContent" checked>
|
|
||||||
<span class="toggle-slider"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info">
|
<div class="setting-row">
|
||||||
<label for="showOnlySFW">Show Only SFW Results</label>
|
<div class="setting-info">
|
||||||
<div class="input-help">
|
<label for="showOnlySFW">Show Only SFW Results</label>
|
||||||
Filter out all NSFW content when browsing and searching
|
</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>
|
</div>
|
||||||
<div class="setting-control">
|
<div class="input-help">
|
||||||
<label class="toggle-switch">
|
Filter out all NSFW content when browsing and searching
|
||||||
<input type="checkbox" id="showOnlySFW" value="{{ settings.get('show_only_sfw', False) }}" {% if settings.get('show_only_sfw', False) %}checked{% endif %}>
|
</div>
|
||||||
<span class="toggle-slider"></span>
|
</div>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,14 +109,16 @@
|
|||||||
<h3>Folder Settings</h3>
|
<h3>Folder Settings</h3>
|
||||||
|
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-info">
|
<div class="setting-row">
|
||||||
<label for="defaultLoraRoot">Default LoRA Root</label>
|
<div class="setting-info">
|
||||||
</div>
|
<label for="defaultLoraRoot">Default LoRA Root</label>
|
||||||
<div class="setting-control select-control">
|
</div>
|
||||||
<select id="defaultLoraRoot">
|
<div class="setting-control select-control">
|
||||||
<option value="">No Default</option>
|
<select id="defaultLoraRoot" onchange="settingsManager.saveSelectSetting('defaultLoraRoot', 'default_lora_root')">
|
||||||
<!-- Options will be loaded dynamically -->
|
<option value="">No Default</option>
|
||||||
</select>
|
<!-- Options will be loaded dynamically -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-help">
|
<div class="input-help">
|
||||||
Set the default LoRA root directory for downloads, imports and moves
|
Set the default LoRA root directory for downloads, imports and moves
|
||||||
@@ -87,18 +126,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@
|
|||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
<div class="recipe-section-actions">
|
<div class="recipe-section-actions">
|
||||||
<span id="recipeLorasCount"><i class="fas fa-layer-group"></i> 0 LoRAs</span>
|
<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">
|
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -32,6 +32,13 @@
|
|||||||
<div title="Import recipes" class="control-group">
|
<div title="Import recipes" class="control-group">
|
||||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> Import</button>
|
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> Import</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
144
web/comfyui/DomWidget.vue
Normal file
144
web/comfyui/DomWidget.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="dom-widget"
|
||||||
|
:title="tooltip"
|
||||||
|
ref="widgetElement"
|
||||||
|
:style="style"
|
||||||
|
v-show="widgetState.visible"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
v-if="isComponentWidget(widget)"
|
||||||
|
:is="widget.component"
|
||||||
|
:modelValue="widget.value"
|
||||||
|
@update:modelValue="emit('update:widgetValue', $event)"
|
||||||
|
:widget="widget"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useEventListener } from '@vueuse/core'
|
||||||
|
import { CSSProperties, computed, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
|
||||||
|
import { useDomClipping } from '@/composables/element/useDomClipping'
|
||||||
|
import {
|
||||||
|
type BaseDOMWidget,
|
||||||
|
isComponentWidget,
|
||||||
|
isDOMWidget
|
||||||
|
} from '@/scripts/domWidget'
|
||||||
|
import { DomWidgetState } from '@/stores/domWidgetStore'
|
||||||
|
import { useCanvasStore } from '@/stores/graphStore'
|
||||||
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
|
|
||||||
|
const { widget, widgetState } = defineProps<{
|
||||||
|
widget: BaseDOMWidget<string | object>
|
||||||
|
widgetState: DomWidgetState
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:widgetValue', value: string | object): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const widgetElement = ref<HTMLElement | undefined>()
|
||||||
|
|
||||||
|
const { style: positionStyle, updatePositionWithTransform } =
|
||||||
|
useAbsolutePosition()
|
||||||
|
const { style: clippingStyle, updateClipPath } = useDomClipping()
|
||||||
|
const style = computed<CSSProperties>(() => ({
|
||||||
|
...positionStyle.value,
|
||||||
|
...(enableDomClipping.value ? clippingStyle.value : {}),
|
||||||
|
zIndex: widgetState.zIndex,
|
||||||
|
pointerEvents: widgetState.readonly ? 'none' : 'auto'
|
||||||
|
}))
|
||||||
|
|
||||||
|
const canvasStore = useCanvasStore()
|
||||||
|
const settingStore = useSettingStore()
|
||||||
|
const enableDomClipping = computed(() =>
|
||||||
|
settingStore.get('Comfy.DOMClippingEnabled')
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateDomClipping = () => {
|
||||||
|
const lgCanvas = canvasStore.canvas
|
||||||
|
if (!lgCanvas || !widgetElement.value) return
|
||||||
|
|
||||||
|
const selectedNode = Object.values(lgCanvas.selected_nodes ?? {})[0]
|
||||||
|
if (!selectedNode) return
|
||||||
|
|
||||||
|
const node = widget.node
|
||||||
|
const isSelected = selectedNode === node
|
||||||
|
const renderArea = selectedNode?.renderArea
|
||||||
|
const offset = lgCanvas.ds.offset
|
||||||
|
const scale = lgCanvas.ds.scale
|
||||||
|
const selectedAreaConfig = renderArea
|
||||||
|
? {
|
||||||
|
x: renderArea[0],
|
||||||
|
y: renderArea[1],
|
||||||
|
width: renderArea[2],
|
||||||
|
height: renderArea[3],
|
||||||
|
scale,
|
||||||
|
offset: [offset[0], offset[1]] as [number, number]
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
updateClipPath(
|
||||||
|
widgetElement.value,
|
||||||
|
lgCanvas.canvas,
|
||||||
|
isSelected,
|
||||||
|
selectedAreaConfig
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => widgetState,
|
||||||
|
(newState) => {
|
||||||
|
updatePositionWithTransform(newState)
|
||||||
|
if (enableDomClipping.value) {
|
||||||
|
updateDomClipping()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => widgetState.visible,
|
||||||
|
(newVisible, oldVisible) => {
|
||||||
|
if (!newVisible && oldVisible) {
|
||||||
|
widget.options.onHide?.(widget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDOMWidget(widget)) {
|
||||||
|
if (widget.element.blur) {
|
||||||
|
useEventListener(document, 'mousedown', (event) => {
|
||||||
|
if (!widget.element.contains(event.target as HTMLElement)) {
|
||||||
|
widget.element.blur()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const evt of widget.options.selectOn ?? ['focus', 'click']) {
|
||||||
|
useEventListener(widget.element, evt, () => {
|
||||||
|
const lgCanvas = canvasStore.canvas
|
||||||
|
lgCanvas?.selectNode(widget.node)
|
||||||
|
lgCanvas?.bringToFront(widget.node)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputSpec = widget.node.constructor.nodeData
|
||||||
|
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (isDOMWidget(widget) && widgetElement.value) {
|
||||||
|
widgetElement.value.appendChild(widget.element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dom-widget > * {
|
||||||
|
@apply h-full w-full;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,193 +1,121 @@
|
|||||||
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
|
import { LGraphNode, LiteGraph } from '@comfyorg/litegraph'
|
||||||
import type { Size, Vector4 } from '@comfyorg/litegraph'
|
|
||||||
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
|
|
||||||
import type {
|
import type {
|
||||||
ICustomWidget,
|
ICustomWidget,
|
||||||
|
IWidget,
|
||||||
IWidgetOptions
|
IWidgetOptions
|
||||||
} from '@comfyorg/litegraph/dist/types/widgets'
|
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { type Component, toRaw } from 'vue'
|
||||||
|
|
||||||
import { useSettingStore } from '@/stores/settingStore'
|
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||||
|
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||||
|
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||||
|
import { generateUUID } from '@/utils/formatUtil'
|
||||||
|
|
||||||
import { app } from './app'
|
export interface BaseDOMWidget<V extends object | string>
|
||||||
|
extends ICustomWidget {
|
||||||
|
// ICustomWidget properties
|
||||||
|
type: 'custom'
|
||||||
|
options: DOMWidgetOptions<V>
|
||||||
|
value: V
|
||||||
|
callback?: (value: V) => void
|
||||||
|
|
||||||
const SIZE = Symbol()
|
// BaseDOMWidget properties
|
||||||
|
/** The unique ID of the widget. */
|
||||||
interface Rect {
|
readonly id: string
|
||||||
height: number
|
/** The node that the widget belongs to. */
|
||||||
width: number
|
readonly node: LGraphNode
|
||||||
x: number
|
/** Whether the widget is visible. */
|
||||||
y: number
|
isVisible(): boolean
|
||||||
|
/** The margin of the widget. */
|
||||||
|
margin: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DOM widget that wraps a custom HTML element as a litegraph widget.
|
||||||
|
*/
|
||||||
export interface DOMWidget<T extends HTMLElement, V extends object | string>
|
export interface DOMWidget<T extends HTMLElement, V extends object | string>
|
||||||
extends ICustomWidget<T> {
|
extends BaseDOMWidget<V> {
|
||||||
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
|
|
||||||
type: 'custom'
|
|
||||||
name: string
|
|
||||||
element: T
|
element: T
|
||||||
options: DOMWidgetOptions<T, V>
|
|
||||||
value: V
|
|
||||||
y?: number
|
|
||||||
/**
|
/**
|
||||||
* @deprecated Legacy property used by some extensions for customtext
|
* @deprecated Legacy property used by some extensions for customtext
|
||||||
* (textarea) widgets. Use `element` instead as it provides the same
|
* (textarea) widgets. Use {@link element} instead as it provides the same
|
||||||
* functionality and works for all DOMWidget types.
|
* functionality and works for all DOMWidget types.
|
||||||
*/
|
*/
|
||||||
inputEl?: T
|
inputEl?: T
|
||||||
callback?: (value: V) => void
|
|
||||||
/**
|
|
||||||
* Draw the widget on the canvas.
|
|
||||||
*/
|
|
||||||
draw?: (
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
node: LGraphNode,
|
|
||||||
widgetWidth: number,
|
|
||||||
y: number,
|
|
||||||
widgetHeight: number
|
|
||||||
) => void
|
|
||||||
/**
|
|
||||||
* TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
|
|
||||||
* on litegraph's IBaseWidget definition, but not called in litegraph.
|
|
||||||
* Currently only called in widgetInputs.ts.
|
|
||||||
*/
|
|
||||||
onRemove?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DOMWidgetOptions<
|
/**
|
||||||
T extends HTMLElement,
|
* A DOM widget that wraps a Vue component as a litegraph widget.
|
||||||
V extends object | string
|
*/
|
||||||
> extends IWidgetOptions {
|
export interface ComponentWidget<V extends object | string>
|
||||||
|
extends BaseDOMWidget<V> {
|
||||||
|
readonly component: Component
|
||||||
|
readonly inputSpec: InputSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DOMWidgetOptions<V extends object | string>
|
||||||
|
extends IWidgetOptions {
|
||||||
|
/**
|
||||||
|
* Whether to render a placeholder rectangle when zoomed out.
|
||||||
|
*/
|
||||||
hideOnZoom?: boolean
|
hideOnZoom?: boolean
|
||||||
selectOn?: string[]
|
selectOn?: string[]
|
||||||
onHide?: (widget: DOMWidget<T, V>) => void
|
onHide?: (widget: BaseDOMWidget<V>) => void
|
||||||
getValue?: () => V
|
getValue?: () => V
|
||||||
setValue?: (value: V) => void
|
setValue?: (value: V) => void
|
||||||
getMinHeight?: () => number
|
getMinHeight?: () => number
|
||||||
getMaxHeight?: () => number
|
getMaxHeight?: () => number
|
||||||
getHeight?: () => string | number
|
getHeight?: () => string | number
|
||||||
onDraw?: (widget: DOMWidget<T, V>) => void
|
onDraw?: (widget: BaseDOMWidget<V>) => void
|
||||||
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
margin?: number
|
||||||
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
/**
|
||||||
|
* @deprecated Use `afterResize` instead. This callback is a legacy API
|
||||||
|
* that fires before resize happens, but it is no longer supported. Now it
|
||||||
|
* fires after resize happens.
|
||||||
|
* The resize logic has been upstreamed to litegraph in
|
||||||
|
* https://github.com/Comfy-Org/ComfyUI_frontend/pull/2557
|
||||||
|
*/
|
||||||
|
beforeResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
|
||||||
|
afterResize?: (this: BaseDOMWidget<V>, node: LGraphNode) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function intersect(a: Rect, b: Rect): Vector4 | null {
|
export const isDOMWidget = <T extends HTMLElement, V extends object | string>(
|
||||||
const x = Math.max(a.x, b.x)
|
widget: IWidget
|
||||||
const num1 = Math.min(a.x + a.width, b.x + b.width)
|
): widget is DOMWidget<T, V> => 'element' in widget && !!widget.element
|
||||||
const y = Math.max(a.y, b.y)
|
|
||||||
const num2 = Math.min(a.y + a.height, b.y + b.height)
|
|
||||||
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
|
|
||||||
else return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClipPath(
|
export const isComponentWidget = <V extends object | string>(
|
||||||
node: LGraphNode,
|
widget: IWidget
|
||||||
element: HTMLElement,
|
): widget is ComponentWidget<V> => 'component' in widget && !!widget.component
|
||||||
canvasRect: DOMRect
|
|
||||||
): string {
|
|
||||||
const selectedNode: LGraphNode = Object.values(
|
|
||||||
app.canvas.selected_nodes ?? {}
|
|
||||||
)[0] as LGraphNode
|
|
||||||
if (selectedNode && selectedNode !== node) {
|
|
||||||
const elRect = element.getBoundingClientRect()
|
|
||||||
const MARGIN = 4
|
|
||||||
const { offset, scale } = app.canvas.ds
|
|
||||||
const { renderArea } = selectedNode
|
|
||||||
|
|
||||||
// Get intersection in browser space
|
abstract class BaseDOMWidgetImpl<V extends object | string>
|
||||||
const intersection = intersect(
|
implements BaseDOMWidget<V>
|
||||||
{
|
|
||||||
x: elRect.left - canvasRect.left,
|
|
||||||
y: elRect.top - canvasRect.top,
|
|
||||||
width: elRect.width,
|
|
||||||
height: elRect.height
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: (renderArea[0] + offset[0] - MARGIN) * scale,
|
|
||||||
y: (renderArea[1] + offset[1] - MARGIN) * scale,
|
|
||||||
width: (renderArea[2] + 2 * MARGIN) * scale,
|
|
||||||
height: (renderArea[3] + 2 * MARGIN) * scale
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!intersection) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert intersection to canvas scale (element has scale transform)
|
|
||||||
const clipX =
|
|
||||||
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
|
|
||||||
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
|
|
||||||
const clipWidth = intersection[2] / scale + 'px'
|
|
||||||
const clipHeight = intersection[3] / scale + 'px'
|
|
||||||
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
|
|
||||||
const elementWidgets = new Set<LGraphNode>()
|
|
||||||
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
|
|
||||||
LGraphCanvas.prototype.computeVisibleNodes = function (
|
|
||||||
nodes?: LGraphNode[],
|
|
||||||
out?: LGraphNode[]
|
|
||||||
): LGraphNode[] {
|
|
||||||
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
|
|
||||||
|
|
||||||
for (const node of app.graph.nodes) {
|
|
||||||
if (elementWidgets.has(node)) {
|
|
||||||
const hidden = visibleNodes.indexOf(node) === -1
|
|
||||||
for (const w of node.widgets ?? []) {
|
|
||||||
if (w.element) {
|
|
||||||
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
|
|
||||||
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
|
|
||||||
const isCollapsed = w.element.dataset.collapsed === 'true'
|
|
||||||
const wasHidden = w.element.hidden
|
|
||||||
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
|
|
||||||
w.element.hidden = actualHidden
|
|
||||||
w.element.style.display = actualHidden ? 'none' : ''
|
|
||||||
if (actualHidden && !wasHidden) {
|
|
||||||
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return visibleNodes
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
|
||||||
implements DOMWidget<T, V>
|
|
||||||
{
|
{
|
||||||
type: 'custom'
|
static readonly DEFAULT_MARGIN = 10
|
||||||
name: string
|
readonly type: 'custom'
|
||||||
element: T
|
readonly name: string
|
||||||
options: DOMWidgetOptions<T, V>
|
readonly options: DOMWidgetOptions<V>
|
||||||
computedHeight?: number
|
computedHeight?: number
|
||||||
|
y: number = 0
|
||||||
callback?: (value: V) => void
|
callback?: (value: V) => void
|
||||||
private mouseDownHandler?: (event: MouseEvent) => void
|
|
||||||
|
|
||||||
constructor(
|
readonly id: string
|
||||||
name: string,
|
readonly node: LGraphNode
|
||||||
type: string,
|
|
||||||
element: T,
|
constructor(obj: {
|
||||||
options: DOMWidgetOptions<T, V> = {}
|
id: string
|
||||||
) {
|
node: LGraphNode
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
options: DOMWidgetOptions<V>
|
||||||
|
}) {
|
||||||
// @ts-expect-error custom widget type
|
// @ts-expect-error custom widget type
|
||||||
this.type = type
|
this.type = obj.type
|
||||||
this.name = name
|
this.name = obj.name
|
||||||
this.element = element
|
this.options = obj.options
|
||||||
this.options = options
|
|
||||||
|
|
||||||
if (element.blur) {
|
this.id = obj.id
|
||||||
this.mouseDownHandler = (event) => {
|
this.node = obj.node
|
||||||
if (!element.contains(event.target as HTMLElement)) {
|
|
||||||
element.blur()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', this.mouseDownHandler)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get value(): V {
|
get value(): V {
|
||||||
@@ -199,6 +127,67 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
|||||||
this.callback?.(this.value)
|
this.callback?.(this.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get margin(): number {
|
||||||
|
return this.options.margin ?? BaseDOMWidgetImpl.DEFAULT_MARGIN
|
||||||
|
}
|
||||||
|
|
||||||
|
isVisible(): boolean {
|
||||||
|
return (
|
||||||
|
!_.isNil(this.computedHeight) &&
|
||||||
|
this.computedHeight > 0 &&
|
||||||
|
!['converted-widget', 'hidden'].includes(this.type) &&
|
||||||
|
!this.node.collapsed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
_node: LGraphNode,
|
||||||
|
widget_width: number,
|
||||||
|
y: number,
|
||||||
|
widget_height: number,
|
||||||
|
lowQuality?: boolean
|
||||||
|
): void {
|
||||||
|
if (this.options.hideOnZoom && lowQuality && this.isVisible()) {
|
||||||
|
// Draw a placeholder rectangle
|
||||||
|
const originalFillStyle = ctx.fillStyle
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR
|
||||||
|
ctx.rect(
|
||||||
|
this.margin,
|
||||||
|
y + this.margin,
|
||||||
|
widget_width - this.margin * 2,
|
||||||
|
(this.computedHeight ?? widget_height) - 2 * this.margin
|
||||||
|
)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.fillStyle = originalFillStyle
|
||||||
|
}
|
||||||
|
this.options.onDraw?.(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemove(): void {
|
||||||
|
useDomWidgetStore().unregisterWidget(this.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||||
|
extends BaseDOMWidgetImpl<V>
|
||||||
|
implements DOMWidget<T, V>
|
||||||
|
{
|
||||||
|
readonly element: T
|
||||||
|
|
||||||
|
constructor(obj: {
|
||||||
|
id: string
|
||||||
|
node: LGraphNode
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
element: T
|
||||||
|
options: DOMWidgetOptions<V>
|
||||||
|
}) {
|
||||||
|
super(obj)
|
||||||
|
this.element = obj.element
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract DOM widget size info */
|
/** Extract DOM widget size info */
|
||||||
computeLayoutSize(node: LGraphNode) {
|
computeLayoutSize(node: LGraphNode) {
|
||||||
// @ts-expect-error custom widget type
|
// @ts-expect-error custom widget type
|
||||||
@@ -241,69 +230,61 @@ export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
|||||||
minWidth: 0
|
minWidth: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
draw(
|
export class ComponentWidgetImpl<V extends object | string>
|
||||||
ctx: CanvasRenderingContext2D,
|
extends BaseDOMWidgetImpl<V>
|
||||||
node: LGraphNode,
|
implements ComponentWidget<V>
|
||||||
widgetWidth: number,
|
{
|
||||||
y: number
|
readonly component: Component
|
||||||
): void {
|
readonly inputSpec: InputSpec
|
||||||
const { offset, scale } = app.canvas.ds
|
|
||||||
const hidden =
|
|
||||||
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
|
|
||||||
(this.computedHeight ?? 0) <= 0 ||
|
|
||||||
// @ts-expect-error custom widget type
|
|
||||||
this.type === 'converted-widget' ||
|
|
||||||
// @ts-expect-error custom widget type
|
|
||||||
this.type === 'hidden'
|
|
||||||
|
|
||||||
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
|
constructor(obj: {
|
||||||
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
|
id: string
|
||||||
const isCollapsed = this.element.dataset.collapsed === 'true'
|
node: LGraphNode
|
||||||
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
|
name: string
|
||||||
const wasHidden = this.element.hidden
|
component: Component
|
||||||
this.element.hidden = actualHidden
|
inputSpec: InputSpec
|
||||||
this.element.style.display = actualHidden ? 'none' : ''
|
options: DOMWidgetOptions<V>
|
||||||
|
}) {
|
||||||
if (actualHidden && !wasHidden) {
|
super({
|
||||||
this.options.onHide?.(this)
|
...obj,
|
||||||
}
|
type: 'custom'
|
||||||
if (actualHidden) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const elRect = ctx.canvas.getBoundingClientRect()
|
|
||||||
const margin = 10
|
|
||||||
const top = node.pos[0] + offset[0] + margin
|
|
||||||
const left = node.pos[1] + offset[1] + margin + y
|
|
||||||
|
|
||||||
Object.assign(this.element.style, {
|
|
||||||
transformOrigin: '0 0',
|
|
||||||
transform: `scale(${scale})`,
|
|
||||||
left: `${top * scale}px`,
|
|
||||||
top: `${left * scale}px`,
|
|
||||||
width: `${widgetWidth - margin * 2}px`,
|
|
||||||
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
|
|
||||||
position: 'absolute',
|
|
||||||
zIndex: app.graph.nodes.indexOf(node),
|
|
||||||
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
|
|
||||||
})
|
})
|
||||||
|
this.component = obj.component
|
||||||
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
|
this.inputSpec = obj.inputSpec
|
||||||
const clipPath = getClipPath(node, this.element, elRect)
|
|
||||||
this.element.style.clipPath = clipPath ?? 'none'
|
|
||||||
this.element.style.willChange = 'clip-path'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.options.onDraw?.(this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onRemove(): void {
|
computeLayoutSize() {
|
||||||
if (this.mouseDownHandler) {
|
const minHeight = this.options.getMinHeight?.() ?? 50
|
||||||
document.removeEventListener('mousedown', this.mouseDownHandler)
|
const maxHeight = this.options.getMaxHeight?.()
|
||||||
|
return {
|
||||||
|
minHeight,
|
||||||
|
maxHeight,
|
||||||
|
minWidth: 0
|
||||||
}
|
}
|
||||||
this.element.remove()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serializeValue(): V {
|
||||||
|
return toRaw(this.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addWidget = <W extends BaseDOMWidget<object | string>>(
|
||||||
|
node: LGraphNode,
|
||||||
|
widget: W
|
||||||
|
) => {
|
||||||
|
node.addCustomWidget(widget)
|
||||||
|
node.onRemoved = useChainCallback(node.onRemoved, () => {
|
||||||
|
widget.onRemove?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
node.onResize = useChainCallback(node.onResize, () => {
|
||||||
|
widget.options.beforeResize?.call(widget, node)
|
||||||
|
widget.options.afterResize?.call(widget, node)
|
||||||
|
})
|
||||||
|
|
||||||
|
useDomWidgetStore().registerWidget(widget)
|
||||||
}
|
}
|
||||||
|
|
||||||
LGraphNode.prototype.addDOMWidget = function <
|
LGraphNode.prototype.addDOMWidget = function <
|
||||||
@@ -314,24 +295,19 @@ LGraphNode.prototype.addDOMWidget = function <
|
|||||||
name: string,
|
name: string,
|
||||||
type: string,
|
type: string,
|
||||||
element: T,
|
element: T,
|
||||||
options: DOMWidgetOptions<T, V> = {}
|
options: DOMWidgetOptions<V> = {}
|
||||||
): DOMWidget<T, V> {
|
): DOMWidget<T, V> {
|
||||||
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
|
const widget = new DOMWidgetImpl({
|
||||||
|
id: generateUUID(),
|
||||||
|
node: this,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
element,
|
||||||
|
options: { hideOnZoom: true, ...options }
|
||||||
|
})
|
||||||
|
// Note: Before `LGraphNode.configure` is called, `this.id` is always `-1`.
|
||||||
|
addWidget(this, widget as unknown as BaseDOMWidget<object | string>)
|
||||||
|
|
||||||
if (!element.parentElement) {
|
|
||||||
app.canvasContainer.append(element)
|
|
||||||
}
|
|
||||||
element.hidden = true
|
|
||||||
element.style.display = 'none'
|
|
||||||
|
|
||||||
const { nodeData } = this.constructor
|
|
||||||
const tooltip = (nodeData?.input.required?.[name] ??
|
|
||||||
nodeData?.input.optional?.[name])?.[1]?.tooltip
|
|
||||||
if (tooltip && !element.title) {
|
|
||||||
element.title = tooltip
|
|
||||||
}
|
|
||||||
|
|
||||||
const widget = new DOMWidgetImpl(name, type, element, options)
|
|
||||||
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
|
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
|
||||||
// Some custom nodes are explicitly expecting getter and setter of `value`
|
// Some custom nodes are explicitly expecting getter and setter of `value`
|
||||||
// property to be on instance instead of prototype.
|
// property to be on instance instead of prototype.
|
||||||
@@ -345,55 +321,5 @@ LGraphNode.prototype.addDOMWidget = function <
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Ensure selectOn exists before iteration
|
|
||||||
const selectEvents = options.selectOn ?? ['focus', 'click']
|
|
||||||
for (const evt of selectEvents) {
|
|
||||||
element.addEventListener(evt, () => {
|
|
||||||
app.canvas.selectNode(this)
|
|
||||||
app.canvas.bringToFront(this)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addCustomWidget(widget)
|
|
||||||
elementWidgets.add(this)
|
|
||||||
|
|
||||||
const collapse = this.collapse
|
|
||||||
this.collapse = function (this: LGraphNode, force?: boolean) {
|
|
||||||
collapse.call(this, force)
|
|
||||||
if (this.collapsed) {
|
|
||||||
element.hidden = true
|
|
||||||
element.style.display = 'none'
|
|
||||||
}
|
|
||||||
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
|
||||||
}
|
|
||||||
|
|
||||||
const { onConfigure } = this
|
|
||||||
this.onConfigure = function (
|
|
||||||
this: LGraphNode,
|
|
||||||
serializedNode: ISerialisedNode
|
|
||||||
) {
|
|
||||||
onConfigure?.call(this, serializedNode)
|
|
||||||
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRemoved = this.onRemoved
|
|
||||||
this.onRemoved = function (this: LGraphNode) {
|
|
||||||
element.remove()
|
|
||||||
elementWidgets.delete(this)
|
|
||||||
onRemoved?.call(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore index with symbol
|
|
||||||
if (!this[SIZE]) {
|
|
||||||
// @ts-ignore index with symbol
|
|
||||||
this[SIZE] = true
|
|
||||||
const onResize = this.onResize
|
|
||||||
this.onResize = function (this: LGraphNode, size: Size) {
|
|
||||||
options.beforeResize?.call(widget, this)
|
|
||||||
onResize?.call(this, size)
|
|
||||||
options.afterResize?.call(widget, this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return widget
|
return widget
|
||||||
}
|
}
|
||||||
|
|||||||
399
web/comfyui/legacyDomWidget.ts
Normal file
399
web/comfyui/legacyDomWidget.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import { LGraphCanvas, LGraphNode } from '@comfyorg/litegraph'
|
||||||
|
import type { Size, Vector4 } from '@comfyorg/litegraph'
|
||||||
|
import type { ISerialisedNode } from '@comfyorg/litegraph/dist/types/serialisation'
|
||||||
|
import type {
|
||||||
|
ICustomWidget,
|
||||||
|
IWidgetOptions
|
||||||
|
} from '@comfyorg/litegraph/dist/types/widgets'
|
||||||
|
|
||||||
|
import { useSettingStore } from '@/stores/settingStore'
|
||||||
|
|
||||||
|
import { app } from './app'
|
||||||
|
|
||||||
|
const SIZE = Symbol()
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
height: number
|
||||||
|
width: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DOMWidget<T extends HTMLElement, V extends object | string>
|
||||||
|
extends ICustomWidget<T> {
|
||||||
|
// All unrecognized types will be treated the same way as 'custom' in litegraph internally.
|
||||||
|
type: 'custom'
|
||||||
|
name: string
|
||||||
|
element: T
|
||||||
|
options: DOMWidgetOptions<T, V>
|
||||||
|
value: V
|
||||||
|
y?: number
|
||||||
|
/**
|
||||||
|
* @deprecated Legacy property used by some extensions for customtext
|
||||||
|
* (textarea) widgets. Use `element` instead as it provides the same
|
||||||
|
* functionality and works for all DOMWidget types.
|
||||||
|
*/
|
||||||
|
inputEl?: T
|
||||||
|
callback?: (value: V) => void
|
||||||
|
/**
|
||||||
|
* Draw the widget on the canvas.
|
||||||
|
*/
|
||||||
|
draw?: (
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
node: LGraphNode,
|
||||||
|
widgetWidth: number,
|
||||||
|
y: number,
|
||||||
|
widgetHeight: number
|
||||||
|
) => void
|
||||||
|
/**
|
||||||
|
* TODO(huchenlei): Investigate when is this callback fired. `onRemove` is
|
||||||
|
* on litegraph's IBaseWidget definition, but not called in litegraph.
|
||||||
|
* Currently only called in widgetInputs.ts.
|
||||||
|
*/
|
||||||
|
onRemove?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DOMWidgetOptions<
|
||||||
|
T extends HTMLElement,
|
||||||
|
V extends object | string
|
||||||
|
> extends IWidgetOptions {
|
||||||
|
hideOnZoom?: boolean
|
||||||
|
selectOn?: string[]
|
||||||
|
onHide?: (widget: DOMWidget<T, V>) => void
|
||||||
|
getValue?: () => V
|
||||||
|
setValue?: (value: V) => void
|
||||||
|
getMinHeight?: () => number
|
||||||
|
getMaxHeight?: () => number
|
||||||
|
getHeight?: () => string | number
|
||||||
|
onDraw?: (widget: DOMWidget<T, V>) => void
|
||||||
|
beforeResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
||||||
|
afterResize?: (this: DOMWidget<T, V>, node: LGraphNode) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function intersect(a: Rect, b: Rect): Vector4 | null {
|
||||||
|
const x = Math.max(a.x, b.x)
|
||||||
|
const num1 = Math.min(a.x + a.width, b.x + b.width)
|
||||||
|
const y = Math.max(a.y, b.y)
|
||||||
|
const num2 = Math.min(a.y + a.height, b.y + b.height)
|
||||||
|
if (num1 >= x && num2 >= y) return [x, y, num1 - x, num2 - y]
|
||||||
|
else return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClipPath(
|
||||||
|
node: LGraphNode,
|
||||||
|
element: HTMLElement,
|
||||||
|
canvasRect: DOMRect
|
||||||
|
): string {
|
||||||
|
const selectedNode: LGraphNode = Object.values(
|
||||||
|
app.canvas.selected_nodes ?? {}
|
||||||
|
)[0] as LGraphNode
|
||||||
|
if (selectedNode && selectedNode !== node) {
|
||||||
|
const elRect = element.getBoundingClientRect()
|
||||||
|
const MARGIN = 4
|
||||||
|
const { offset, scale } = app.canvas.ds
|
||||||
|
const { renderArea } = selectedNode
|
||||||
|
|
||||||
|
// Get intersection in browser space
|
||||||
|
const intersection = intersect(
|
||||||
|
{
|
||||||
|
x: elRect.left - canvasRect.left,
|
||||||
|
y: elRect.top - canvasRect.top,
|
||||||
|
width: elRect.width,
|
||||||
|
height: elRect.height
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: (renderArea[0] + offset[0] - MARGIN) * scale,
|
||||||
|
y: (renderArea[1] + offset[1] - MARGIN) * scale,
|
||||||
|
width: (renderArea[2] + 2 * MARGIN) * scale,
|
||||||
|
height: (renderArea[3] + 2 * MARGIN) * scale
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!intersection) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert intersection to canvas scale (element has scale transform)
|
||||||
|
const clipX =
|
||||||
|
(intersection[0] - elRect.left + canvasRect.left) / scale + 'px'
|
||||||
|
const clipY = (intersection[1] - elRect.top + canvasRect.top) / scale + 'px'
|
||||||
|
const clipWidth = intersection[2] / scale + 'px'
|
||||||
|
const clipHeight = intersection[3] / scale + 'px'
|
||||||
|
const path = `polygon(0% 0%, 0% 100%, ${clipX} 100%, ${clipX} ${clipY}, calc(${clipX} + ${clipWidth}) ${clipY}, calc(${clipX} + ${clipWidth}) calc(${clipY} + ${clipHeight}), ${clipX} calc(${clipY} + ${clipHeight}), ${clipX} 100%, 100% 100%, 100% 0%)`
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the compute visible nodes function to allow us to hide/show DOM elements when the node goes offscreen
|
||||||
|
const elementWidgets = new Set<LGraphNode>()
|
||||||
|
const computeVisibleNodes = LGraphCanvas.prototype.computeVisibleNodes
|
||||||
|
LGraphCanvas.prototype.computeVisibleNodes = function (
|
||||||
|
nodes?: LGraphNode[],
|
||||||
|
out?: LGraphNode[]
|
||||||
|
): LGraphNode[] {
|
||||||
|
const visibleNodes = computeVisibleNodes.call(this, nodes, out)
|
||||||
|
|
||||||
|
for (const node of app.graph.nodes) {
|
||||||
|
if (elementWidgets.has(node)) {
|
||||||
|
const hidden = visibleNodes.indexOf(node) === -1
|
||||||
|
for (const w of node.widgets ?? []) {
|
||||||
|
if (w.element) {
|
||||||
|
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
|
||||||
|
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
|
||||||
|
const isCollapsed = w.element.dataset.collapsed === 'true'
|
||||||
|
const wasHidden = w.element.hidden
|
||||||
|
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
|
||||||
|
w.element.hidden = actualHidden
|
||||||
|
w.element.style.display = actualHidden ? 'none' : ''
|
||||||
|
if (actualHidden && !wasHidden) {
|
||||||
|
w.options.onHide?.(w as DOMWidget<HTMLElement, object>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleNodes
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DOMWidgetImpl<T extends HTMLElement, V extends object | string>
|
||||||
|
implements DOMWidget<T, V>
|
||||||
|
{
|
||||||
|
type: 'custom'
|
||||||
|
name: string
|
||||||
|
element: T
|
||||||
|
options: DOMWidgetOptions<T, V>
|
||||||
|
computedHeight?: number
|
||||||
|
callback?: (value: V) => void
|
||||||
|
private mouseDownHandler?: (event: MouseEvent) => void
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
element: T,
|
||||||
|
options: DOMWidgetOptions<T, V> = {}
|
||||||
|
) {
|
||||||
|
// @ts-expect-error custom widget type
|
||||||
|
this.type = type
|
||||||
|
this.name = name
|
||||||
|
this.element = element
|
||||||
|
this.options = options
|
||||||
|
|
||||||
|
if (element.blur) {
|
||||||
|
this.mouseDownHandler = (event) => {
|
||||||
|
if (!element.contains(event.target as HTMLElement)) {
|
||||||
|
element.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', this.mouseDownHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): V {
|
||||||
|
return this.options.getValue?.() ?? ('' as V)
|
||||||
|
}
|
||||||
|
|
||||||
|
set value(v: V) {
|
||||||
|
this.options.setValue?.(v)
|
||||||
|
this.callback?.(this.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract DOM widget size info */
|
||||||
|
computeLayoutSize(node: LGraphNode) {
|
||||||
|
// @ts-expect-error custom widget type
|
||||||
|
if (this.type === 'hidden') {
|
||||||
|
return {
|
||||||
|
minHeight: 0,
|
||||||
|
maxHeight: 0,
|
||||||
|
minWidth: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = getComputedStyle(this.element)
|
||||||
|
let minHeight =
|
||||||
|
this.options.getMinHeight?.() ??
|
||||||
|
parseInt(styles.getPropertyValue('--comfy-widget-min-height'))
|
||||||
|
let maxHeight =
|
||||||
|
this.options.getMaxHeight?.() ??
|
||||||
|
parseInt(styles.getPropertyValue('--comfy-widget-max-height'))
|
||||||
|
|
||||||
|
let prefHeight: string | number =
|
||||||
|
this.options.getHeight?.() ??
|
||||||
|
styles.getPropertyValue('--comfy-widget-height')
|
||||||
|
|
||||||
|
if (typeof prefHeight === 'string' && prefHeight.endsWith?.('%')) {
|
||||||
|
prefHeight =
|
||||||
|
node.size[1] *
|
||||||
|
(parseFloat(prefHeight.substring(0, prefHeight.length - 1)) / 100)
|
||||||
|
} else {
|
||||||
|
prefHeight =
|
||||||
|
typeof prefHeight === 'number' ? prefHeight : parseInt(prefHeight)
|
||||||
|
|
||||||
|
if (isNaN(minHeight)) {
|
||||||
|
minHeight = prefHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
minHeight: isNaN(minHeight) ? 50 : minHeight,
|
||||||
|
maxHeight: isNaN(maxHeight) ? undefined : maxHeight,
|
||||||
|
minWidth: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
node: LGraphNode,
|
||||||
|
widgetWidth: number,
|
||||||
|
y: number
|
||||||
|
): void {
|
||||||
|
const { offset, scale } = app.canvas.ds
|
||||||
|
const hidden =
|
||||||
|
(!!this.options.hideOnZoom && app.canvas.low_quality) ||
|
||||||
|
(this.computedHeight ?? 0) <= 0 ||
|
||||||
|
// @ts-expect-error custom widget type
|
||||||
|
this.type === 'converted-widget' ||
|
||||||
|
// @ts-expect-error custom widget type
|
||||||
|
this.type === 'hidden'
|
||||||
|
|
||||||
|
this.element.dataset.shouldHide = hidden ? 'true' : 'false'
|
||||||
|
const isInVisibleNodes = this.element.dataset.isInVisibleNodes === 'true'
|
||||||
|
const isCollapsed = this.element.dataset.collapsed === 'true'
|
||||||
|
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
|
||||||
|
const wasHidden = this.element.hidden
|
||||||
|
this.element.hidden = actualHidden
|
||||||
|
this.element.style.display = actualHidden ? 'none' : ''
|
||||||
|
|
||||||
|
if (actualHidden && !wasHidden) {
|
||||||
|
this.options.onHide?.(this)
|
||||||
|
}
|
||||||
|
if (actualHidden) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const elRect = ctx.canvas.getBoundingClientRect()
|
||||||
|
const margin = 10
|
||||||
|
const top = node.pos[0] + offset[0] + margin
|
||||||
|
const left = node.pos[1] + offset[1] + margin + y
|
||||||
|
|
||||||
|
Object.assign(this.element.style, {
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
transform: `scale(${scale})`,
|
||||||
|
left: `${top * scale}px`,
|
||||||
|
top: `${left * scale}px`,
|
||||||
|
width: `${widgetWidth - margin * 2}px`,
|
||||||
|
height: `${(this.computedHeight ?? 50) - margin * 2}px`,
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: app.graph.nodes.indexOf(node),
|
||||||
|
pointerEvents: app.canvas.read_only ? 'none' : 'auto'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (useSettingStore().get('Comfy.DOMClippingEnabled')) {
|
||||||
|
const clipPath = getClipPath(node, this.element, elRect)
|
||||||
|
this.element.style.clipPath = clipPath ?? 'none'
|
||||||
|
this.element.style.willChange = 'clip-path'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options.onDraw?.(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
onRemove(): void {
|
||||||
|
if (this.mouseDownHandler) {
|
||||||
|
document.removeEventListener('mousedown', this.mouseDownHandler)
|
||||||
|
}
|
||||||
|
this.element.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LGraphNode.prototype.addDOMWidget = function <
|
||||||
|
T extends HTMLElement,
|
||||||
|
V extends object | string
|
||||||
|
>(
|
||||||
|
this: LGraphNode,
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
element: T,
|
||||||
|
options: DOMWidgetOptions<T, V> = {}
|
||||||
|
): DOMWidget<T, V> {
|
||||||
|
options = { hideOnZoom: true, selectOn: ['focus', 'click'], ...options }
|
||||||
|
|
||||||
|
if (!element.parentElement) {
|
||||||
|
app.canvasContainer.append(element)
|
||||||
|
}
|
||||||
|
element.hidden = true
|
||||||
|
element.style.display = 'none'
|
||||||
|
|
||||||
|
const { nodeData } = this.constructor
|
||||||
|
const tooltip = (nodeData?.input.required?.[name] ??
|
||||||
|
nodeData?.input.optional?.[name])?.[1]?.tooltip
|
||||||
|
if (tooltip && !element.title) {
|
||||||
|
element.title = tooltip
|
||||||
|
}
|
||||||
|
|
||||||
|
const widget = new DOMWidgetImpl(name, type, element, options)
|
||||||
|
// Workaround for https://github.com/Comfy-Org/ComfyUI_frontend/issues/2493
|
||||||
|
// Some custom nodes are explicitly expecting getter and setter of `value`
|
||||||
|
// property to be on instance instead of prototype.
|
||||||
|
Object.defineProperty(widget, 'value', {
|
||||||
|
get(this: DOMWidgetImpl<T, V>): V {
|
||||||
|
return this.options.getValue?.() ?? ('' as V)
|
||||||
|
},
|
||||||
|
set(this: DOMWidgetImpl<T, V>, v: V) {
|
||||||
|
this.options.setValue?.(v)
|
||||||
|
this.callback?.(this.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ensure selectOn exists before iteration
|
||||||
|
const selectEvents = options.selectOn ?? ['focus', 'click']
|
||||||
|
for (const evt of selectEvents) {
|
||||||
|
element.addEventListener(evt, () => {
|
||||||
|
app.canvas.selectNode(this)
|
||||||
|
app.canvas.bringToFront(this)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addCustomWidget(widget)
|
||||||
|
elementWidgets.add(this)
|
||||||
|
|
||||||
|
const collapse = this.collapse
|
||||||
|
this.collapse = function (this: LGraphNode, force?: boolean) {
|
||||||
|
collapse.call(this, force)
|
||||||
|
if (this.collapsed) {
|
||||||
|
element.hidden = true
|
||||||
|
element.style.display = 'none'
|
||||||
|
}
|
||||||
|
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onConfigure } = this
|
||||||
|
this.onConfigure = function (
|
||||||
|
this: LGraphNode,
|
||||||
|
serializedNode: ISerialisedNode
|
||||||
|
) {
|
||||||
|
onConfigure?.call(this, serializedNode)
|
||||||
|
element.dataset.collapsed = this.collapsed ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRemoved = this.onRemoved
|
||||||
|
this.onRemoved = function (this: LGraphNode) {
|
||||||
|
element.remove()
|
||||||
|
elementWidgets.delete(this)
|
||||||
|
onRemoved?.call(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore index with symbol
|
||||||
|
if (!this[SIZE]) {
|
||||||
|
// @ts-ignore index with symbol
|
||||||
|
this[SIZE] = true
|
||||||
|
const onResize = this.onResize
|
||||||
|
this.onResize = function (this: LGraphNode, size: Size) {
|
||||||
|
options.beforeResize?.call(widget, this)
|
||||||
|
onResize?.call(this, size)
|
||||||
|
options.afterResize?.call(widget, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget
|
||||||
|
}
|
||||||
882
web/comfyui/legacy_loras_widget.js
Normal file
882
web/comfyui/legacy_loras_widget.js
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
import { app } from "../../scripts/app.js";
|
||||||
|
|
||||||
|
export function addLorasWidget(node, name, opts, callback) {
|
||||||
|
// Create container for loras
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "comfy-loras-container";
|
||||||
|
Object.assign(container.style, {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "6px",
|
||||||
|
backgroundColor: "rgba(40, 44, 52, 0.6)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
width: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize default value
|
||||||
|
const defaultValue = opts?.defaultVal || [];
|
||||||
|
|
||||||
|
// Parse LoRA entries from value
|
||||||
|
const parseLoraValue = (value) => {
|
||||||
|
if (!value) return [];
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format LoRA data
|
||||||
|
const formatLoraValue = (loras) => {
|
||||||
|
return loras;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to create toggle element
|
||||||
|
const createToggle = (active, onChange) => {
|
||||||
|
const toggle = document.createElement("div");
|
||||||
|
toggle.className = "comfy-lora-toggle";
|
||||||
|
|
||||||
|
updateToggleStyle(toggle, active);
|
||||||
|
|
||||||
|
toggle.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(!active);
|
||||||
|
});
|
||||||
|
|
||||||
|
return toggle;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to update toggle style
|
||||||
|
function updateToggleStyle(toggleEl, active) {
|
||||||
|
Object.assign(toggleEl.style, {
|
||||||
|
width: "18px",
|
||||||
|
height: "18px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
backgroundColor: active ? "rgba(66, 153, 225, 0.9)" : "rgba(45, 55, 72, 0.7)",
|
||||||
|
border: `1px solid ${active ? "rgba(66, 153, 225, 0.9)" : "rgba(226, 232, 240, 0.2)"}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hover effect
|
||||||
|
toggleEl.onmouseenter = () => {
|
||||||
|
toggleEl.style.transform = "scale(1.05)";
|
||||||
|
toggleEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
|
||||||
|
};
|
||||||
|
|
||||||
|
toggleEl.onmouseleave = () => {
|
||||||
|
toggleEl.style.transform = "scale(1)";
|
||||||
|
toggleEl.style.boxShadow = "none";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create arrow button for strength adjustment
|
||||||
|
const createArrowButton = (direction, onClick) => {
|
||||||
|
const button = document.createElement("div");
|
||||||
|
button.className = `comfy-lora-arrow comfy-lora-arrow-${direction}`;
|
||||||
|
|
||||||
|
Object.assign(button.style, {
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
});
|
||||||
|
|
||||||
|
button.textContent = direction === "left" ? "◀" : "▶";
|
||||||
|
|
||||||
|
button.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add hover effect
|
||||||
|
button.onmouseenter = () => {
|
||||||
|
button.style.color = "white";
|
||||||
|
button.style.transform = "scale(1.2)";
|
||||||
|
};
|
||||||
|
|
||||||
|
button.onmouseleave = () => {
|
||||||
|
button.style.color = "rgba(226, 232, 240, 0.8)";
|
||||||
|
button.style.transform = "scale(1)";
|
||||||
|
};
|
||||||
|
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加预览弹窗组件
|
||||||
|
class PreviewTooltip {
|
||||||
|
constructor() {
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
Object.assign(this.element.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
zIndex: 9999,
|
||||||
|
background: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||||
|
display: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxWidth: '300px',
|
||||||
|
});
|
||||||
|
document.body.appendChild(this.element);
|
||||||
|
this.hideTimeout = null; // 添加超时处理变量
|
||||||
|
|
||||||
|
// 添加全局点击事件来隐藏tooltip
|
||||||
|
document.addEventListener('click', () => this.hide());
|
||||||
|
|
||||||
|
// 添加滚动事件监听
|
||||||
|
document.addEventListener('scroll', () => this.hide(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async show(loraName, x, y) {
|
||||||
|
try {
|
||||||
|
// 清除之前的隐藏定时器
|
||||||
|
if (this.hideTimeout) {
|
||||||
|
clearTimeout(this.hideTimeout);
|
||||||
|
this.hideTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经显示同一个lora的预览,则不重复显示
|
||||||
|
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentLora = loraName;
|
||||||
|
|
||||||
|
// 获取预览URL
|
||||||
|
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch preview URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.preview_url) {
|
||||||
|
throw new Error('No preview available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除现有内容
|
||||||
|
while (this.element.firstChild) {
|
||||||
|
this.element.removeChild(this.element.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create media container with relative positioning
|
||||||
|
const mediaContainer = document.createElement('div');
|
||||||
|
Object.assign(mediaContainer.style, {
|
||||||
|
position: 'relative',
|
||||||
|
maxWidth: '300px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
});
|
||||||
|
|
||||||
|
const isVideo = data.preview_url.endsWith('.mp4');
|
||||||
|
const mediaElement = isVideo ? document.createElement('video') : document.createElement('img');
|
||||||
|
|
||||||
|
Object.assign(mediaElement.style, {
|
||||||
|
maxWidth: '300px',
|
||||||
|
maxHeight: '300px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isVideo) {
|
||||||
|
mediaElement.autoplay = true;
|
||||||
|
mediaElement.loop = true;
|
||||||
|
mediaElement.muted = true;
|
||||||
|
mediaElement.controls = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaElement.src = data.preview_url;
|
||||||
|
|
||||||
|
// Create name label with absolute positioning
|
||||||
|
const nameLabel = document.createElement('div');
|
||||||
|
nameLabel.textContent = loraName;
|
||||||
|
Object.assign(nameLabel.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '0',
|
||||||
|
left: '0',
|
||||||
|
right: '0',
|
||||||
|
padding: '8px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontFamily: "'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
||||||
|
background: 'linear-gradient(transparent, rgba(0, 0, 0, 0.8))',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
textAlign: 'center',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
WebkitBackdropFilter: 'blur(4px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
mediaContainer.appendChild(mediaElement);
|
||||||
|
mediaContainer.appendChild(nameLabel);
|
||||||
|
this.element.appendChild(mediaContainer);
|
||||||
|
|
||||||
|
// 添加淡入效果
|
||||||
|
this.element.style.opacity = '0';
|
||||||
|
this.element.style.display = 'block';
|
||||||
|
this.position(x, y);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
this.element.style.transition = 'opacity 0.15s ease';
|
||||||
|
this.element.style.opacity = '1';
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load preview:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
position(x, y) {
|
||||||
|
// 确保预览框不超出视窗边界
|
||||||
|
const rect = this.element.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = x + 10; // 默认在鼠标右侧偏移10px
|
||||||
|
let top = y + 10; // 默认在鼠标下方偏移10px
|
||||||
|
|
||||||
|
// 检查右边界
|
||||||
|
if (left + rect.width > viewportWidth) {
|
||||||
|
left = x - rect.width - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查下边界
|
||||||
|
if (top + rect.height > viewportHeight) {
|
||||||
|
top = y - rect.height - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this.element.style, {
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
// 使用淡出效果
|
||||||
|
if (this.element.style.display === 'block') {
|
||||||
|
this.element.style.opacity = '0';
|
||||||
|
this.hideTimeout = setTimeout(() => {
|
||||||
|
this.element.style.display = 'none';
|
||||||
|
this.currentLora = null;
|
||||||
|
// 停止视频播放
|
||||||
|
const video = this.element.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
this.hideTimeout = null;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if (this.hideTimeout) {
|
||||||
|
clearTimeout(this.hideTimeout);
|
||||||
|
}
|
||||||
|
// 移除所有事件监听器
|
||||||
|
document.removeEventListener('click', () => this.hide());
|
||||||
|
document.removeEventListener('scroll', () => this.hide(), true);
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预览tooltip实例
|
||||||
|
const previewTooltip = new PreviewTooltip();
|
||||||
|
|
||||||
|
// Function to create menu item
|
||||||
|
const createMenuItem = (text, icon, onClick) => {
|
||||||
|
const menuItem = document.createElement('div');
|
||||||
|
Object.assign(menuItem.style, {
|
||||||
|
padding: '6px 20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'rgba(226, 232, 240, 0.9)',
|
||||||
|
fontSize: '13px',
|
||||||
|
userSelect: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create icon element
|
||||||
|
const iconEl = document.createElement('div');
|
||||||
|
iconEl.innerHTML = icon;
|
||||||
|
Object.assign(iconEl.style, {
|
||||||
|
width: '14px',
|
||||||
|
height: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create text element
|
||||||
|
const textEl = document.createElement('span');
|
||||||
|
textEl.textContent = text;
|
||||||
|
|
||||||
|
menuItem.appendChild(iconEl);
|
||||||
|
menuItem.appendChild(textEl);
|
||||||
|
|
||||||
|
menuItem.addEventListener('mouseenter', () => {
|
||||||
|
menuItem.style.backgroundColor = 'rgba(66, 153, 225, 0.2)';
|
||||||
|
});
|
||||||
|
|
||||||
|
menuItem.addEventListener('mouseleave', () => {
|
||||||
|
menuItem.style.backgroundColor = 'transparent';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
menuItem.addEventListener('click', onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
return menuItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to create context menu
|
||||||
|
const createContextMenu = (x, y, loraName, widget) => {
|
||||||
|
// Hide preview tooltip first
|
||||||
|
previewTooltip.hide();
|
||||||
|
|
||||||
|
// Remove existing context menu if any
|
||||||
|
const existingMenu = document.querySelector('.comfy-lora-context-menu');
|
||||||
|
if (existingMenu) {
|
||||||
|
existingMenu.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'comfy-lora-context-menu';
|
||||||
|
Object.assign(menu.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
backgroundColor: 'rgba(30, 30, 30, 0.95)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '4px 0',
|
||||||
|
zIndex: 1000,
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
|
||||||
|
minWidth: '180px',
|
||||||
|
});
|
||||||
|
|
||||||
|
// View on Civitai option with globe icon
|
||||||
|
const viewOnCivitaiOption = createMenuItem(
|
||||||
|
'View on Civitai',
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>',
|
||||||
|
async () => {
|
||||||
|
menu.remove();
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get Civitai URL from API
|
||||||
|
const response = await api.fetchApi(`/lora-civitai-url?name=${encodeURIComponent(loraName)}`, {
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || 'Failed to get Civitai URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success && data.civitai_url) {
|
||||||
|
// Open the URL in a new tab
|
||||||
|
window.open(data.civitai_url, '_blank');
|
||||||
|
} else {
|
||||||
|
// Show error message if no Civitai URL
|
||||||
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity: 'warning',
|
||||||
|
summary: 'Not Found',
|
||||||
|
detail: 'This LoRA has no associated Civitai URL',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('This LoRA has no associated Civitai URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting Civitai URL:', error);
|
||||||
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: error.message || 'Failed to get Civitai URL',
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (error.message || 'Failed to get Civitai URL'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete option with trash icon
|
||||||
|
const deleteOption = createMenuItem(
|
||||||
|
'Delete',
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18m-2 0v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6m3 0V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>',
|
||||||
|
() => {
|
||||||
|
menu.remove();
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
|
||||||
|
const lorasData = parseLoraValue(widget.value).filter(l => l.name !== loraName);
|
||||||
|
widget.value = formatLoraValue(lorasData);
|
||||||
|
|
||||||
|
if (widget.callback) {
|
||||||
|
widget.callback(widget.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save recipe option with bookmark icon
|
||||||
|
const saveOption = createMenuItem(
|
||||||
|
'Save Recipe',
|
||||||
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path></svg>',
|
||||||
|
() => {
|
||||||
|
menu.remove();
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
saveRecipeDirectly(widget);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add separator
|
||||||
|
const separator = document.createElement('div');
|
||||||
|
Object.assign(separator.style, {
|
||||||
|
margin: '4px 0',
|
||||||
|
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.appendChild(viewOnCivitaiOption); // Add the new menu option
|
||||||
|
menu.appendChild(deleteOption);
|
||||||
|
menu.appendChild(separator);
|
||||||
|
menu.appendChild(saveOption);
|
||||||
|
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
const closeMenu = (e) => {
|
||||||
|
if (!menu.contains(e.target)) {
|
||||||
|
menu.remove();
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', closeMenu), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to render loras from data
|
||||||
|
const renderLoras = (value, widget) => {
|
||||||
|
// Clear existing content
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the loras data
|
||||||
|
const lorasData = parseLoraValue(value);
|
||||||
|
|
||||||
|
if (lorasData.length === 0) {
|
||||||
|
// Show message when no loras are added
|
||||||
|
const emptyMessage = document.createElement("div");
|
||||||
|
emptyMessage.textContent = "No LoRAs added";
|
||||||
|
Object.assign(emptyMessage.style, {
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px 0",
|
||||||
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
|
fontStyle: "italic",
|
||||||
|
userSelect: "none", // Add this line to prevent text selection
|
||||||
|
WebkitUserSelect: "none", // For Safari support
|
||||||
|
MozUserSelect: "none", // For Firefox support
|
||||||
|
msUserSelect: "none", // For IE/Edge support
|
||||||
|
});
|
||||||
|
container.appendChild(emptyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create header
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "comfy-loras-header";
|
||||||
|
Object.assign(header.style, {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 8px",
|
||||||
|
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
||||||
|
marginBottom: "8px"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add toggle all control
|
||||||
|
const allActive = lorasData.every(lora => lora.active);
|
||||||
|
const toggleAll = createToggle(allActive, (active) => {
|
||||||
|
// Update all loras active state
|
||||||
|
const lorasData = parseLoraValue(widget.value);
|
||||||
|
lorasData.forEach(lora => lora.active = active);
|
||||||
|
|
||||||
|
const newValue = formatLoraValue(lorasData);
|
||||||
|
widget.value = newValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add label to toggle all
|
||||||
|
const toggleLabel = document.createElement("div");
|
||||||
|
toggleLabel.textContent = "Toggle All";
|
||||||
|
Object.assign(toggleLabel.style, {
|
||||||
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
|
fontSize: "13px",
|
||||||
|
marginLeft: "8px",
|
||||||
|
userSelect: "none", // Add this line to prevent text selection
|
||||||
|
WebkitUserSelect: "none", // For Safari support
|
||||||
|
MozUserSelect: "none", // For Firefox support
|
||||||
|
msUserSelect: "none", // For IE/Edge support
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleContainer = document.createElement("div");
|
||||||
|
Object.assign(toggleContainer.style, {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
});
|
||||||
|
toggleContainer.appendChild(toggleAll);
|
||||||
|
toggleContainer.appendChild(toggleLabel);
|
||||||
|
|
||||||
|
// Strength label
|
||||||
|
const strengthLabel = document.createElement("div");
|
||||||
|
strengthLabel.textContent = "Strength";
|
||||||
|
Object.assign(strengthLabel.style, {
|
||||||
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
|
fontSize: "13px",
|
||||||
|
marginRight: "8px",
|
||||||
|
userSelect: "none", // Add this line to prevent text selection
|
||||||
|
WebkitUserSelect: "none", // For Safari support
|
||||||
|
MozUserSelect: "none", // For Firefox support
|
||||||
|
msUserSelect: "none", // For IE/Edge support
|
||||||
|
});
|
||||||
|
|
||||||
|
header.appendChild(toggleContainer);
|
||||||
|
header.appendChild(strengthLabel);
|
||||||
|
container.appendChild(header);
|
||||||
|
|
||||||
|
// Render each lora entry
|
||||||
|
lorasData.forEach((loraData) => {
|
||||||
|
const { name, strength, active } = loraData;
|
||||||
|
|
||||||
|
const loraEl = document.createElement("div");
|
||||||
|
loraEl.className = "comfy-lora-entry";
|
||||||
|
Object.assign(loraEl.style, {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "8px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
marginBottom: "6px",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create toggle for this lora
|
||||||
|
const toggle = createToggle(active, (newActive) => {
|
||||||
|
// Update this lora's active state
|
||||||
|
const lorasData = parseLoraValue(widget.value);
|
||||||
|
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||||
|
|
||||||
|
if (loraIndex >= 0) {
|
||||||
|
lorasData[loraIndex].active = newActive;
|
||||||
|
|
||||||
|
const newValue = formatLoraValue(lorasData);
|
||||||
|
widget.value = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create name display
|
||||||
|
const nameEl = document.createElement("div");
|
||||||
|
nameEl.textContent = name;
|
||||||
|
Object.assign(nameEl.style, {
|
||||||
|
marginLeft: "10px",
|
||||||
|
flex: "1",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
|
||||||
|
fontSize: "13px",
|
||||||
|
cursor: "pointer", // Add pointer cursor to indicate hoverable area
|
||||||
|
userSelect: "none", // Add this line to prevent text selection
|
||||||
|
WebkitUserSelect: "none", // For Safari support
|
||||||
|
MozUserSelect: "none", // For Firefox support
|
||||||
|
msUserSelect: "none", // For IE/Edge support
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move preview tooltip events to nameEl instead of loraEl
|
||||||
|
nameEl.addEventListener('mouseenter', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const rect = nameEl.getBoundingClientRect();
|
||||||
|
await previewTooltip.show(name, rect.right, rect.top);
|
||||||
|
});
|
||||||
|
|
||||||
|
nameEl.addEventListener('mouseleave', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
previewTooltip.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the preview tooltip events from loraEl
|
||||||
|
loraEl.onmouseenter = () => {
|
||||||
|
loraEl.style.backgroundColor = active ? "rgba(50, 60, 80, 0.8)" : "rgba(40, 45, 55, 0.6)";
|
||||||
|
};
|
||||||
|
|
||||||
|
loraEl.onmouseleave = () => {
|
||||||
|
loraEl.style.backgroundColor = active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add context menu event
|
||||||
|
loraEl.addEventListener('contextmenu', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
createContextMenu(e.clientX, e.clientY, name, widget);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create strength control
|
||||||
|
const strengthControl = document.createElement("div");
|
||||||
|
Object.assign(strengthControl.style, {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Left arrow
|
||||||
|
const leftArrow = createArrowButton("left", () => {
|
||||||
|
// Decrease strength
|
||||||
|
const lorasData = parseLoraValue(widget.value);
|
||||||
|
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||||
|
|
||||||
|
if (loraIndex >= 0) {
|
||||||
|
lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2);
|
||||||
|
|
||||||
|
const newValue = formatLoraValue(lorasData);
|
||||||
|
widget.value = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Strength display
|
||||||
|
const strengthEl = document.createElement("input");
|
||||||
|
strengthEl.type = "text";
|
||||||
|
strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2);
|
||||||
|
Object.assign(strengthEl.style, {
|
||||||
|
minWidth: "50px",
|
||||||
|
width: "50px",
|
||||||
|
textAlign: "center",
|
||||||
|
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
|
||||||
|
fontSize: "13px",
|
||||||
|
background: "none",
|
||||||
|
border: "1px solid transparent",
|
||||||
|
padding: "2px 4px",
|
||||||
|
borderRadius: "3px",
|
||||||
|
outline: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加hover效果
|
||||||
|
strengthEl.addEventListener('mouseenter', () => {
|
||||||
|
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
|
||||||
|
});
|
||||||
|
|
||||||
|
strengthEl.addEventListener('mouseleave', () => {
|
||||||
|
if (document.activeElement !== strengthEl) {
|
||||||
|
strengthEl.style.border = "1px solid transparent";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理焦点
|
||||||
|
strengthEl.addEventListener('focus', () => {
|
||||||
|
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
|
||||||
|
strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
|
||||||
|
// 自动选中所有内容
|
||||||
|
strengthEl.select();
|
||||||
|
});
|
||||||
|
|
||||||
|
strengthEl.addEventListener('blur', () => {
|
||||||
|
strengthEl.style.border = "1px solid transparent";
|
||||||
|
strengthEl.style.background = "none";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理输入变化
|
||||||
|
strengthEl.addEventListener('change', () => {
|
||||||
|
let newValue = parseFloat(strengthEl.value);
|
||||||
|
|
||||||
|
// 验证输入
|
||||||
|
if (isNaN(newValue)) {
|
||||||
|
newValue = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新数值
|
||||||
|
const lorasData = parseLoraValue(widget.value);
|
||||||
|
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||||
|
|
||||||
|
if (loraIndex >= 0) {
|
||||||
|
lorasData[loraIndex].strength = newValue.toFixed(2);
|
||||||
|
|
||||||
|
// 更新值并触发回调
|
||||||
|
const newLorasValue = formatLoraValue(lorasData);
|
||||||
|
widget.value = newLorasValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理按键事件
|
||||||
|
strengthEl.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
strengthEl.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right arrow
|
||||||
|
const rightArrow = createArrowButton("right", () => {
|
||||||
|
// Increase strength
|
||||||
|
const lorasData = parseLoraValue(widget.value);
|
||||||
|
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||||
|
|
||||||
|
if (loraIndex >= 0) {
|
||||||
|
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
|
||||||
|
|
||||||
|
const newValue = formatLoraValue(lorasData);
|
||||||
|
widget.value = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
strengthControl.appendChild(leftArrow);
|
||||||
|
strengthControl.appendChild(strengthEl);
|
||||||
|
strengthControl.appendChild(rightArrow);
|
||||||
|
|
||||||
|
// Assemble entry
|
||||||
|
const leftSection = document.createElement("div");
|
||||||
|
Object.assign(leftSection.style, {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
flex: "1",
|
||||||
|
minWidth: "0", // Allow shrinking
|
||||||
|
});
|
||||||
|
|
||||||
|
leftSection.appendChild(toggle);
|
||||||
|
leftSection.appendChild(nameEl);
|
||||||
|
|
||||||
|
loraEl.appendChild(leftSection);
|
||||||
|
loraEl.appendChild(strengthControl);
|
||||||
|
|
||||||
|
container.appendChild(loraEl);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the value in a variable to avoid recursion
|
||||||
|
let widgetValue = defaultValue;
|
||||||
|
|
||||||
|
// Create widget with initial properties
|
||||||
|
const widget = node.addDOMWidget(name, "loras", container, {
|
||||||
|
getValue: function() {
|
||||||
|
return widgetValue;
|
||||||
|
},
|
||||||
|
setValue: function(v) {
|
||||||
|
// Remove duplicates by keeping the last occurrence of each lora name
|
||||||
|
const uniqueValue = (v || []).reduce((acc, lora) => {
|
||||||
|
// Remove any existing lora with the same name
|
||||||
|
const filtered = acc.filter(l => l.name !== lora.name);
|
||||||
|
// Add the current lora
|
||||||
|
return [...filtered, lora];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
widgetValue = uniqueValue;
|
||||||
|
renderLoras(widgetValue, widget);
|
||||||
|
|
||||||
|
// Update container height after rendering
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const minHeight = this.getMinHeight();
|
||||||
|
container.style.height = `${minHeight}px`;
|
||||||
|
|
||||||
|
// Force node to update size
|
||||||
|
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getMinHeight: function() {
|
||||||
|
// Calculate height based on content
|
||||||
|
const lorasCount = parseLoraValue(widgetValue).length;
|
||||||
|
return Math.max(
|
||||||
|
100,
|
||||||
|
lorasCount > 0 ? 60 + lorasCount * 44 : 60
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.value = defaultValue;
|
||||||
|
|
||||||
|
widget.callback = callback;
|
||||||
|
|
||||||
|
widget.serializeValue = () => {
|
||||||
|
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
|
||||||
|
return [...widgetValue,
|
||||||
|
{ name: "__dummy_item1__", strength: 0, active: false, _isDummy: true },
|
||||||
|
{ name: "__dummy_item2__", strength: 0, active: false, _isDummy: true }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onRemove = () => {
|
||||||
|
container.remove();
|
||||||
|
previewTooltip.cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { minWidth: 400, minHeight: 200, widget };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to directly save the recipe without dialog
|
||||||
|
async function saveRecipeDirectly(widget) {
|
||||||
|
try {
|
||||||
|
// Get the workflow data from the ComfyUI app
|
||||||
|
const prompt = await app.graphToPrompt();
|
||||||
|
console.log('Prompt:', prompt);
|
||||||
|
|
||||||
|
// Show loading toast
|
||||||
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: 'Saving Recipe',
|
||||||
|
detail: 'Please wait...',
|
||||||
|
life: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare the data - only send workflow JSON
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('workflow_json', JSON.stringify(prompt.output));
|
||||||
|
|
||||||
|
// Send the request
|
||||||
|
const response = await fetch('/api/recipes/save-from-widget', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Show result toast
|
||||||
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
|
if (result.success) {
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Recipe Saved',
|
||||||
|
detail: 'Recipe has been saved successfully',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: result.error || 'Failed to save recipe',
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving recipe:', error);
|
||||||
|
|
||||||
|
// Show error toast
|
||||||
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
|
app.extensionManager.toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'Failed to save recipe: ' + (error.message || 'Unknown error'),
|
||||||
|
life: 5000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
web/comfyui/legacy_tags_widget.js
Normal file
193
web/comfyui/legacy_tags_widget.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
export function addTagsWidget(node, name, opts, callback) {
|
||||||
|
// Create container for tags
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "comfy-tags-container";
|
||||||
|
Object.assign(container.style, {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "4px", // 从8px减小到4px
|
||||||
|
padding: "6px",
|
||||||
|
minHeight: "30px",
|
||||||
|
backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background
|
||||||
|
borderRadius: "6px", // Slightly larger radius
|
||||||
|
width: "100%",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize default value as array
|
||||||
|
const initialTagsData = opts?.defaultVal || [];
|
||||||
|
|
||||||
|
// Function to render tags from array data
|
||||||
|
const renderTags = (tagsData, widget) => {
|
||||||
|
// Clear existing tags
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTags = tagsData;
|
||||||
|
|
||||||
|
if (normalizedTags.length === 0) {
|
||||||
|
// Show message when no tags are present
|
||||||
|
const emptyMessage = document.createElement("div");
|
||||||
|
emptyMessage.textContent = "No trigger words detected";
|
||||||
|
Object.assign(emptyMessage.style, {
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "20px 0",
|
||||||
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
|
fontStyle: "italic",
|
||||||
|
userSelect: "none",
|
||||||
|
WebkitUserSelect: "none",
|
||||||
|
MozUserSelect: "none",
|
||||||
|
msUserSelect: "none",
|
||||||
|
});
|
||||||
|
container.appendChild(emptyMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedTags.forEach((tagData, index) => {
|
||||||
|
const { text, active } = tagData;
|
||||||
|
const tagEl = document.createElement("div");
|
||||||
|
tagEl.className = "comfy-tag";
|
||||||
|
|
||||||
|
updateTagStyle(tagEl, active);
|
||||||
|
|
||||||
|
tagEl.textContent = text;
|
||||||
|
tagEl.title = text; // Set tooltip for full content
|
||||||
|
|
||||||
|
// Add click handler to toggle state
|
||||||
|
tagEl.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Toggle active state for this specific tag using its index
|
||||||
|
const updatedTags = [...widget.value];
|
||||||
|
updatedTags[index].active = !updatedTags[index].active;
|
||||||
|
updateTagStyle(tagEl, updatedTags[index].active);
|
||||||
|
|
||||||
|
widget.value = updatedTags;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(tagEl);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to update tag style based on active state
|
||||||
|
function updateTagStyle(tagEl, active) {
|
||||||
|
const baseStyles = {
|
||||||
|
padding: "4px 12px", // 垂直内边距从6px减小到4px
|
||||||
|
borderRadius: "6px", // Matching container radius
|
||||||
|
maxWidth: "200px", // Increased max width
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontSize: "13px", // Slightly larger font
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.2s ease", // Smoother transition
|
||||||
|
border: "1px solid transparent",
|
||||||
|
display: "inline-block",
|
||||||
|
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
|
||||||
|
margin: "2px", // 从4px减小到2px
|
||||||
|
userSelect: "none", // Add this line to prevent text selection
|
||||||
|
WebkitUserSelect: "none", // For Safari support
|
||||||
|
MozUserSelect: "none", // For Firefox support
|
||||||
|
msUserSelect: "none", // For IE/Edge support
|
||||||
|
};
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
Object.assign(tagEl.style, {
|
||||||
|
...baseStyles,
|
||||||
|
backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue
|
||||||
|
color: "white",
|
||||||
|
borderColor: "rgba(66, 153, 225, 0.9)",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(tagEl.style, {
|
||||||
|
...baseStyles,
|
||||||
|
backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state
|
||||||
|
color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast
|
||||||
|
borderColor: "rgba(226, 232, 240, 0.2)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add hover effect
|
||||||
|
tagEl.onmouseenter = () => {
|
||||||
|
tagEl.style.transform = "translateY(-1px)";
|
||||||
|
tagEl.style.boxShadow = "0 2px 4px rgba(0,0,0,0.15)";
|
||||||
|
};
|
||||||
|
|
||||||
|
tagEl.onmouseleave = () => {
|
||||||
|
tagEl.style.transform = "translateY(0)";
|
||||||
|
tagEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the value as array
|
||||||
|
let widgetValue = initialTagsData;
|
||||||
|
|
||||||
|
// Create widget with initial properties
|
||||||
|
const widget = node.addDOMWidget(name, "tags", container, {
|
||||||
|
getValue: function() {
|
||||||
|
return widgetValue;
|
||||||
|
},
|
||||||
|
setValue: function(v) {
|
||||||
|
widgetValue = v;
|
||||||
|
renderTags(widgetValue, widget);
|
||||||
|
|
||||||
|
// Update container height after rendering
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const minHeight = this.getMinHeight();
|
||||||
|
container.style.height = `${minHeight}px`;
|
||||||
|
|
||||||
|
// Force node to update size
|
||||||
|
node.setSize([node.size[0], node.computeSize()[1]]);
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getMinHeight: function() {
|
||||||
|
const minHeight = 150;
|
||||||
|
// If no tags or only showing the empty message, return a minimum height
|
||||||
|
if (widgetValue.length === 0) {
|
||||||
|
return minHeight; // Height for empty state with message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all tag elements
|
||||||
|
const tagElements = container.querySelectorAll('.comfy-tag');
|
||||||
|
|
||||||
|
if (tagElements.length === 0) {
|
||||||
|
return minHeight; // Fallback if elements aren't rendered yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the actual height based on tag positions
|
||||||
|
let maxBottom = 0;
|
||||||
|
|
||||||
|
tagElements.forEach(tag => {
|
||||||
|
const rect = tag.getBoundingClientRect();
|
||||||
|
const tagBottom = rect.bottom - container.getBoundingClientRect().top;
|
||||||
|
maxBottom = Math.max(maxBottom, tagBottom);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add padding (top and bottom padding of container)
|
||||||
|
const computedStyle = window.getComputedStyle(container);
|
||||||
|
const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
|
||||||
|
const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
|
||||||
|
|
||||||
|
// Add extra buffer for potential wrapping issues and to ensure no clipping
|
||||||
|
const extraBuffer = 20;
|
||||||
|
|
||||||
|
// Round up to nearest 5px for clean sizing and ensure minimum height
|
||||||
|
return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
widget.value = initialTagsData;
|
||||||
|
|
||||||
|
widget.callback = callback;
|
||||||
|
|
||||||
|
widget.serializeValue = () => {
|
||||||
|
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
|
||||||
|
return [...widgetValue,
|
||||||
|
{ text: "__dummy_item__", active: false, _isDummy: true },
|
||||||
|
{ text: "__dummy_item__", active: false, _isDummy: true }
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return { minWidth: 300, minHeight: 150, widget };
|
||||||
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { addLorasWidget } from "./loras_widget.js";
|
import { dynamicImportByVersion } from "./utils.js";
|
||||||
|
|
||||||
// Extract pattern into a constant for consistent use
|
// Extract pattern into a constant for consistent use
|
||||||
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g;
|
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g;
|
||||||
|
|
||||||
|
// Function to get the appropriate loras widget based on ComfyUI version
|
||||||
|
async function getLorasWidgetModule() {
|
||||||
|
return await dynamicImportByVersion("./loras_widget.js", "./legacy_loras_widget.js");
|
||||||
|
}
|
||||||
|
|
||||||
function mergeLoras(lorasText, lorasArr) {
|
function mergeLoras(lorasText, lorasArr) {
|
||||||
const result = [];
|
const result = [];
|
||||||
let match;
|
let match;
|
||||||
@@ -44,7 +49,7 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for node to be properly initialized
|
// Wait for node to be properly initialized
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(async () => {
|
||||||
// Restore saved value if exists
|
// Restore saved value if exists
|
||||||
let existingLoras = [];
|
let existingLoras = [];
|
||||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||||
@@ -67,6 +72,10 @@ app.registerExtension({
|
|||||||
|
|
||||||
// Add flag to prevent callback loops
|
// Add flag to prevent callback loops
|
||||||
let isUpdating = false;
|
let isUpdating = false;
|
||||||
|
|
||||||
|
// Dynamically load the appropriate widget module
|
||||||
|
const lorasModule = await getLorasWidgetModule();
|
||||||
|
const { addLorasWidget } = lorasModule;
|
||||||
|
|
||||||
// Get the widget object directly from the returned object
|
// Get the widget object directly from the returned object
|
||||||
const result = addLorasWidget(node, "loras", {
|
const result = addLorasWidget(node, "loras", {
|
||||||
|
|||||||
@@ -5,19 +5,34 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
// Create container for loras
|
// Create container for loras
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.className = "comfy-loras-container";
|
container.className = "comfy-loras-container";
|
||||||
|
|
||||||
|
// Set initial height using CSS variables approach
|
||||||
|
const defaultHeight = 200;
|
||||||
|
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
||||||
|
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
|
||||||
|
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "8px",
|
gap: "5px",
|
||||||
padding: "6px",
|
padding: "6px",
|
||||||
backgroundColor: "rgba(40, 44, 52, 0.6)",
|
backgroundColor: "rgba(40, 44, 52, 0.6)",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "auto"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize default value
|
// Initialize default value
|
||||||
const defaultValue = opts?.defaultVal || [];
|
const defaultValue = opts?.defaultVal || [];
|
||||||
|
|
||||||
|
// Fixed sizes for component calculations
|
||||||
|
const LORA_ENTRY_HEIGHT = 40; // Height of a single lora entry
|
||||||
|
const HEADER_HEIGHT = 40; // Height of the header section
|
||||||
|
const CONTAINER_PADDING = 12; // Top and bottom padding
|
||||||
|
const EMPTY_CONTAINER_HEIGHT = 100; // Height when no loras are present
|
||||||
|
|
||||||
// Parse LoRA entries from value
|
// Parse LoRA entries from value
|
||||||
const parseLoraValue = (value) => {
|
const parseLoraValue = (value) => {
|
||||||
if (!value) return [];
|
if (!value) return [];
|
||||||
@@ -29,6 +44,23 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
return loras;
|
return loras;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to update widget height consistently
|
||||||
|
const updateWidgetHeight = (height) => {
|
||||||
|
// Ensure minimum height
|
||||||
|
const finalHeight = Math.max(defaultHeight, height);
|
||||||
|
|
||||||
|
// Update CSS variables
|
||||||
|
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
|
||||||
|
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
|
||||||
|
|
||||||
|
// Force node to update size after a short delay to ensure DOM is updated
|
||||||
|
if (node) {
|
||||||
|
setTimeout(() => {
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Function to create toggle element
|
// Function to create toggle element
|
||||||
const createToggle = (active, onChange) => {
|
const createToggle = (active, onChange) => {
|
||||||
const toggle = document.createElement("div");
|
const toggle = document.createElement("div");
|
||||||
@@ -107,7 +139,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
return button;
|
return button;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 添加预览弹窗组件
|
// Preview tooltip class
|
||||||
class PreviewTooltip {
|
class PreviewTooltip {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.element = document.createElement('div');
|
this.element = document.createElement('div');
|
||||||
@@ -122,31 +154,31 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
maxWidth: '300px',
|
maxWidth: '300px',
|
||||||
});
|
});
|
||||||
document.body.appendChild(this.element);
|
document.body.appendChild(this.element);
|
||||||
this.hideTimeout = null; // 添加超时处理变量
|
this.hideTimeout = null;
|
||||||
|
|
||||||
// 添加全局点击事件来隐藏tooltip
|
// Add global click event to hide tooltip
|
||||||
document.addEventListener('click', () => this.hide());
|
document.addEventListener('click', () => this.hide());
|
||||||
|
|
||||||
// 添加滚动事件监听
|
// Add scroll event listener
|
||||||
document.addEventListener('scroll', () => this.hide(), true);
|
document.addEventListener('scroll', () => this.hide(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async show(loraName, x, y) {
|
async show(loraName, x, y) {
|
||||||
try {
|
try {
|
||||||
// 清除之前的隐藏定时器
|
// Clear previous hide timer
|
||||||
if (this.hideTimeout) {
|
if (this.hideTimeout) {
|
||||||
clearTimeout(this.hideTimeout);
|
clearTimeout(this.hideTimeout);
|
||||||
this.hideTimeout = null;
|
this.hideTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已经显示同一个lora的预览,则不重复显示
|
// Don't redisplay the same lora preview
|
||||||
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
if (this.element.style.display === 'block' && this.currentLora === loraName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentLora = loraName;
|
this.currentLora = loraName;
|
||||||
|
|
||||||
// 获取预览URL
|
// Get preview URL
|
||||||
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
|
const response = await api.fetchApi(`/lora-preview-url?name=${encodeURIComponent(loraName)}`, {
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
@@ -160,7 +192,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
throw new Error('No preview available');
|
throw new Error('No preview available');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除现有内容
|
// Clear existing content
|
||||||
while (this.element.firstChild) {
|
while (this.element.firstChild) {
|
||||||
this.element.removeChild(this.element.firstChild);
|
this.element.removeChild(this.element.firstChild);
|
||||||
}
|
}
|
||||||
@@ -217,7 +249,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
mediaContainer.appendChild(nameLabel);
|
mediaContainer.appendChild(nameLabel);
|
||||||
this.element.appendChild(mediaContainer);
|
this.element.appendChild(mediaContainer);
|
||||||
|
|
||||||
// 添加淡入效果
|
// Add fade-in effect
|
||||||
this.element.style.opacity = '0';
|
this.element.style.opacity = '0';
|
||||||
this.element.style.display = 'block';
|
this.element.style.display = 'block';
|
||||||
this.position(x, y);
|
this.position(x, y);
|
||||||
@@ -232,20 +264,20 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
position(x, y) {
|
position(x, y) {
|
||||||
// 确保预览框不超出视窗边界
|
// Ensure preview box doesn't exceed viewport boundaries
|
||||||
const rect = this.element.getBoundingClientRect();
|
const rect = this.element.getBoundingClientRect();
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
let left = x + 10; // 默认在鼠标右侧偏移10px
|
let left = x + 10; // Default 10px offset to the right of mouse
|
||||||
let top = y + 10; // 默认在鼠标下方偏移10px
|
let top = y + 10; // Default 10px offset below mouse
|
||||||
|
|
||||||
// 检查右边界
|
// Check right boundary
|
||||||
if (left + rect.width > viewportWidth) {
|
if (left + rect.width > viewportWidth) {
|
||||||
left = x - rect.width - 10;
|
left = x - rect.width - 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查下边界
|
// Check bottom boundary
|
||||||
if (top + rect.height > viewportHeight) {
|
if (top + rect.height > viewportHeight) {
|
||||||
top = y - rect.height - 10;
|
top = y - rect.height - 10;
|
||||||
}
|
}
|
||||||
@@ -257,13 +289,13 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hide() {
|
hide() {
|
||||||
// 使用淡出效果
|
// Use fade-out effect
|
||||||
if (this.element.style.display === 'block') {
|
if (this.element.style.display === 'block') {
|
||||||
this.element.style.opacity = '0';
|
this.element.style.opacity = '0';
|
||||||
this.hideTimeout = setTimeout(() => {
|
this.hideTimeout = setTimeout(() => {
|
||||||
this.element.style.display = 'none';
|
this.element.style.display = 'none';
|
||||||
this.currentLora = null;
|
this.currentLora = null;
|
||||||
// 停止视频播放
|
// Stop video playback
|
||||||
const video = this.element.querySelector('video');
|
const video = this.element.querySelector('video');
|
||||||
if (video) {
|
if (video) {
|
||||||
video.pause();
|
video.pause();
|
||||||
@@ -277,14 +309,14 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
if (this.hideTimeout) {
|
if (this.hideTimeout) {
|
||||||
clearTimeout(this.hideTimeout);
|
clearTimeout(this.hideTimeout);
|
||||||
}
|
}
|
||||||
// 移除所有事件监听器
|
// Remove all event listeners
|
||||||
document.removeEventListener('click', () => this.hide());
|
document.removeEventListener('click', () => this.hide());
|
||||||
document.removeEventListener('scroll', () => this.hide(), true);
|
document.removeEventListener('scroll', () => this.hide(), true);
|
||||||
this.element.remove();
|
this.element.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建预览tooltip实例
|
// Create preview tooltip instance
|
||||||
const previewTooltip = new PreviewTooltip();
|
const previewTooltip = new PreviewTooltip();
|
||||||
|
|
||||||
// Function to create menu item
|
// Function to create menu item
|
||||||
@@ -357,7 +389,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
padding: '4px 0',
|
padding: '4px 0',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
|
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
|
||||||
minWidth: '180px',
|
minWidth: '180px',
|
||||||
});
|
});
|
||||||
|
|
||||||
// View on Civitai option with globe icon
|
// View on Civitai option with globe icon
|
||||||
@@ -447,7 +479,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
});
|
});
|
||||||
|
|
||||||
menu.appendChild(viewOnCivitaiOption); // Add the new menu option
|
menu.appendChild(viewOnCivitaiOption);
|
||||||
menu.appendChild(deleteOption);
|
menu.appendChild(deleteOption);
|
||||||
menu.appendChild(separator);
|
menu.appendChild(separator);
|
||||||
menu.appendChild(saveOption);
|
menu.appendChild(saveOption);
|
||||||
@@ -483,12 +515,16 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
padding: "20px 0",
|
padding: "20px 0",
|
||||||
color: "rgba(226, 232, 240, 0.8)",
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
userSelect: "none", // Add this line to prevent text selection
|
userSelect: "none",
|
||||||
WebkitUserSelect: "none", // For Safari support
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: "none", // For Firefox support
|
MozUserSelect: "none",
|
||||||
msUserSelect: "none", // For IE/Edge support
|
msUserSelect: "none",
|
||||||
|
width: "100%"
|
||||||
});
|
});
|
||||||
container.appendChild(emptyMessage);
|
container.appendChild(emptyMessage);
|
||||||
|
|
||||||
|
// Set fixed height for empty state
|
||||||
|
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +537,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "4px 8px",
|
padding: "4px 8px",
|
||||||
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
borderBottom: "1px solid rgba(226, 232, 240, 0.2)",
|
||||||
marginBottom: "8px"
|
marginBottom: "5px"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add toggle all control
|
// Add toggle all control
|
||||||
@@ -522,10 +558,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
color: "rgba(226, 232, 240, 0.8)",
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
marginLeft: "8px",
|
marginLeft: "8px",
|
||||||
userSelect: "none", // Add this line to prevent text selection
|
userSelect: "none",
|
||||||
WebkitUserSelect: "none", // For Safari support
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: "none", // For Firefox support
|
MozUserSelect: "none",
|
||||||
msUserSelect: "none", // For IE/Edge support
|
msUserSelect: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleContainer = document.createElement("div");
|
const toggleContainer = document.createElement("div");
|
||||||
@@ -543,10 +579,10 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
color: "rgba(226, 232, 240, 0.8)",
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
marginRight: "8px",
|
marginRight: "8px",
|
||||||
userSelect: "none", // Add this line to prevent text selection
|
userSelect: "none",
|
||||||
WebkitUserSelect: "none", // For Safari support
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: "none", // For Firefox support
|
MozUserSelect: "none",
|
||||||
msUserSelect: "none", // For IE/Edge support
|
msUserSelect: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
header.appendChild(toggleContainer);
|
header.appendChild(toggleContainer);
|
||||||
@@ -563,11 +599,11 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "8px",
|
padding: "6px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
|
backgroundColor: active ? "rgba(45, 55, 72, 0.7)" : "rgba(35, 40, 50, 0.5)",
|
||||||
transition: "all 0.2s ease",
|
transition: "all 0.2s ease",
|
||||||
marginBottom: "6px",
|
marginBottom: "4px",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create toggle for this lora
|
// Create toggle for this lora
|
||||||
@@ -595,11 +631,11 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
|
color: active ? "rgba(226, 232, 240, 0.9)" : "rgba(226, 232, 240, 0.6)",
|
||||||
fontSize: "13px",
|
fontSize: "13px",
|
||||||
cursor: "pointer", // Add pointer cursor to indicate hoverable area
|
cursor: "pointer",
|
||||||
userSelect: "none", // Add this line to prevent text selection
|
userSelect: "none",
|
||||||
WebkitUserSelect: "none", // For Safari support
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: "none", // For Firefox support
|
MozUserSelect: "none",
|
||||||
msUserSelect: "none", // For IE/Edge support
|
msUserSelect: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Move preview tooltip events to nameEl instead of loraEl
|
// Move preview tooltip events to nameEl instead of loraEl
|
||||||
@@ -645,7 +681,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||||
|
|
||||||
if (loraIndex >= 0) {
|
if (loraIndex >= 0) {
|
||||||
lorasData[loraIndex].strength = (lorasData[loraIndex].strength - 0.05).toFixed(2);
|
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
|
||||||
|
|
||||||
const newValue = formatLoraValue(lorasData);
|
const newValue = formatLoraValue(lorasData);
|
||||||
widget.value = newValue;
|
widget.value = newValue;
|
||||||
@@ -669,7 +705,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
outline: "none",
|
outline: "none",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加hover效果
|
// Add hover effect
|
||||||
strengthEl.addEventListener('mouseenter', () => {
|
strengthEl.addEventListener('mouseenter', () => {
|
||||||
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
|
strengthEl.style.border = "1px solid rgba(226, 232, 240, 0.2)";
|
||||||
});
|
});
|
||||||
@@ -680,11 +716,11 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理焦点
|
// Handle focus
|
||||||
strengthEl.addEventListener('focus', () => {
|
strengthEl.addEventListener('focus', () => {
|
||||||
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
|
strengthEl.style.border = "1px solid rgba(66, 153, 225, 0.6)";
|
||||||
strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
|
strengthEl.style.background = "rgba(0, 0, 0, 0.2)";
|
||||||
// 自动选中所有内容
|
// Auto-select all content
|
||||||
strengthEl.select();
|
strengthEl.select();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -693,29 +729,29 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
strengthEl.style.background = "none";
|
strengthEl.style.background = "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理输入变化
|
// Handle input changes
|
||||||
strengthEl.addEventListener('change', () => {
|
strengthEl.addEventListener('change', () => {
|
||||||
let newValue = parseFloat(strengthEl.value);
|
let newValue = parseFloat(strengthEl.value);
|
||||||
|
|
||||||
// 验证输入
|
// Validate input
|
||||||
if (isNaN(newValue)) {
|
if (isNaN(newValue)) {
|
||||||
newValue = 1.0;
|
newValue = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新数值
|
// Update value
|
||||||
const lorasData = parseLoraValue(widget.value);
|
const lorasData = parseLoraValue(widget.value);
|
||||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||||
|
|
||||||
if (loraIndex >= 0) {
|
if (loraIndex >= 0) {
|
||||||
lorasData[loraIndex].strength = newValue.toFixed(2);
|
lorasData[loraIndex].strength = newValue.toFixed(2);
|
||||||
|
|
||||||
// 更新值并触发回调
|
// Update value and trigger callback
|
||||||
const newLorasValue = formatLoraValue(lorasData);
|
const newLorasValue = formatLoraValue(lorasData);
|
||||||
widget.value = newLorasValue;
|
widget.value = newLorasValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 处理按键事件
|
// Handle key events
|
||||||
strengthEl.addEventListener('keydown', (e) => {
|
strengthEl.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
strengthEl.blur();
|
strengthEl.blur();
|
||||||
@@ -757,13 +793,17 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
container.appendChild(loraEl);
|
container.appendChild(loraEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate height based on number of loras and fixed sizes
|
||||||
|
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (lorasData.length * LORA_ENTRY_HEIGHT);
|
||||||
|
updateWidgetHeight(calculatedHeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store the value in a variable to avoid recursion
|
// Store the value in a variable to avoid recursion
|
||||||
let widgetValue = defaultValue;
|
let widgetValue = defaultValue;
|
||||||
|
|
||||||
// Create widget with initial properties
|
// Create widget with new DOM Widget API
|
||||||
const widget = node.addDOMWidget(name, "loras", container, {
|
const widget = node.addDOMWidget(name, "custom", container, {
|
||||||
getValue: function() {
|
getValue: function() {
|
||||||
return widgetValue;
|
return widgetValue;
|
||||||
},
|
},
|
||||||
@@ -778,29 +818,28 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
|
|
||||||
widgetValue = uniqueValue;
|
widgetValue = uniqueValue;
|
||||||
renderLoras(widgetValue, widget);
|
renderLoras(widgetValue, widget);
|
||||||
|
|
||||||
// Update container height after rendering
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const minHeight = this.getMinHeight();
|
|
||||||
container.style.height = `${minHeight}px`;
|
|
||||||
|
|
||||||
// Force node to update size
|
|
||||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
|
||||||
node.setDirtyCanvas(true, true);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
getMinHeight: function() {
|
getMinHeight: function() {
|
||||||
// Calculate height based on content
|
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
|
||||||
const lorasCount = parseLoraValue(widgetValue).length;
|
|
||||||
return Math.max(
|
|
||||||
100,
|
|
||||||
lorasCount > 0 ? 60 + lorasCount * 44 : 60
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
getMaxHeight: function() {
|
||||||
|
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
|
||||||
|
},
|
||||||
|
getHeight: function() {
|
||||||
|
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
|
||||||
|
},
|
||||||
|
hideOnZoom: true,
|
||||||
|
selectOn: ['click', 'focus'],
|
||||||
|
afterResize: function(node) {
|
||||||
|
// Re-render after node resize
|
||||||
|
if (this.value && this.value.length > 0) {
|
||||||
|
renderLoras(this.value, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
widget.value = defaultValue;
|
widget.value = defaultValue;
|
||||||
|
|
||||||
widget.callback = callback;
|
widget.callback = callback;
|
||||||
|
|
||||||
widget.serializeValue = () => {
|
widget.serializeValue = () => {
|
||||||
@@ -816,7 +855,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
|||||||
previewTooltip.cleanup();
|
previewTooltip.cleanup();
|
||||||
};
|
};
|
||||||
|
|
||||||
return { minWidth: 400, minHeight: 200, widget };
|
return { minWidth: 400, minHeight: defaultHeight, widget };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to directly save the recipe without dialog
|
// Function to directly save the recipe without dialog
|
||||||
@@ -824,7 +863,6 @@ async function saveRecipeDirectly(widget) {
|
|||||||
try {
|
try {
|
||||||
// Get the workflow data from the ComfyUI app
|
// Get the workflow data from the ComfyUI app
|
||||||
const prompt = await app.graphToPrompt();
|
const prompt = await app.graphToPrompt();
|
||||||
console.log('Prompt:', prompt);
|
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
if (app && app.extensionManager && app.extensionManager.toast) {
|
if (app && app.extensionManager && app.extensionManager.toast) {
|
||||||
@@ -879,4 +917,4 @@ async function saveRecipeDirectly(widget) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,20 +2,36 @@ export function addTagsWidget(node, name, opts, callback) {
|
|||||||
// Create container for tags
|
// Create container for tags
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.className = "comfy-tags-container";
|
container.className = "comfy-tags-container";
|
||||||
|
|
||||||
|
// Set initial height
|
||||||
|
const defaultHeight = 150;
|
||||||
|
container.style.setProperty('--comfy-widget-min-height', `${defaultHeight}px`);
|
||||||
|
container.style.setProperty('--comfy-widget-max-height', `${defaultHeight * 2}px`);
|
||||||
|
container.style.setProperty('--comfy-widget-height', `${defaultHeight}px`);
|
||||||
|
|
||||||
Object.assign(container.style, {
|
Object.assign(container.style, {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: "4px", // 从8px减小到4px
|
gap: "4px",
|
||||||
padding: "6px",
|
padding: "6px",
|
||||||
minHeight: "30px",
|
backgroundColor: "rgba(40, 44, 52, 0.6)",
|
||||||
backgroundColor: "rgba(40, 44, 52, 0.6)", // Darker, more modern background
|
borderRadius: "6px",
|
||||||
borderRadius: "6px", // Slightly larger radius
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
overflow: "auto",
|
||||||
|
alignItems: "flex-start" // Ensure tags align at the top of each row
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize default value as array
|
// Initialize default value as array
|
||||||
const initialTagsData = opts?.defaultVal || [];
|
const initialTagsData = opts?.defaultVal || [];
|
||||||
|
|
||||||
|
// Fixed sizes for tag elements to avoid zoom-related calculation issues
|
||||||
|
const TAG_HEIGHT = 26; // Adjusted height of a single tag including margins
|
||||||
|
const TAGS_PER_ROW = 3; // Approximate number of tags per row
|
||||||
|
const ROW_GAP = 2; // Reduced gap between rows
|
||||||
|
const CONTAINER_PADDING = 12; // Top and bottom padding
|
||||||
|
const EMPTY_CONTAINER_HEIGHT = 60; // Height when no tags are present
|
||||||
|
|
||||||
// Function to render tags from array data
|
// Function to render tags from array data
|
||||||
const renderTags = (tagsData, widget) => {
|
const renderTags = (tagsData, widget) => {
|
||||||
// Clear existing tags
|
// Clear existing tags
|
||||||
@@ -38,11 +54,28 @@ export function addTagsWidget(node, name, opts, callback) {
|
|||||||
WebkitUserSelect: "none",
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: "none",
|
MozUserSelect: "none",
|
||||||
msUserSelect: "none",
|
msUserSelect: "none",
|
||||||
|
width: "100%"
|
||||||
});
|
});
|
||||||
container.appendChild(emptyMessage);
|
container.appendChild(emptyMessage);
|
||||||
|
|
||||||
|
// Set fixed height for empty state
|
||||||
|
updateWidgetHeight(EMPTY_CONTAINER_HEIGHT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a row container approach for better layout control
|
||||||
|
let rowContainer = document.createElement("div");
|
||||||
|
rowContainer.className = "comfy-tags-row";
|
||||||
|
Object.assign(rowContainer.style, {
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: "4px",
|
||||||
|
width: "100%",
|
||||||
|
marginBottom: "2px" // Small gap between rows
|
||||||
|
});
|
||||||
|
container.appendChild(rowContainer);
|
||||||
|
|
||||||
|
let tagCount = 0;
|
||||||
normalizedTags.forEach((tagData, index) => {
|
normalizedTags.forEach((tagData, index) => {
|
||||||
const { text, active } = tagData;
|
const { text, active } = tagData;
|
||||||
const tagEl = document.createElement("div");
|
const tagEl = document.createElement("div");
|
||||||
@@ -65,44 +98,75 @@ export function addTagsWidget(node, name, opts, callback) {
|
|||||||
widget.value = updatedTags;
|
widget.value = updatedTags;
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(tagEl);
|
rowContainer.appendChild(tagEl);
|
||||||
|
tagCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate height based on number of tags and fixed sizes
|
||||||
|
const tagsCount = normalizedTags.length;
|
||||||
|
const rows = Math.ceil(tagsCount / TAGS_PER_ROW);
|
||||||
|
const calculatedHeight = CONTAINER_PADDING + (rows * TAG_HEIGHT) + ((rows - 1) * ROW_GAP);
|
||||||
|
|
||||||
|
// Update widget height with calculated value
|
||||||
|
updateWidgetHeight(calculatedHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to update widget height consistently
|
||||||
|
const updateWidgetHeight = (height) => {
|
||||||
|
// Ensure minimum height
|
||||||
|
const finalHeight = Math.max(defaultHeight, height);
|
||||||
|
|
||||||
|
// Update CSS variables
|
||||||
|
container.style.setProperty('--comfy-widget-min-height', `${finalHeight}px`);
|
||||||
|
container.style.setProperty('--comfy-widget-height', `${finalHeight}px`);
|
||||||
|
|
||||||
|
// Force node to update size after a short delay to ensure DOM is updated
|
||||||
|
if (node) {
|
||||||
|
setTimeout(() => {
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to update tag style based on active state
|
// Helper function to update tag style based on active state
|
||||||
function updateTagStyle(tagEl, active) {
|
function updateTagStyle(tagEl, active) {
|
||||||
const baseStyles = {
|
const baseStyles = {
|
||||||
padding: "4px 12px", // 垂直内边距从6px减小到4px
|
padding: "4px 10px", // Slightly reduced horizontal padding
|
||||||
borderRadius: "6px", // Matching container radius
|
borderRadius: "6px",
|
||||||
maxWidth: "200px", // Increased max width
|
maxWidth: "200px",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
fontSize: "13px", // Slightly larger font
|
fontSize: "13px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "all 0.2s ease", // Smoother transition
|
transition: "all 0.2s ease",
|
||||||
border: "1px solid transparent",
|
border: "1px solid transparent",
|
||||||
display: "inline-block",
|
display: "inline-flex", // Changed to inline-flex for better text alignment
|
||||||
|
alignItems: "center", // Center text vertically
|
||||||
|
justifyContent: "center", // Center text horizontally
|
||||||
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
|
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
|
||||||
margin: "2px", // 从4px减小到2px
|
margin: "1px", // Reduced margin
|
||||||
userSelect: "none", // Add this line to prevent text selection
|
userSelect: "none",
|
||||||
WebkitUserSelect: "none", // For Safari support
|
WebkitUserSelect: "none",
|
||||||
MozUserSelect: "none", // For Firefox support
|
MozUserSelect: "none",
|
||||||
msUserSelect: "none", // For IE/Edge support
|
msUserSelect: "none",
|
||||||
|
height: "20px", // Slightly increased height to prevent text cutoff
|
||||||
|
minHeight: "20px", // Ensure consistent height
|
||||||
|
boxSizing: "border-box" // Ensure padding doesn't affect the overall size
|
||||||
};
|
};
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
Object.assign(tagEl.style, {
|
Object.assign(tagEl.style, {
|
||||||
...baseStyles,
|
...baseStyles,
|
||||||
backgroundColor: "rgba(66, 153, 225, 0.9)", // Modern blue
|
backgroundColor: "rgba(66, 153, 225, 0.9)",
|
||||||
color: "white",
|
color: "white",
|
||||||
borderColor: "rgba(66, 153, 225, 0.9)",
|
borderColor: "rgba(66, 153, 225, 0.9)",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
Object.assign(tagEl.style, {
|
Object.assign(tagEl.style, {
|
||||||
...baseStyles,
|
...baseStyles,
|
||||||
backgroundColor: "rgba(45, 55, 72, 0.7)", // Darker inactive state
|
backgroundColor: "rgba(45, 55, 72, 0.7)",
|
||||||
color: "rgba(226, 232, 240, 0.8)", // Lighter text for contrast
|
color: "rgba(226, 232, 240, 0.8)",
|
||||||
borderColor: "rgba(226, 232, 240, 0.2)",
|
borderColor: "rgba(226, 232, 240, 0.2)",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -122,72 +186,48 @@ export function addTagsWidget(node, name, opts, callback) {
|
|||||||
// Store the value as array
|
// Store the value as array
|
||||||
let widgetValue = initialTagsData;
|
let widgetValue = initialTagsData;
|
||||||
|
|
||||||
// Create widget with initial properties
|
// Create widget with new DOM Widget API
|
||||||
const widget = node.addDOMWidget(name, "tags", container, {
|
const widget = node.addDOMWidget(name, "custom", container, {
|
||||||
getValue: function() {
|
getValue: function() {
|
||||||
return widgetValue;
|
return widgetValue;
|
||||||
},
|
},
|
||||||
setValue: function(v) {
|
setValue: function(v) {
|
||||||
widgetValue = v;
|
widgetValue = v;
|
||||||
renderTags(widgetValue, widget);
|
renderTags(widgetValue, widget);
|
||||||
|
|
||||||
// Update container height after rendering
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const minHeight = this.getMinHeight();
|
|
||||||
container.style.height = `${minHeight}px`;
|
|
||||||
|
|
||||||
// Force node to update size
|
|
||||||
node.setSize([node.size[0], node.computeSize()[1]]);
|
|
||||||
node.setDirtyCanvas(true, true);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
getMinHeight: function() {
|
getMinHeight: function() {
|
||||||
const minHeight = 150;
|
return parseInt(container.style.getPropertyValue('--comfy-widget-min-height')) || defaultHeight;
|
||||||
// If no tags or only showing the empty message, return a minimum height
|
|
||||||
if (widgetValue.length === 0) {
|
|
||||||
return minHeight; // Height for empty state with message
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all tag elements
|
|
||||||
const tagElements = container.querySelectorAll('.comfy-tag');
|
|
||||||
|
|
||||||
if (tagElements.length === 0) {
|
|
||||||
return minHeight; // Fallback if elements aren't rendered yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the actual height based on tag positions
|
|
||||||
let maxBottom = 0;
|
|
||||||
|
|
||||||
tagElements.forEach(tag => {
|
|
||||||
const rect = tag.getBoundingClientRect();
|
|
||||||
const tagBottom = rect.bottom - container.getBoundingClientRect().top;
|
|
||||||
maxBottom = Math.max(maxBottom, tagBottom);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add padding (top and bottom padding of container)
|
|
||||||
const computedStyle = window.getComputedStyle(container);
|
|
||||||
const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
|
|
||||||
const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
|
|
||||||
|
|
||||||
// Add extra buffer for potential wrapping issues and to ensure no clipping
|
|
||||||
const extraBuffer = 20;
|
|
||||||
|
|
||||||
// Round up to nearest 5px for clean sizing and ensure minimum height
|
|
||||||
return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
|
|
||||||
},
|
},
|
||||||
|
getMaxHeight: function() {
|
||||||
|
return parseInt(container.style.getPropertyValue('--comfy-widget-max-height')) || defaultHeight * 2;
|
||||||
|
},
|
||||||
|
getHeight: function() {
|
||||||
|
return parseInt(container.style.getPropertyValue('--comfy-widget-height')) || defaultHeight;
|
||||||
|
},
|
||||||
|
hideOnZoom: true,
|
||||||
|
selectOn: ['click', 'focus'],
|
||||||
|
afterResize: function(node) {
|
||||||
|
// Re-render tags after node resize
|
||||||
|
if (this.value && this.value.length > 0) {
|
||||||
|
renderTags(this.value, this);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
widget.value = initialTagsData;
|
widget.value = initialTagsData;
|
||||||
|
|
||||||
|
// Set callback
|
||||||
widget.callback = callback;
|
widget.callback = callback;
|
||||||
|
|
||||||
|
// Add serialization method to avoid ComfyUI serialization issues
|
||||||
widget.serializeValue = () => {
|
widget.serializeValue = () => {
|
||||||
// Add dummy items to avoid the 2-element serialization issue, a bug in comfyui
|
// Add dummy items to avoid the 2-element serialization issue
|
||||||
return [...widgetValue,
|
return [...widgetValue,
|
||||||
{ text: "__dummy_item__", active: false, _isDummy: true },
|
{ text: "__dummy_item__", active: false, _isDummy: true },
|
||||||
{ text: "__dummy_item__", active: false, _isDummy: true }
|
{ text: "__dummy_item__", active: false, _isDummy: true }
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
return { minWidth: 300, minHeight: 150, widget };
|
return { minWidth: 300, minHeight: defaultHeight, widget };
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
import { api } from "../../scripts/api.js";
|
import { api } from "../../scripts/api.js";
|
||||||
import { addTagsWidget } from "./tags_widget.js";
|
import { CONVERTED_TYPE, dynamicImportByVersion } from "./utils.js";
|
||||||
|
|
||||||
const CONVERTED_TYPE = 'converted-widget'
|
// Function to get the appropriate tags widget based on ComfyUI version
|
||||||
|
async function getTagsWidgetModule() {
|
||||||
|
return await dynamicImportByVersion("./tags_widget.js", "./legacy_tags_widget.js");
|
||||||
|
}
|
||||||
|
|
||||||
// TriggerWordToggle extension for ComfyUI
|
// TriggerWordToggle extension for ComfyUI
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
@@ -26,7 +29,11 @@ app.registerExtension({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait for node to be properly initialized
|
// Wait for node to be properly initialized
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(async () => {
|
||||||
|
// Dynamically import the appropriate tags widget module
|
||||||
|
const tagsModule = await getTagsWidgetModule();
|
||||||
|
const { addTagsWidget } = tagsModule;
|
||||||
|
|
||||||
// Get the widget object directly from the returned object
|
// Get the widget object directly from the returned object
|
||||||
const result = addTagsWidget(node, "toggle_trigger_words", {
|
const result = addTagsWidget(node, "toggle_trigger_words", {
|
||||||
defaultVal: []
|
defaultVal: []
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
export const CONVERTED_TYPE = 'converted-widget';
|
export const CONVERTED_TYPE = 'converted-widget';
|
||||||
|
|
||||||
|
export function getComfyUIFrontendVersion() {
|
||||||
|
return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamically import the appropriate widget based on app version
|
||||||
|
export async function dynamicImportByVersion(latestModulePath, legacyModulePath) {
|
||||||
|
// Parse app version and compare with 1.12.6 (version when tags widget API changed)
|
||||||
|
const currentVersion = getComfyUIFrontendVersion();
|
||||||
|
const versionParts = currentVersion.split('.').map(part => parseInt(part, 10));
|
||||||
|
const requiredVersion = [1, 12, 6];
|
||||||
|
|
||||||
|
// Compare version numbers
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (versionParts[i] > requiredVersion[i]) {
|
||||||
|
console.log(`Using latest widget: ${latestModulePath}`);
|
||||||
|
return import(latestModulePath);
|
||||||
|
} else if (versionParts[i] < requiredVersion[i]) {
|
||||||
|
console.log(`Using legacy widget: ${legacyModulePath}`);
|
||||||
|
return import(legacyModulePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, versions are equal, use the latest module
|
||||||
|
console.log(`Using latest widget: ${latestModulePath}`);
|
||||||
|
return import(latestModulePath);
|
||||||
|
}
|
||||||
|
|
||||||
export function hideWidgetForGood(node, widget, suffix = "") {
|
export function hideWidgetForGood(node, widget, suffix = "") {
|
||||||
widget.origType = widget.type;
|
widget.origType = widget.type;
|
||||||
widget.origComputeSize = widget.computeSize;
|
widget.origComputeSize = widget.computeSize;
|
||||||
|
|||||||
Reference in New Issue
Block a user