diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..c2f81e86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +### **LoRA Manager Version** +- Version: `vX.X.X` + +### **Environment Information** +- **Operating System**: (e.g., Windows 11, macOS Ventura, Ubuntu 22.04) +- **Browser & Version**: (e.g., Chrome 120.0.0, Edge 115.0.0) + +### **Issue Description** +- Describe the issue in detail. + +### **Steps to Reproduce** +1. Open LoRA Manager in [your browser]. +2. Perform [specific action]. +3. Observe the issue. + +### **Expected Behavior** +- What did you expect to happen? + +### **Screenshots** *(If applicable)* +- Upload screenshots or screen recordings. + +### **Logs** +- Provide the **ComfyUI startup log** and any relevant error messages. +- Check the browser developer console (F12 → Console tab) and attach any errors. + +### **Additional Context** *(Optional)* +- Any other relevant details. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..bbcbbe7d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/README.md b/README.md index e5f6d855..237e32e1 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,21 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration ## Release Notes +### v0.7.37 +* Added NSFW content control settings (blur mature content and SFW-only filter) +* Implemented intelligent blur effects for previews and showcase media +* Added manual content rating option through context menu +* Enhanced user experience with configurable content visibility +* Fixed various bugs and improved stability + +### v0.7.36 +* Enhanced LoRA details view with model descriptions and tags display +* Added tag filtering system for improved model discovery +* Implemented editable trigger words functionality +* Improved TriggerWord Toggle node with new group mode option for granular control +* Added new Lora Stacker node with cross-compatibility support (works with efficiency nodes, ComfyRoll, easy-use, etc.) +* Fixed several bugs + ### v0.7.35-beta * Added base model filtering * Implemented bulk operations (copy syntax, move multiple LoRAs) diff --git a/__init__.py b/__init__.py index db97c0e1..4adea9d1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,10 +1,12 @@ from .py.lora_manager import LoraManager from .py.nodes.lora_loader import LoraManagerLoader from .py.nodes.trigger_word_toggle import TriggerWordToggle +from .py.nodes.lora_stacker import LoraStacker NODE_CLASS_MAPPINGS = { LoraManagerLoader.NAME: LoraManagerLoader, - TriggerWordToggle.NAME: TriggerWordToggle + TriggerWordToggle.NAME: TriggerWordToggle, + LoraStacker.NAME: LoraStacker } WEB_DIRECTORY = "./web/comfyui" diff --git a/py/nodes/lora_loader.py b/py/nodes/lora_loader.py index 60c91472..ec287721 100644 --- a/py/nodes/lora_loader.py +++ b/py/nodes/lora_loader.py @@ -8,7 +8,7 @@ from .utils import FlexibleOptionalInputType, any_type class LoraManagerLoader: NAME = "Lora Loader (LoraManager)" - CATEGORY = "loaders" + CATEGORY = "Lora Manager/loaders" @classmethod def INPUT_TYPES(cls): @@ -49,11 +49,32 @@ class LoraManagerLoader: return relative_path, trigger_words return lora_name, [] # Fallback if not found + def extract_lora_name(self, lora_path): + """Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')""" + # Get the basename without extension + basename = os.path.basename(lora_path) + return os.path.splitext(basename)[0] + def load_loras(self, model, clip, text, **kwargs): - """Loads multiple LoRAs based on the kwargs input.""" + """Loads multiple LoRAs based on the kwargs input and lora_stack.""" loaded_loras = [] all_trigger_words = [] + lora_stack = kwargs.get('lora_stack', None) + # First process lora_stack if available + if lora_stack: + for lora_path, model_strength, clip_strength in lora_stack: + # Apply the LoRA using the provided path and strengths + model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength) + + # Extract lora name for trigger words lookup + lora_name = self.extract_lora_name(lora_path) + _, trigger_words = asyncio.run(self.get_lora_info(lora_name)) + + all_trigger_words.extend(trigger_words) + loaded_loras.append(f"{lora_name}: {model_strength}") + + # Then process loras from kwargs if 'loras' in kwargs: for lora in kwargs['loras']: if not lora.get('active', False): @@ -72,6 +93,7 @@ class LoraManagerLoader: # Add trigger words to collection all_trigger_words.extend(trigger_words) - trigger_words_text = ", ".join(all_trigger_words) if all_trigger_words else "" + # use ',, ' to separate trigger words for group mode + trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else "" return (model, clip, trigger_words_text) \ No newline at end of file diff --git a/py/nodes/lora_stacker.py b/py/nodes/lora_stacker.py new file mode 100644 index 00000000..8535ce17 --- /dev/null +++ b/py/nodes/lora_stacker.py @@ -0,0 +1,91 @@ +from comfy.comfy_types import IO # type: ignore +from ..services.lora_scanner import LoraScanner +from ..config import config +import asyncio +import os +from .utils import FlexibleOptionalInputType, any_type + +class LoraStacker: + NAME = "Lora Stacker (LoraManager)" + CATEGORY = "Lora Manager/stackers" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "text": (IO.STRING, { + "multiline": True, + "dynamicPrompts": True, + "tooltip": "Format: separated by spaces or punctuation", + "placeholder": "LoRA syntax input: " + }), + }, + "optional": FlexibleOptionalInputType(any_type), + } + + RETURN_TYPES = ("LORA_STACK", IO.STRING) + RETURN_NAMES = ("LORA_STACK", "trigger_words") + FUNCTION = "stack_loras" + + async def get_lora_info(self, lora_name): + """Get the lora path and trigger words from cache""" + scanner = await LoraScanner.get_instance() + cache = await scanner.get_cached_data() + + for item in cache.raw_data: + if item.get('file_name') == lora_name: + file_path = item.get('file_path') + if file_path: + for root in config.loras_roots: + root = root.replace(os.sep, '/') + if file_path.startswith(root): + relative_path = os.path.relpath(file_path, root).replace(os.sep, '/') + # Get trigger words from civitai metadata + civitai = item.get('civitai', {}) + trigger_words = civitai.get('trainedWords', []) if civitai else [] + return relative_path, trigger_words + return lora_name, [] # Fallback if not found + + def extract_lora_name(self, lora_path): + """Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')""" + # Get the basename without extension + basename = os.path.basename(lora_path) + return os.path.splitext(basename)[0] + + def stack_loras(self, text, **kwargs): + """Stacks multiple LoRAs based on the kwargs input without loading them.""" + stack = [] + all_trigger_words = [] + + # Process existing lora_stack if available + lora_stack = kwargs.get('lora_stack', None) + if lora_stack: + stack.extend(lora_stack) + # Get trigger words from existing stack entries + for lora_path, _, _ in lora_stack: + lora_name = self.extract_lora_name(lora_path) + _, trigger_words = asyncio.run(self.get_lora_info(lora_name)) + all_trigger_words.extend(trigger_words) + + if 'loras' in kwargs: + for lora in kwargs['loras']: + if not lora.get('active', False): + continue + + lora_name = lora['name'] + model_strength = float(lora['strength']) + clip_strength = model_strength # Using same strength for both as in the original loader + + # Get lora path and trigger words + lora_path, trigger_words = asyncio.run(self.get_lora_info(lora_name)) + + # Add to stack without loading + stack.append((lora_path, model_strength, clip_strength)) + + # Add trigger words to collection + all_trigger_words.extend(trigger_words) + + # use ',, ' to separate trigger words for group mode + trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else "" + + return (stack, trigger_words_text) diff --git a/py/nodes/trigger_word_toggle.py b/py/nodes/trigger_word_toggle.py index 3067d261..bdd771dd 100644 --- a/py/nodes/trigger_word_toggle.py +++ b/py/nodes/trigger_word_toggle.py @@ -1,17 +1,18 @@ import json +import re from server import PromptServer # type: ignore from .utils import FlexibleOptionalInputType, any_type class TriggerWordToggle: NAME = "TriggerWord Toggle (LoraManager)" - CATEGORY = "lora manager" + CATEGORY = "Lora Manager/utils" DESCRIPTION = "Toggle trigger words on/off" @classmethod def INPUT_TYPES(cls): return { "required": { - "trigger_words": ("STRING", {"defaultInput": True, "forceInput": True}), + "group_mode": ("BOOLEAN", {"default": True}), }, "optional": FlexibleOptionalInputType(any_type), "hidden": { @@ -23,7 +24,8 @@ class TriggerWordToggle: RETURN_NAMES = ("filtered_trigger_words",) FUNCTION = "process_trigger_words" - def process_trigger_words(self, trigger_words, id, **kwargs): + def process_trigger_words(self, id, group_mode, **kwargs): + trigger_words = kwargs.get("trigger_words", "") # Send trigger words to frontend PromptServer.instance.send_sync("trigger_word_update", { "id": id, @@ -41,20 +43,33 @@ class TriggerWordToggle: if isinstance(trigger_data, str): trigger_data = json.loads(trigger_data) - # Create dictionaries to track active state of words + # Create dictionaries to track active state of words or groups active_state = {item['text']: item.get('active', False) for item in trigger_data} - # Split original trigger words - original_words = [word.strip() for word in trigger_words.split(',')] - - # Filter words: keep those not in toggle_trigger_words or those that are active - filtered_words = [word for word in original_words if word not in active_state or active_state[word]] - - # Join them in the same format as input - if filtered_words: - filtered_triggers = ', '.join(filtered_words) + if group_mode: + # Split by two or more consecutive commas to get groups + groups = re.split(r',{2,}', trigger_words) + # Remove leading/trailing whitespace from each group + groups = [group.strip() for group in groups] + + # Filter groups: keep those not in toggle_trigger_words or those that are active + filtered_groups = [group for group in groups if group not in active_state or active_state[group]] + + if filtered_groups: + filtered_triggers = ', '.join(filtered_groups) + else: + filtered_triggers = "" else: - filtered_triggers = "" + # Original behavior for individual words mode + original_words = [word.strip() for word in trigger_words.split(',')] + # Filter out empty strings + original_words = [word for word in original_words if word] + filtered_words = [word for word in original_words if word not in active_state or active_state[word]] + + if filtered_words: + filtered_triggers = ', '.join(filtered_words) + else: + filtered_triggers = "" except Exception as e: print(f"Error processing trigger words: {e}") diff --git a/py/nodes/utils.py b/py/nodes/utils.py index 4c1884b1..89b96c97 100644 --- a/py/nodes/utils.py +++ b/py/nodes/utils.py @@ -4,6 +4,7 @@ class AnyType(str): def __ne__(self, __value: object) -> bool: return False +# Credit to Regis Gaughan, III (rgthree) class FlexibleOptionalInputType(dict): """A special class to make flexible nodes that pass data to our python handlers. diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 14c003e8..954aa1ca 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -4,6 +4,8 @@ import logging from aiohttp import web from typing import Dict, List +from ..utils.model_utils import determine_base_model + from ..services.file_monitor import LoraFileMonitor from ..services.download_manager import DownloadManager from ..services.civitai_client import CivitaiClient @@ -42,9 +44,11 @@ class ApiRoutes: app.router.add_post('/api/download-lora', routes.download_lora) app.router.add_post('/api/settings', routes.update_settings) app.router.add_post('/api/move_model', routes.move_model) + app.router.add_get('/api/lora-model-description', routes.get_lora_model_description) # Add new route app.router.add_post('/loras/api/save-metadata', routes.save_metadata) app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route app.router.add_post('/api/move_models_bulk', routes.move_models_bulk) + app.router.add_get('/api/top-tags', routes.get_top_tags) # Add new route for top tags app.router.add_get('/api/recipes', cls.handle_get_recipes) # Add update check routes @@ -132,6 +136,11 @@ class ApiRoutes: base_models = request.query.get('base_models', '').split(',') base_models = [model.strip() for model in base_models if model.strip()] + # Parse search options + search_filename = request.query.get('search_filename', 'true').lower() == 'true' + search_modelname = request.query.get('search_modelname', 'true').lower() == 'true' + search_tags = request.query.get('search_tags', 'false').lower() == 'true' + # Validate parameters if page < 1 or page_size < 1 or page_size > 100: return web.json_response({ @@ -143,6 +152,10 @@ class ApiRoutes: 'error': 'Invalid sort parameter' }, status=400) + # Parse tags filter parameter + tags = request.query.get('tags', '').split(',') + tags = [tag.strip() for tag in tags if tag.strip()] + # Get paginated data with search and filters result = await self.scanner.get_paginated_data( page=page, @@ -152,7 +165,13 @@ class ApiRoutes: search=search, fuzzy=fuzzy, recursive=recursive, - base_models=base_models # Pass base models filter + base_models=base_models, # Pass base models filter + tags=tags, # Add tags parameter + search_options={ + 'filename': search_filename, + 'modelname': search_modelname, + 'tags': search_tags + } ) # Format the response data @@ -185,12 +204,15 @@ class ApiRoutes: "model_name": lora["model_name"], "file_name": lora["file_name"], "preview_url": config.get_preview_static_url(lora["preview_url"]), + "preview_nsfw_level": lora.get("preview_nsfw_level", 0), "base_model": lora["base_model"], "folder": lora["folder"], "sha256": lora["sha256"], "file_path": lora["file_path"].replace(os.sep, "/"), "file_size": lora["size"], "modified": lora["modified"], + "tags": lora["tags"], + "modelDescription": lora["modelDescription"], "from_civitai": lora.get("from_civitai", True), "usage_tips": lora.get("usage_tips", ""), "notes": lora.get("notes", ""), @@ -333,8 +355,16 @@ class ApiRoutes: # Update model name if available if 'model' in civitai_metadata: - local_metadata['model_name'] = civitai_metadata['model'].get('name', - local_metadata.get('model_name')) + if civitai_metadata.get('model', {}).get('name'): + local_metadata['model_name'] = determine_base_model(civitai_metadata['model']['name']) + + # Fetch additional model metadata (description and tags) if we have model ID + model_id = civitai_metadata['modelId'] + if model_id: + model_metadata, _ = await client.get_model_metadata(str(model_id)) + if model_metadata: + local_metadata['modelDescription'] = model_metadata.get('description', '') + local_metadata['tags'] = model_metadata.get('tags', []) # Update base model local_metadata['base_model'] = civitai_metadata.get('baseModel') @@ -350,6 +380,7 @@ class ApiRoutes: if await client.download_preview_image(first_preview['url'], preview_path): local_metadata['preview_url'] = preview_path.replace(os.sep, '/') + local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0) # Save updated metadata with open(metadata_path, 'w', encoding='utf-8') as f: @@ -369,7 +400,7 @@ class ApiRoutes: # 准备要处理的 loras to_process = [ lora for lora in cache.raw_data - if lora.get('sha256') and not lora.get('civitai') and lora.get('from_civitai') + if lora.get('sha256') and (not lora.get('civitai') or 'id' not in lora.get('civitai')) and lora.get('from_civitai') # TODO: for lora not from CivitAI but added traineWords ] total_to_process = len(to_process) @@ -547,6 +578,8 @@ class ApiRoutes: # Validate and update settings if 'civitai_api_key' in data: settings.set('civitai_api_key', data['civitai_api_key']) + if 'show_only_sfw' in data: + settings.set('show_only_sfw', data['show_only_sfw']) return web.json_response({'success': True}) except Exception as e: @@ -602,8 +635,15 @@ class ApiRoutes: else: metadata = {} - # Update metadata with new values - metadata.update(metadata_updates) + # Handle nested updates (for civitai.trainedWords) + for key, value in metadata_updates.items(): + if isinstance(value, dict) and key in metadata and isinstance(metadata[key], dict): + # Deep update for nested dictionaries + for nested_key, nested_value in value.items(): + metadata[key][nested_key] = nested_value + else: + # Regular update for top-level keys + metadata[key] = value # Save updated metadata with open(metadata_path, 'w', encoding='utf-8') as f: @@ -694,6 +734,97 @@ class ApiRoutes: logger.error(f"Error moving models in bulk: {e}", exc_info=True) return web.Response(text=str(e), status=500) + async def get_lora_model_description(self, request: web.Request) -> web.Response: + """Get model description for a Lora model""" + try: + # Get parameters + model_id = request.query.get('model_id') + file_path = request.query.get('file_path') + + if not model_id: + return web.json_response({ + 'success': False, + 'error': 'Model ID is required' + }, status=400) + + # Check if we already have the description stored in metadata + description = None + tags = [] + if file_path: + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + if os.path.exists(metadata_path): + try: + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + description = metadata.get('modelDescription') + tags = metadata.get('tags', []) + except Exception as e: + logger.error(f"Error loading metadata from {metadata_path}: {e}") + + # If description is not in metadata, fetch from CivitAI + if not description: + logger.info(f"Fetching model metadata for model ID: {model_id}") + model_metadata, _ = await self.civitai_client.get_model_metadata(model_id) + + if model_metadata: + description = model_metadata.get('description') + tags = model_metadata.get('tags', []) + + # Save the metadata to file if we have a file path and got metadata + if file_path: + try: + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + if os.path.exists(metadata_path): + with open(metadata_path, 'r', encoding='utf-8') as f: + metadata = json.load(f) + + metadata['modelDescription'] = description + metadata['tags'] = tags + + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + logger.info(f"Saved model metadata to file for {file_path}") + except Exception as e: + logger.error(f"Error saving model metadata: {e}") + + return web.json_response({ + 'success': True, + 'description': description or "

No model description available.

", + 'tags': tags + }) + + except Exception as e: + logger.error(f"Error getting model metadata: {e}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': str(e) + }, status=500) + + async def get_top_tags(self, request: web.Request) -> web.Response: + """Handle request for top tags sorted by frequency""" + try: + # Parse query parameters + limit = int(request.query.get('limit', '20')) + + # Validate limit + if limit < 1 or limit > 100: + limit = 20 # Default to a reasonable limit + + # Get top tags + top_tags = await self.scanner.get_top_tags(limit) + + return web.json_response({ + 'success': True, + 'tags': top_tags + }) + + except Exception as e: + logger.error(f"Error getting top tags: {str(e)}", exc_info=True) + return web.json_response({ + 'success': False, + 'error': 'Internal server error' + }, status=500) + @staticmethod async def handle_get_recipes(request): """API endpoint for getting paginated recipes""" diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index 2c30968f..ef6aed55 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -28,10 +28,16 @@ class LoraRoutes: "model_name": lora["model_name"], "file_name": lora["file_name"], "preview_url": config.get_preview_static_url(lora["preview_url"]), + "preview_nsfw_level": lora.get("preview_nsfw_level", 0), "base_model": lora["base_model"], "folder": lora["folder"], "sha256": lora["sha256"], "file_path": lora["file_path"].replace(os.sep, "/"), + "size": lora["size"], + "tags": lora["tags"], + "modelDescription": lora["modelDescription"], + "usage_tips": lora["usage_tips"], + "notes": lora["notes"], "modified": lora["modified"], "from_civitai": lora.get("from_civitai", True), "civitai": self._filter_civitai_data(lora.get("civitai", {})) diff --git a/py/services/civitai_client.py b/py/services/civitai_client.py index 286fcd78..91387ebe 100644 --- a/py/services/civitai_client.py +++ b/py/services/civitai_client.py @@ -163,6 +163,53 @@ class CivitaiClient: logger.error(f"Error fetching model version info: {e}") return None + async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]: + """Fetch model metadata (description and tags) from Civitai API + + Args: + model_id: The Civitai model ID + + Returns: + Tuple[Optional[Dict], int]: A tuple containing: + - A dictionary with model metadata or None if not found + - The HTTP status code from the request + """ + try: + session = await self.session + headers = self._get_request_headers() + url = f"{self.base_url}/models/{model_id}" + + async with session.get(url, headers=headers) as response: + status_code = response.status + + if status_code != 200: + logger.warning(f"Failed to fetch model metadata: Status {status_code}") + return None, status_code + + data = await response.json() + + # Extract relevant metadata + metadata = { + "description": data.get("description") or "No model description available", + "tags": data.get("tags", []) + } + + if metadata["description"] or metadata["tags"]: + return metadata, status_code + else: + logger.warning(f"No metadata found for model {model_id}") + return None, status_code + + except Exception as e: + logger.error(f"Error fetching model metadata: {e}", exc_info=True) + return None, 0 + + # Keep old method for backward compatibility, delegating to the new one + async def get_model_description(self, model_id: str) -> Optional[str]: + """Fetch the model description from Civitai API (Legacy method)""" + metadata, _ = await self.get_model_metadata(model_id) + return metadata.get("description") if metadata else None + async def close(self): """Close the session if it exists""" if self._session is not None: diff --git a/py/services/download_manager.py b/py/services/download_manager.py index e8d72843..2dd0444f 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -51,6 +51,16 @@ class DownloadManager: # 5. 准备元数据 metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path) + # 5.1 获取并更新模型标签和描述信息 + model_id = version_info.get('modelId') + if model_id: + model_metadata, _ = await self.civitai_client.get_model_metadata(str(model_id)) + if model_metadata: + if model_metadata.get("tags"): + metadata.tags = model_metadata.get("tags", []) + if model_metadata.get("description"): + metadata.modelDescription = model_metadata.get("description", "") + # 6. 开始下载流程 result = await self._execute_download( download_url=download_url, @@ -86,6 +96,7 @@ class DownloadManager: preview_path = os.path.splitext(save_path)[0] + '.preview' + preview_ext if await self.civitai_client.download_preview_image(images[0]['url'], preview_path): metadata.preview_url = preview_path.replace(os.sep, '/') + metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0) with open(metadata_path, 'w', encoding='utf-8') as f: json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False) diff --git a/py/services/file_monitor.py b/py/services/file_monitor.py index 3bef2dd5..33b53448 100644 --- a/py/services/file_monitor.py +++ b/py/services/file_monitor.py @@ -98,6 +98,10 @@ class LoraFileHandler(FileSystemEventHandler): # Scan new file lora_data = await self.scanner.scan_single_lora(file_path) if lora_data: + # Update tags count + for tag in lora_data.get('tags', []): + self.scanner._tags_count[tag] = self.scanner._tags_count.get(tag, 0) + 1 + cache.raw_data.append(lora_data) new_folders.add(lora_data['folder']) # Update hash index @@ -109,6 +113,16 @@ class LoraFileHandler(FileSystemEventHandler): needs_resort = True elif action == 'remove': + # Find the lora to remove so we can update tags count + lora_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None) + if lora_to_remove: + # Update tags count by reducing counts + for tag in lora_to_remove.get('tags', []): + if tag in self.scanner._tags_count: + self.scanner._tags_count[tag] = max(0, self.scanner._tags_count[tag] - 1) + if self.scanner._tags_count[tag] == 0: + del self.scanner._tags_count[tag] + # Remove from cache and hash index logger.info(f"Removing {file_path} from cache") self.scanner._hash_index.remove_by_path(file_path) diff --git a/py/services/lora_scanner.py b/py/services/lora_scanner.py index 88d53049..1cd0da09 100644 --- a/py/services/lora_scanner.py +++ b/py/services/lora_scanner.py @@ -11,6 +11,8 @@ from ..utils.file_utils import load_metadata, get_file_info from .lora_cache import LoraCache from difflib import SequenceMatcher from .lora_hash_index import LoraHashIndex +from .settings_manager import settings +from ..utils.constants import NSFW_LEVELS import sys logger = logging.getLogger(__name__) @@ -35,6 +37,7 @@ class LoraScanner: self._initialization_task: Optional[asyncio.Task] = None self._initialized = True self.file_monitor = None # Add this line + self._tags_count = {} # Add a dictionary to store tag counts def set_file_monitor(self, monitor): """Set file monitor instance""" @@ -91,13 +94,21 @@ class LoraScanner: # Clear existing hash index self._hash_index.clear() + # Clear existing tags count + self._tags_count = {} + # Scan for new data raw_data = await self.scan_all_loras() - # Build hash index + # Build hash index and tags count for lora_data in raw_data: if 'sha256' in lora_data and 'file_path' in lora_data: self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path']) + + # Count tags + if 'tags' in lora_data and lora_data['tags']: + for tag in lora_data['tags']: + self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 # Update cache self._cache = LoraCache( @@ -159,7 +170,8 @@ class LoraScanner: async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name', folder: str = None, search: str = None, fuzzy: bool = False, - recursive: bool = False, base_models: list = None): + recursive: bool = False, base_models: list = None, tags: list = None, + search_options: dict = None) -> Dict: """Get paginated and filtered lora data Args: @@ -171,22 +183,39 @@ class LoraScanner: fuzzy: Use fuzzy matching for search recursive: Include subfolders when folder filter is applied base_models: List of base models to filter by + tags: List of tags to filter by + search_options: Dictionary with search options (filename, modelname, tags) """ cache = await self.get_cached_data() - # 先获取基础数据集 + # Get default search options if not provided + if search_options is None: + search_options = { + 'filename': True, + 'modelname': True, + 'tags': False + } + + # Get the base data set filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name - # 应用文件夹过滤 + # Apply SFW filtering if enabled + if settings.get('show_only_sfw', False): + filtered_data = [ + item for item in filtered_data + if not item.get('preview_nsfw_level') or item.get('preview_nsfw_level') < NSFW_LEVELS['R'] + ] + + # Apply folder filtering if folder is not None: if recursive: - # 递归模式:匹配所有以该文件夹开头的路径 + # Recursive mode: match all paths starting with this folder filtered_data = [ item for item in filtered_data if item['folder'].startswith(folder + '/') or item['folder'] == folder ] else: - # 非递归模式:只匹配确切的文件夹 + # Non-recursive mode: match exact folder filtered_data = [ item for item in filtered_data if item['folder'] == folder @@ -199,28 +228,27 @@ class LoraScanner: if item.get('base_model') in base_models ] - # 应用搜索过滤 + # Apply tag filtering + if tags and len(tags) > 0: + filtered_data = [ + item for item in filtered_data + if any(tag in item.get('tags', []) for tag in tags) + ] + + # Apply search filtering if search: if fuzzy: filtered_data = [ item for item in filtered_data - if any( - self.fuzzy_match(str(value), search) - for value in [ - item.get('model_name', ''), - item.get('base_model', '') - ] - if value - ) + if self._fuzzy_search_match(item, search, search_options) ] else: - # Original exact search logic filtered_data = [ item for item in filtered_data - if search in str(item.get('model_name', '')).lower() + if self._exact_search_match(item, search, search_options) ] - # 计算分页 + # Calculate pagination total_items = len(filtered_data) start_idx = (page - 1) * page_size end_idx = min(start_idx + page_size, total_items) @@ -235,6 +263,44 @@ class LoraScanner: return result + def _fuzzy_search_match(self, item: Dict, search: str, search_options: Dict) -> bool: + """Check if an item matches the search term using fuzzy matching with search options""" + # Check filename if enabled + if search_options.get('filename', True) and self.fuzzy_match(item.get('file_name', ''), search): + return True + + # Check model name if enabled + if search_options.get('modelname', True) and self.fuzzy_match(item.get('model_name', ''), search): + return True + + # Check tags if enabled + if search_options.get('tags', False) and item.get('tags'): + for tag in item['tags']: + if self.fuzzy_match(tag, search): + return True + + return False + + def _exact_search_match(self, item: Dict, search: str, search_options: Dict) -> bool: + """Check if an item matches the search term using exact matching with search options""" + search = search.lower() + + # Check filename if enabled + if search_options.get('filename', True) and search in item.get('file_name', '').lower(): + return True + + # Check model name if enabled + if search_options.get('modelname', True) and search in item.get('model_name', '').lower(): + return True + + # Check tags if enabled + if search_options.get('tags', False) and item.get('tags'): + for tag in item['tags']: + if search in tag.lower(): + return True + + return False + def invalidate_cache(self): """Invalidate the current cache""" self._cache = None @@ -312,12 +378,86 @@ class LoraScanner: # Convert to dict and add folder info lora_data = metadata.to_dict() + # Try to fetch missing metadata from Civitai if needed + await self._fetch_missing_metadata(file_path, lora_data) rel_path = os.path.relpath(file_path, root_path) folder = os.path.dirname(rel_path) lora_data['folder'] = folder.replace(os.path.sep, '/') return lora_data + async def _fetch_missing_metadata(self, file_path: str, lora_data: Dict) -> None: + """Fetch missing description and tags from Civitai if needed + + Args: + file_path: Path to the lora file + lora_data: Lora metadata dictionary to update + """ + try: + # Skip if already marked as deleted on Civitai + if lora_data.get('civitai_deleted', False): + logger.debug(f"Skipping metadata fetch for {file_path}: marked as deleted on Civitai") + return + + # Check if we need to fetch additional metadata from Civitai + needs_metadata_update = False + model_id = None + + # Check if we have Civitai model ID but missing metadata + if lora_data.get('civitai'): + # Try to get model ID directly from the correct location + model_id = lora_data['civitai'].get('modelId') + + if model_id: + model_id = str(model_id) + # Check if tags are missing or empty + tags_missing = not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0 + + # Check if description is missing or empty + desc_missing = not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "") + + needs_metadata_update = tags_missing or desc_missing + + # Fetch missing metadata if needed + if needs_metadata_update and model_id: + logger.debug(f"Fetching missing metadata for {file_path} with model ID {model_id}") + from ..services.civitai_client import CivitaiClient + client = CivitaiClient() + + # Get metadata and status code + model_metadata, status_code = await client.get_model_metadata(model_id) + await client.close() + + # Handle 404 status (model deleted from Civitai) + if status_code == 404: + logger.warning(f"Model {model_id} appears to be deleted from Civitai (404 response)") + # Mark as deleted to avoid future API calls + lora_data['civitai_deleted'] = True + + # Save the updated metadata back to file + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(lora_data, f, indent=2, ensure_ascii=False) + + # Process valid metadata if available + elif model_metadata: + logger.debug(f"Updating metadata for {file_path} with model ID {model_id}") + + # Update tags if they were missing + if model_metadata.get('tags') and (not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0): + lora_data['tags'] = model_metadata['tags'] + + # Update description if it was missing + if model_metadata.get('description') and (not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")): + lora_data['modelDescription'] = model_metadata['description'] + + # Save the updated metadata back to file + metadata_path = os.path.splitext(file_path)[0] + '.metadata.json' + with open(metadata_path, 'w', encoding='utf-8') as f: + json.dump(lora_data, f, indent=2, ensure_ascii=False) + except Exception as e: + logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}") + async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool: """Update preview URL in cache for a specific lora @@ -428,6 +568,15 @@ class LoraScanner: async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool: cache = await self.get_cached_data() + # Find the existing item to remove its tags from count + existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None) + if existing_item and 'tags' in existing_item: + for tag in existing_item.get('tags', []): + if tag in self._tags_count: + self._tags_count[tag] = max(0, self._tags_count[tag] - 1) + if self._tags_count[tag] == 0: + del self._tags_count[tag] + # Remove old path from hash index if exists self._hash_index.remove_by_path(original_path) @@ -461,6 +610,11 @@ class LoraScanner: # Update folders list all_folders = set(item['folder'] for item in cache.raw_data) cache.folders = sorted(list(all_folders), key=lambda x: x.lower()) + + # Update tags count with the new/updated tags + if 'tags' in metadata: + for tag in metadata.get('tags', []): + self._tags_count[tag] = self._tags_count.get(tag, 0) + 1 # Resort cache await cache.resort() @@ -506,6 +660,29 @@ class LoraScanner: """Get hash for a LoRA by its file path""" return self._hash_index.get_hash(file_path) + # Add new method to get top tags + async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]: + """Get top tags sorted by count + + Args: + limit: Maximum number of tags to return + + Returns: + List of dictionaries with tag name and count, sorted by count + """ + # Make sure cache is initialized + await self.get_cached_data() + + # Sort tags by count in descending order + sorted_tags = sorted( + [{"tag": tag, "count": count} for tag, count in self._tags_count.items()], + key=lambda x: x['count'], + reverse=True + ) + + # Return limited number + return sorted_tags[:limit] + async def diagnose_hash_index(self): """Diagnostic method to verify hash index functionality""" print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 0f323e73..a003c2df 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -37,7 +37,8 @@ class SettingsManager: def _get_default_settings(self) -> Dict[str, Any]: """Return default settings""" return { - "civitai_api_key": "" + "civitai_api_key": "", + "show_only_sfw": False } def get(self, key: str, default: Any = None) -> Any: diff --git a/py/utils/constants.py b/py/utils/constants.py new file mode 100644 index 00000000..69a96ca2 --- /dev/null +++ b/py/utils/constants.py @@ -0,0 +1,8 @@ +NSFW_LEVELS = { + "PG": 1, + "PG13": 2, + "R": 4, + "X": 8, + "XXX": 16, + "Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account? +} \ No newline at end of file diff --git a/py/utils/file_utils.py b/py/utils/file_utils.py index 2aaa1ed2..8aec9002 100644 --- a/py/utils/file_utils.py +++ b/py/utils/file_utils.py @@ -4,6 +4,8 @@ import hashlib import json from typing import Dict, Optional +from .model_utils import determine_base_model + from .lora_metadata import extract_lora_metadata from .models import LoraMetadata @@ -69,6 +71,8 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]: notes="", from_civitai=True, preview_url=normalize_path(preview_url), + tags=[], + modelDescription="" ) # create metadata file @@ -103,9 +107,18 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]: data = json.load(f) needs_update = False + + # Check and normalize base model name + normalized_base_model = determine_base_model(data['base_model']) + if data['base_model'] != normalized_base_model: + data['base_model'] = normalized_base_model + needs_update = True - if data['file_path'] != normalize_path(data['file_path']): - data['file_path'] = normalize_path(data['file_path']) + # Compare paths without extensions + stored_path_base = os.path.splitext(data['file_path'])[0] + current_path_base = os.path.splitext(normalize_path(file_path))[0] + if stored_path_base != current_path_base: + data['file_path'] = normalize_path(file_path) needs_update = True preview_url = data.get('preview_url', '') @@ -116,8 +129,21 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]: if new_preview_url != preview_url: data['preview_url'] = new_preview_url needs_update = True - elif preview_url != normalize_path(preview_url): - data['preview_url'] = normalize_path(preview_url) + else: + # Compare preview paths without extensions + stored_preview_base = os.path.splitext(preview_url)[0] + current_preview_base = os.path.splitext(normalize_path(preview_url))[0] + if stored_preview_base != current_preview_base: + data['preview_url'] = normalize_path(preview_url) + needs_update = True + + # Ensure all fields are present + if 'tags' not in data: + data['tags'] = [] + needs_update = True + + if 'modelDescription' not in data: + data['modelDescription'] = "" needs_update = True if needs_update: diff --git a/py/utils/model_utils.py b/py/utils/model_utils.py index 80c2d023..7f62925d 100644 --- a/py/utils/model_utils.py +++ b/py/utils/model_utils.py @@ -8,7 +8,8 @@ BASE_MODEL_MAPPING = { "sd-v2": "SD 2.0", "flux1": "Flux.1 D", "flux.1 d": "Flux.1 D", - "illustrious": "IL", + "illustrious": "Illustrious", + "il": "Illustrious", "pony": "Pony", "Hunyuan Video": "Hunyuan Video" } diff --git a/py/utils/models.py b/py/utils/models.py index 0a39e065..67be65f7 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, asdict -from typing import Dict, Optional +from typing import Dict, Optional, List from datetime import datetime import os from .model_utils import determine_base_model @@ -15,10 +15,18 @@ class LoraMetadata: sha256: str # SHA256 hash of the file base_model: str # Base model (SD1.5/SD2.1/SDXL/etc.) preview_url: str # Preview image URL + preview_nsfw_level: int = 0 # NSFW level of the preview image usage_tips: str = "{}" # Usage tips for the model, json string notes: str = "" # Additional notes - from_civitai: bool = True # Whether the lora is from Civitai + from_civitai: bool = True # Whether the lora is from Civitai civitai: Optional[Dict] = None # Civitai API data if available + tags: List[str] = None # Model tags + modelDescription: str = "" # Full model description + + def __post_init__(self): + # Initialize empty lists to avoid mutable default parameter issue + if self.tags is None: + self.tags = [] @classmethod def from_dict(cls, data: Dict) -> 'LoraMetadata': @@ -42,6 +50,7 @@ class LoraMetadata: sha256=file_info['hashes'].get('SHA256', ''), base_model=base_model, preview_url=None, # Will be updated after preview download + preview_nsfw_level=0, # Will be updated after preview download, it is decided by the nsfw level of the preview image from_civitai=True, civitai=version_info ) diff --git a/pyproject.toml b/pyproject.toml index b49673d6..e188938e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-lora-manager" description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration." -version = "0.7.35-beta" +version = "0.7.37-bugfix" license = {file = "LICENSE"} dependencies = [ "aiohttp", diff --git a/static/css/components/bulk.css b/static/css/components/bulk.css index 76f88397..ce60a52c 100644 --- a/static/css/components/bulk.css +++ b/static/css/components/bulk.css @@ -262,6 +262,83 @@ background: var(--lora-accent); } +/* NSFW Level Selector */ +.nsfw-level-selector { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-base); + padding: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + z-index: var(--z-modal); + width: 300px; + display: none; +} + +.nsfw-level-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.nsfw-level-header h3 { + margin: 0; + font-size: 16px; + font-weight: 500; +} + +.close-nsfw-selector { + background: transparent; + border: none; + color: var(--text-color); + cursor: pointer; + padding: 4px; + border-radius: var(--border-radius-xs); +} + +.close-nsfw-selector:hover { + background: var(--border-color); +} + +.current-level { + margin-bottom: 12px; + padding: 8px; + background: var(--bg-color); + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); +} + +.nsfw-level-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.nsfw-level-btn { + flex: 1 0 calc(33% - 8px); + padding: 8px; + border-radius: var(--border-radius-xs); + background: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.nsfw-level-btn:hover { + background: var(--lora-border); +} + +.nsfw-level-btn.active { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + /* Mobile optimizations */ @media (max-width: 768px) { .selected-thumbnails-strip { diff --git a/static/css/components/card.css b/static/css/components/card.css index 99e272e8..84801ef3 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -60,6 +60,96 @@ object-position: center top; /* Align the top of the image with the top of the container */ } +/* NSFW Content Blur */ +.card-preview.blurred img, +.card-preview.blurred video { + filter: blur(25px); +} + +.nsfw-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + pointer-events: none; +} + +.nsfw-warning { + text-align: center; + color: white; + background: rgba(0, 0, 0, 0.6); + padding: var(--space-2); + border-radius: var(--border-radius-base); + backdrop-filter: blur(4px); + max-width: 80%; + pointer-events: auto; +} + +.nsfw-warning p { + margin: 0 0 var(--space-1); + font-weight: bold; + font-size: 1.1em; + text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5); +} + +.toggle-blur-btn { + position: absolute; + left: var(--space-1); + top: var(--space-1); + background: rgba(0, 0, 0, 0.5); + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: white; + cursor: pointer; + z-index: 3; + transition: background-color 0.2s, transform 0.2s; +} + +.toggle-blur-btn:hover { + background: rgba(0, 0, 0, 0.7); + transform: scale(1.1); +} + +.toggle-blur-btn i { + font-size: 0.9em; +} + +.show-content-btn { + background: var(--lora-accent); + color: white; + border: none; + border-radius: var(--border-radius-xs); + padding: 4px var(--space-1); + cursor: pointer; + font-size: 0.9em; + transition: background-color 0.2s, transform 0.2s; +} + +.show-content-btn:hover { + background: oklch(58% 0.28 256); + transform: scale(1.05); +} + +/* Adjust base model label positioning when toggle button is present */ +.base-model-label.with-toggle { + margin-left: 28px; /* Make room for the toggle button */ +} + +/* Ensure card actions remain clickable */ +.card-header .card-actions { + z-index: 3; +} + .card-footer { position: absolute; bottom: 0; diff --git a/static/css/components/lora-modal.css b/static/css/components/lora-modal.css index 0e2f16a9..16250e94 100644 --- a/static/css/components/lora-modal.css +++ b/static/css/components/lora-modal.css @@ -1,8 +1,9 @@ /* Lora Modal Header */ .modal-header { display: flex; - justify-content: space-between; - align-items: center; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; margin-bottom: var(--space-3); padding-bottom: var(--space-2); border-bottom: 1px solid var(--lora-border); @@ -162,12 +163,57 @@ border: 1px solid var(--lora-border); } +/* New header style for trigger words */ +.trigger-words-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.edit-trigger-words-btn { + background: transparent; + border: none; + color: var(--text-color); + opacity: 0.5; + cursor: pointer; + padding: 2px 5px; + border-radius: var(--border-radius-xs); + transition: all 0.2s ease; +} + +.edit-trigger-words-btn:hover { + opacity: 0.8; + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .edit-trigger-words-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + +/* Edit mode active state */ +.trigger-words.edit-mode .edit-trigger-words-btn { + opacity: 0.8; + color: var(--lora-accent); +} + +.trigger-words-content { + margin-bottom: var(--space-1); +} + .trigger-words-tags { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; - margin-top: var(--space-1); +} + +/* No trigger words message */ +.no-trigger-words { + color: var(--text-color); + opacity: 0.7; + font-style: italic; + font-size: 0.9em; } /* Update Trigger Words styles */ @@ -181,6 +227,7 @@ cursor: pointer; transition: all 0.2s ease; gap: 6px; + position: relative; } /* Update trigger word content color to use theme accent */ @@ -206,6 +253,123 @@ transition: opacity 0.2s; } +/* Delete button for trigger word */ +.delete-trigger-word-btn { + position: absolute; + top: -5px; + right: -5px; + width: 16px; + height: 16px; + background: var(--lora-error); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + transition: transform 0.2s ease; +} + +.delete-trigger-word-btn:hover { + transform: scale(1.1); +} + +/* Edit controls */ +.trigger-words-edit-controls { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-2); +} + +.trigger-words-edit-controls button { + padding: 3px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.85em; + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + transition: all 0.2s ease; +} + +.trigger-words-edit-controls button:hover { + background: oklch(var(--lora-accent) / 0.1); + border-color: var(--lora-accent); +} + +.trigger-words-edit-controls button i { + font-size: 0.8em; +} + +.save-trigger-words-btn { + background: var(--lora-accent) !important; + color: white !important; + border-color: var(--lora-accent) !important; +} + +.save-trigger-words-btn:hover { + opacity: 0.9; +} + +/* Add trigger word form */ +.add-trigger-word-form { + margin-top: var(--space-2); + display: flex; + gap: var(--space-1); +} + +.new-trigger-word-input { + flex: 1; + padding: 4px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.9em; +} + +.new-trigger-word-input:focus { + border-color: var(--lora-accent); + outline: none; +} + +.confirm-add-trigger-word-btn, +.cancel-add-trigger-word-btn { + padding: 4px 8px; + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); + background: var(--bg-color); + color: var(--text-color); + font-size: 0.85em; + cursor: pointer; + transition: all 0.2s ease; +} + +.confirm-add-trigger-word-btn { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +.confirm-add-trigger-word-btn:hover { + opacity: 0.9; +} + +.cancel-add-trigger-word-btn:hover { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] .cancel-add-trigger-word-btn:hover { + background: rgba(255, 255, 255, 0.05); +} + /* Editable Fields */ .editable-field { position: relative; @@ -479,4 +643,395 @@ /* Ensure close button is accessible */ .modal-content .close { z-index: 10; /* Ensure close button is above other elements */ +} + +/* Tab System Styling */ +.showcase-tabs { + display: flex; + border-bottom: 1px solid var(--lora-border); + margin-bottom: var(--space-2); + position: relative; + z-index: 2; +} + +.tab-btn { + padding: var(--space-1) var(--space-2); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-color); + cursor: pointer; + font-size: 0.95em; + transition: all 0.2s; + opacity: 0.7; + position: relative; +} + +.tab-btn:hover { + opacity: 1; + background: oklch(var(--lora-accent) / 0.05); +} + +.tab-btn.active { + border-bottom: 2px solid var(--lora-accent); + opacity: 1; + font-weight: 600; +} + +.tab-content { + position: relative; + min-height: 100px; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +/* Model Description Styling */ +.model-description-container { + background: var(--lora-surface); + border-radius: var(--border-radius-sm); + overflow: hidden; + min-height: 200px; + position: relative; + /* Remove the max-height and overflow-y to allow content to expand naturally */ +} + +.model-description-loading { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + color: var(--text-color); + opacity: 0.7; + font-size: 0.9em; +} + +.model-description-loading .fa-spinner { + margin-right: var(--space-1); +} + +.model-description-content { + padding: var(--space-2); + line-height: 1.5; + overflow-wrap: break-word; + font-size: 0.95em; +} + +.model-description-content h1, +.model-description-content h2, +.model-description-content h3, +.model-description-content h4, +.model-description-content h5, +.model-description-content h6 { + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.model-description-content p { + margin-bottom: 1em; +} + +.model-description-content img { + max-width: 100%; + height: auto; + border-radius: var(--border-radius-xs); + display: block; + margin: 1em 0; +} + +.model-description-content pre { + background: rgba(0, 0, 0, 0.05); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + white-space: pre-wrap; + margin: 1em 0; + overflow-x: auto; +} + +.model-description-content code { + font-family: monospace; + font-size: 0.9em; + background: rgba(0, 0, 0, 0.05); + padding: 0.1em 0.3em; + border-radius: 3px; +} + +.model-description-content pre code { + background: transparent; + padding: 0; +} + +.model-description-content ul, +.model-description-content ol { + margin-left: 1.5em; + margin-bottom: 1em; +} + +.model-description-content li { + margin-bottom: 0.5em; +} + +.model-description-content blockquote { + border-left: 3px solid var(--lora-accent); + padding-left: 1em; + margin-left: 0; + margin-right: 0; + font-style: italic; + opacity: 0.8; +} + +/* Adjust dark mode for model description */ +[data-theme="dark"] .model-description-content pre, +[data-theme="dark"] .model-description-content code { + background: rgba(255, 255, 255, 0.05); +} + +.hidden { + display: none !important; +} + +.error-message { + color: var(--lora-error); + text-align: center; + padding: var(--space-2); +} + +.no-examples { + text-align: center; + padding: var(--space-3); + color: var(--text-color); + opacity: 0.7; +} + +/* Adjust the media wrapper for tab system */ +#showcase-tab .carousel-container { + margin-top: var(--space-2); +} + +/* Enhanced Model Description Styling */ +.model-description-container { + background: var(--lora-surface); + border-radius: var(--border-radius-sm); + overflow: hidden; + min-height: 200px; + position: relative; + /* Remove the max-height and overflow-y to allow content to expand naturally */ +} + +.model-description-content { + padding: var(--space-2); + line-height: 1.5; + overflow-wrap: break-word; + font-size: 0.95em; +} + +.model-description-content h1, +.model-description-content h2, +.model-description-content h3, +.model-description-content h4, +.model-description-content h5, +.model-description-content h6 { + margin-top: 1em; + margin-bottom: 0.5em; + font-weight: 600; +} + +.model-description-content p { + margin-bottom: 1em; +} + +.model-description-content img { + max-width: 100%; + height: auto; + border-radius: var(--border-radius-xs); + display: block; + margin: 1em 0; +} + +.model-description-content pre { + background: rgba(0, 0, 0, 0.05); + border-radius: var(--border-radius-xs); + padding: var(--space-1); + white-space: pre-wrap; + margin: 1em 0; + overflow-x: auto; +} + +.model-description-content code { + font-family: monospace; + font-size: 0.9em; + background: rgba(0, 0, 0, 0.05); + padding: 0.1em 0.3em; + border-radius: 3px; +} + +.model-description-content pre code { + background: transparent; + padding: 0; +} + +.model-description-content ul, +.model-description-content ol { + margin-left: 1.5em; + margin-bottom: 1em; +} + +.model-description-content li { + margin-bottom: 0.5em; +} + +.model-description-content blockquote { + border-left: 3px solid var (--lora-accent); + padding-left: 1em; + margin-left: 0; + margin-right: 0; + font-style: italic; + opacity: 0.8; +} + +/* Adjust dark mode for model description */ +[data-theme="dark"] .model-description-content pre, +[data-theme="dark"] .model-description-content code { + background: rgba(255, 255, 255, 0.05); +} + +/* Model Tags styles */ +.model-tags { + display: none; +} + +.model-tag { + display: none; +} + +/* Updated Model Tags styles - improved visibility in light theme */ +.model-tags-container { + position: relative; + margin-top: 4px; +} + +.model-tags-compact { + display: flex; + flex-wrap: nowrap; + gap: 6px; + align-items: center; +} + +.model-tag-compact { + /* Updated styles to match info-item appearance */ + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-xs); + padding: 2px 8px; + font-size: 0.75em; + color: var(--text-color); + white-space: nowrap; +} + +/* Adjust dark theme tag styles */ +[data-theme="dark"] .model-tag-compact { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +.model-tag-more { + background: var(--lora-accent); + color: var(--lora-text); + border-radius: var(--border-radius-xs); + padding: 2px 8px; + font-size: 0.75em; + cursor: pointer; + white-space: nowrap; + font-weight: 500; +} + +.model-tags-tooltip { + position: absolute; + top: calc(100% + 8px); + left: 0; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); /* Enhanced shadow for better visibility */ + padding: 10px 14px; + max-width: 400px; + z-index: 10; + opacity: 0; + visibility: hidden; + transform: translateY(-4px); + transition: all 0.2s ease; + pointer-events: none; +} + +.model-tags-tooltip.visible { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; /* Enable interactions when visible */ +} + +.tooltip-content { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 200px; + overflow-y: auto; +} + +.tooltip-tag { + /* Updated styles to match info-item appearance */ + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: var(--border-radius-xs); + padding: 3px 8px; + font-size: 0.75em; + color: var(--text-color); +} + +/* Adjust dark theme tooltip tag styles */ +[data-theme="dark"] .tooltip-tag { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--lora-border); +} + +/* Add styles for blurred showcase content */ +.nsfw-media-wrapper { + position: relative; +} + +.media-wrapper img.blurred, +.media-wrapper video.blurred { + filter: blur(25px); +} + +.media-wrapper .nsfw-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + pointer-events: none; +} + +/* Position the toggle button at the top left of showcase media */ +.showcase-toggle-btn { + position: absolute; + left: var(--space-1); + top: var(--space-1); + z-index: 3; +} + +/* Make sure media wrapper maintains position: relative for absolute positioning of children */ +.carousel .media-wrapper { + position: relative; } \ No newline at end of file diff --git a/static/css/components/modal.css b/static/css/components/modal.css index 10c7c141..a8684865 100644 --- a/static/css/components/modal.css +++ b/static/css/components/modal.css @@ -323,4 +323,124 @@ body.modal-open { [data-theme="dark"] .path-preview { background: rgba(255, 255, 255, 0.03); border: 1px solid var(--lora-border); +} + +/* Settings Styles */ +.settings-section { + margin-top: var(--space-3); + border-top: 1px solid var(--lora-border); + padding-top: var(--space-2); +} + +.settings-section h3 { + font-size: 1.1em; + margin-bottom: var(--space-2); + color: var(--text-color); + opacity: 0.9; +} + +.setting-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--space-2); + padding: var(--space-1); + border-radius: var(--border-radius-xs); +} + +.setting-item:hover { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="dark"] .setting-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.setting-info { + flex: 1; +} + +.setting-info label { + display: block; + margin-bottom: 4px; + font-weight: 500; +} + +.setting-control { + padding-left: var(--space-2); +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 24px; + cursor: pointer; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: .3s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .3s; + border-radius: 50%; +} + +input:checked + .toggle-slider { + background-color: var(--lora-accent); +} + +input:checked + .toggle-slider:before { + transform: translateX(26px); +} + +.toggle-label { + margin-left: 60px; + line-height: 24px; +} + +/* Add small animation for the toggle */ +.toggle-slider:active:before { + width: 22px; +} + +/* Update input help styles */ +.input-help { + font-size: 0.85em; + color: var(--text-color); + opacity: 0.7; + margin-top: 4px; + line-height: 1.4; +} + +/* Blur effect for NSFW content */ +.nsfw-blur { + filter: blur(12px); + transition: filter 0.3s ease; +} + +.nsfw-blur:hover { + filter: blur(8px); } \ No newline at end of file diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index 50f7fc40..f18abfeb 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -237,6 +237,44 @@ border-color: var(--lora-accent); } +/* Tag filter styles */ +.tag-filter { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 60px; +} + +.tag-count { + background: rgba(0, 0, 0, 0.1); + padding: 1px 6px; + border-radius: 10px; + font-size: 0.8em; + margin-left: 4px; +} + +[data-theme="dark"] .tag-count { + background: rgba(255, 255, 255, 0.1); +} + +.tag-filter.active .tag-count { + background: rgba(255, 255, 255, 0.3); + color: white; +} + +.tags-loading, .tags-error, .no-tags { + width: 100%; + padding: 8px; + text-align: center; + font-size: 0.9em; + color: var(--text-color); + opacity: 0.7; +} + +.tags-error { + color: var(--lora-error); +} + /* Filter actions */ .filter-actions { display: flex; @@ -276,4 +314,197 @@ right: 20px; top: 140px; } -} \ No newline at end of file +} + +/* Search Options Toggle */ +.search-options-toggle { + background: var(--lora-surface); + border: 1px solid oklch(65% 0.02 256); + border-radius: var(--border-radius-sm); + color: var(--text-color); + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.search-options-toggle:hover { + background-color: var(--lora-surface-hover, oklch(95% 0.02 256)); + color: var(--lora-accent); + border-color: var(--lora-accent); +} + +.search-options-toggle.active { + background-color: oklch(95% 0.05 256); + color: var(--lora-accent); + border-color: var(--lora-accent); +} + +.search-options-toggle i { + font-size: 0.9em; +} + +/* Search Options Panel */ +.search-options-panel { + position: absolute; + top: 140px; + right: 65px; /* Position it closer to the search options button */ + width: 280px; /* Slightly wider to accommodate tags better */ + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-base); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + z-index: var(--z-overlay); + padding: 16px; + transition: transform 0.3s ease, opacity 0.3s ease; + transform-origin: top right; +} + +.search-options-panel.hidden { + opacity: 0; + transform: scale(0.95); + pointer-events: none; +} + +.options-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.options-header h3 { + margin: 0; + font-size: 16px; + color: var(--text-color); +} + +.close-options-btn { + background: none; + border: none; + color: var(--text-color); + cursor: pointer; + font-size: 16px; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.close-options-btn:hover { + color: var(--lora-accent); +} + +.options-section { + margin-bottom: 16px; +} + +.options-section h4 { + margin: 0 0 8px 0; + font-size: 14px; + color: var(--text-color); + opacity: 0.8; +} + +.search-option-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; /* Increased gap for better spacing */ +} + +.search-option-tag { + padding: 6px 8px; /* Adjusted padding for better text display */ + border-radius: var(--border-radius-sm); + background-color: var(--lora-surface); + border: 1px solid var(--border-color); + color: var(--text-color); + font-size: 13px; /* Slightly smaller font size */ + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + flex: 1; + text-align: center; + white-space: nowrap; /* Prevent text wrapping */ + min-width: 80px; /* Ensure minimum width for each tag */ + display: inline-flex; /* Better control over layout */ + justify-content: center; + align-items: center; +} + +.search-option-tag:hover { + background-color: var(--lora-surface-hover); +} + +.search-option-tag.active { + background-color: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +/* Switch styles */ +.search-option-switch { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; +} + +.switch { + position: relative; + display: inline-block; + width: 46px; + height: 24px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + transition: .4s; +} + +.slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: .4s; +} + +input:checked + .slider { + background-color: var(--lora-accent); +} + +input:focus + .slider { + box-shadow: 0 0 1px var(--lora-accent); +} + +input:checked + .slider:before { + transform: translateX(22px); +} + +.slider.round { + border-radius: 34px; +} + +.slider.round:before { + border-radius: 50%; +} \ No newline at end of file diff --git a/static/css/components/update-modal.css b/static/css/components/update-modal.css index 28d185bd..f8e12d14 100644 --- a/static/css/components/update-modal.css +++ b/static/css/components/update-modal.css @@ -153,56 +153,43 @@ border-top: 1px solid var(--lora-border); margin-top: var(--space-2); padding-top: var(--space-2); -} - -/* Toggle switch styles */ -.toggle-switch { display: flex; align-items: center; - gap: 12px; + justify-content: flex-start; +} + +/* Override toggle switch styles for update preferences */ +.update-preferences .toggle-switch { + position: relative; + display: inline-flex; + align-items: center; + width: auto; + height: 24px; cursor: pointer; - user-select: none; } -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; - position: absolute; -} - -.toggle-slider { +.update-preferences .toggle-slider { position: relative; display: inline-block; - width: 40px; - height: 20px; - background-color: var(--border-color); - border-radius: 20px; - transition: .4s; + width: 50px; + height: 24px; flex-shrink: 0; + margin-right: 10px; } -.toggle-slider:before { - position: absolute; - content: ""; - height: 16px; - width: 16px; - left: 2px; - bottom: 2px; - background-color: white; - border-radius: 50%; - transition: .4s; +.update-preferences .toggle-label { + margin-left: 0; + white-space: nowrap; + line-height: 24px; } -input:checked + .toggle-slider { - background-color: var(--lora-accent); -} - -input:checked + .toggle-slider:before { - transform: translateX(20px); -} - -.toggle-label { - font-size: 0.9em; - color: var(--text-color); +@media (max-width: 480px) { + .update-preferences { + flex-direction: row; + flex-wrap: wrap; + } + + .update-preferences .toggle-label { + margin-top: 5px; + } } \ No newline at end of file diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 1ac6f012..86278c21 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -32,9 +32,15 @@ export async function loadMoreLoras(boolUpdateFolders = false) { } // Add filter parameters if active - if (state.filters && state.filters.baseModel && state.filters.baseModel.length > 0) { - // Convert the array of base models to a comma-separated string - params.append('base_models', state.filters.baseModel.join(',')); + if (state.filters) { + if (state.filters.tags && state.filters.tags.length > 0) { + // Convert the array of tags to a comma-separated string + params.append('tags', state.filters.tags.join(',')); + } + if (state.filters.baseModel && state.filters.baseModel.length > 0) { + // Convert the array of base models to a comma-separated string + params.append('base_models', state.filters.baseModel.join(',')); + } } console.log('Loading loras with params:', params.toString()); @@ -107,7 +113,8 @@ export async function fetchCivitai() { await state.loadingManager.showWithProgress(async (loading) => { try { - ws = new WebSocket(`ws://${window.location.host}/ws/fetch-progress`); + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`); const operationComplete = new Promise((resolve, reject) => { ws.onmessage = (event) => { @@ -325,4 +332,19 @@ export async function refreshSingleLoraMetadata(filePath) { state.loadingManager.hide(); state.loadingManager.restoreProgressBar(); } +} + +export async function fetchModelDescription(modelId, filePath) { + try { + 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}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching model description:', error); + throw error; + } } \ No newline at end of file diff --git a/static/js/components/ContextMenu.js b/static/js/components/ContextMenu.js index 7cb13884..b02f2828 100644 --- a/static/js/components/ContextMenu.js +++ b/static/js/components/ContextMenu.js @@ -1,9 +1,12 @@ import { refreshSingleLoraMetadata } from '../api/loraApi.js'; +import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js'; +import { NSFW_LEVELS } from '../utils/constants.js'; export class LoraContextMenu { constructor() { this.menu = document.getElementById('loraContextMenu'); this.currentCard = null; + this.nsfwSelector = document.getElementById('nsfwLevelSelector'); this.init(); } @@ -58,10 +61,274 @@ export class LoraContextMenu { case 'refresh-metadata': refreshSingleLoraMetadata(this.currentCard.dataset.filepath); break; + case 'set-nsfw': + this.showNSFWLevelSelector(null, null, this.currentCard); + break; } this.hideMenu(); }); + + // Initialize NSFW Level Selector events + this.initNSFWSelector(); + } + + initNSFWSelector() { + // Close button + const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector'); + closeBtn.addEventListener('click', () => { + this.nsfwSelector.style.display = 'none'; + }); + + // Level buttons + const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn'); + levelButtons.forEach(btn => { + btn.addEventListener('click', async () => { + const level = parseInt(btn.dataset.level); + const filePath = this.nsfwSelector.dataset.cardPath; + + if (!filePath) return; + + try { + await this.saveModelMetadata(filePath, { preview_nsfw_level: level }); + + // Update card data + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (card) { + let metaData = {}; + try { + metaData = JSON.parse(card.dataset.meta || '{}'); + } catch (err) { + console.error('Error parsing metadata:', err); + } + + metaData.preview_nsfw_level = level; + card.dataset.meta = JSON.stringify(metaData); + card.dataset.nsfwLevel = level.toString(); + + // Apply blur effect immediately + this.updateCardBlurEffect(card, level); + } + + showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success'); + this.nsfwSelector.style.display = 'none'; + } catch (error) { + showToast(`Failed to set content rating: ${error.message}`, 'error'); + } + }); + }); + + // Close when clicking outside + document.addEventListener('click', (e) => { + if (this.nsfwSelector.style.display === 'block' && + !this.nsfwSelector.contains(e.target) && + !e.target.closest('.context-menu-item[data-action="set-nsfw"]')) { + this.nsfwSelector.style.display = 'none'; + } + }); + } + + async 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 await response.json(); + } + + updateCardBlurEffect(card, level) { + // Get user settings for blur threshold + const blurThreshold = parseInt(localStorage.getItem('nsfwBlurLevel') || '4'); + + // Get card preview container + const previewContainer = card.querySelector('.card-preview'); + if (!previewContainer) return; + + // Get preview media element + const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video'); + if (!previewMedia) return; + + // Check if blur should be applied + if (level >= blurThreshold) { + // Add blur class to the preview container + previewContainer.classList.add('blurred'); + + // Get or create the NSFW overlay + let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay'); + if (!nsfwOverlay) { + // Create new overlay + nsfwOverlay = document.createElement('div'); + nsfwOverlay.className = 'nsfw-overlay'; + + // Create and configure the warning content + const warningContent = document.createElement('div'); + warningContent.className = 'nsfw-warning'; + + // Determine NSFW warning text based on level + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + + // Add warning text and show button + warningContent.innerHTML = ` +

${nsfwText}

+ + `; + + // Add click event to the show button + const showBtn = warningContent.querySelector('.show-content-btn'); + showBtn.addEventListener('click', (e) => { + e.stopPropagation(); + previewContainer.classList.remove('blurred'); + nsfwOverlay.style.display = 'none'; + + // Update toggle button icon if it exists + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + }); + + nsfwOverlay.appendChild(warningContent); + previewContainer.appendChild(nsfwOverlay); + } else { + // Update existing overlay + const warningText = nsfwOverlay.querySelector('p'); + if (warningText) { + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + warningText.textContent = nsfwText; + } + nsfwOverlay.style.display = 'flex'; + } + + // Get or create the toggle button in the header + const cardHeader = previewContainer.querySelector('.card-header'); + if (cardHeader) { + let toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); + + if (!toggleBtn) { + toggleBtn = document.createElement('button'); + toggleBtn.className = 'toggle-blur-btn'; + toggleBtn.title = 'Toggle blur'; + toggleBtn.innerHTML = ''; + + // Add click event to toggle button + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isBlurred = previewContainer.classList.toggle('blurred'); + const icon = toggleBtn.querySelector('i'); + + // Update icon and overlay visibility + if (isBlurred) { + icon.className = 'fas fa-eye'; + nsfwOverlay.style.display = 'flex'; + } else { + icon.className = 'fas fa-eye-slash'; + nsfwOverlay.style.display = 'none'; + } + }); + + // Add to the beginning of header + cardHeader.insertBefore(toggleBtn, cardHeader.firstChild); + + // Update base model label class + const baseModelLabel = cardHeader.querySelector('.base-model-label'); + if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) { + baseModelLabel.classList.add('with-toggle'); + } + } else { + // Update existing toggle button + toggleBtn.querySelector('i').className = 'fas fa-eye'; + } + } + } else { + // Remove blur + previewContainer.classList.remove('blurred'); + + // Hide overlay if it exists + const overlay = previewContainer.querySelector('.nsfw-overlay'); + if (overlay) overlay.style.display = 'none'; + + // Update or remove toggle button + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + // We'll leave the button but update the icon + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + } + } + + showNSFWLevelSelector(x, y, card) { + const selector = document.getElementById('nsfwLevelSelector'); + const currentLevelEl = document.getElementById('currentNSFWLevel'); + + // Get current NSFW level + let currentLevel = 0; + try { + const metaData = JSON.parse(card.dataset.meta || '{}'); + currentLevel = metaData.preview_nsfw_level || 0; + + // Update if we have no recorded level but have a dataset attribute + if (!currentLevel && card.dataset.nsfwLevel) { + currentLevel = parseInt(card.dataset.nsfwLevel) || 0; + } + } catch (err) { + console.error('Error parsing metadata:', err); + } + + currentLevelEl.textContent = getNSFWLevelName(currentLevel); + + // Position the selector + if (x && y) { + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const selectorRect = selector.getBoundingClientRect(); + + // Center the selector if no coordinates provided + let finalX = (viewportWidth - selectorRect.width) / 2; + let finalY = (viewportHeight - selectorRect.height) / 2; + + selector.style.left = `${finalX}px`; + selector.style.top = `${finalY}px`; + } + + // Highlight current level button + document.querySelectorAll('.nsfw-level-btn').forEach(btn => { + if (parseInt(btn.dataset.level) === currentLevel) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Store reference to current card + selector.dataset.cardPath = card.dataset.filepath; + + // Show selector + selector.style.display = 'block'; } showMenu(x, y, card) { diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index 99985f4d..f266f662 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -2,6 +2,7 @@ import { showToast } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; import { showLoraModal } from './LoraModal.js'; import { bulkManager } from '../managers/BulkManager.js'; +import { NSFW_LEVELS } from '../utils/constants.js'; export function createLoraCard(lora) { const card = document.createElement('div'); @@ -18,6 +19,24 @@ export function createLoraCard(lora) { card.dataset.usage_tips = lora.usage_tips; card.dataset.notes = lora.notes; card.dataset.meta = JSON.stringify(lora.civitai || {}); + + // Store tags and model description + if (lora.tags && Array.isArray(lora.tags)) { + card.dataset.tags = JSON.stringify(lora.tags); + } + if (lora.modelDescription) { + card.dataset.modelDescription = lora.modelDescription; + } + + // Store NSFW level if available + const nsfwLevel = lora.preview_nsfw_level !== undefined ? lora.preview_nsfw_level : 0; + card.dataset.nsfwLevel = nsfwLevel; + + // Determine if the preview should be blurred based on NSFW level and user settings + const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; + if (shouldBlur) { + card.classList.add('nsfw-content'); + } // Apply selection state if in bulk mode and this card is in the selected set if (state.bulkMode && state.selectedLoras.has(lora.file_path)) { @@ -28,8 +47,18 @@ export function createLoraCard(lora) { const previewUrl = lora.preview_url || '/loras_static/images/no-preview.png'; const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl; + // 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"; + } + card.innerHTML = ` -
+
${previewUrl.endsWith('.mp4') ? `
- ${renderTriggerWords(escapedWords)} + ${renderTriggerWords(escapedWords, lora.file_path)}
@@ -81,10 +83,35 @@ export function showLoraModal(lora) {
${lora.description || 'N/A'}
-
- ${renderShowcaseImages(lora.civitai.images)} +
+
+ + +
+ +
+
+ ${renderShowcaseContent(lora.civitai?.images)} +
+ +
+
+
+ Loading model description... +
+
+ ${lora.modelDescription || ''} +
+
+
+
+ + +
`; @@ -92,6 +119,231 @@ export function showLoraModal(lora) { modalManager.showModal('loraModal', content); setupEditableFields(); setupShowcaseScroll(); + setupTabSwitching(); + setupTagTooltip(); + setupTriggerWordsEditMode(); + + // If we have a model ID but no description, fetch it + if (lora.civitai?.modelId && !lora.modelDescription) { + loadModelDescription(lora.civitai.modelId, lora.file_path); + } +} + +// Function to render showcase content +function renderShowcaseContent(images) { + if (!images?.length) return '
No example images available
'; + + // 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 ` +
+

All example images are filtered due to NSFW content settings

+

Your settings are currently set to show only safe-for-work content

+

You can change this in Settings

+
+ `; + } + + // Show hidden content notification if applicable + const hiddenNotification = hiddenCount > 0 ? + `
+ ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting +
` : ''; + + return ` +
+ + Scroll or click to show ${filteredImages.length} examples +
+ + `; +} + +// New function to handle tab switching +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 = '
No model description available
'; + descriptionContent.classList.remove('hidden'); + } + } + } + }); + }); +} + +// New function to load model description +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 = `
Failed to load model description. ${error.message}
`; + } + + // Show empty state message in the description container + const descriptionContainer = document.querySelector('.model-description-content'); + if (descriptionContainer) { + descriptionContainer.innerHTML = '
No model description available
'; + descriptionContainer.classList.remove('hidden'); + } + } } // 添加复制文件名的函数 @@ -324,85 +576,71 @@ async function saveModelMetadata(filePath, data) { } } -function renderTriggerWords(words) { +function renderTriggerWords(words, filePath) { if (!words.length) return `
- - No trigger word needed +
+ + +
+
+ No trigger word needed + +
+ +
`; return `
- -
- ${words.map(word => ` -
- ${word} - - - -
- `).join('')} +
+ +
-
- `; -} - -function renderShowcaseImages(images) { - if (!images?.length) return ''; - - return ` -
-
- - Scroll or click to show ${images.length} examples -
-
+ + + +
+

Tags (Top 20)

+
+ +
Loading tags...
+
+
+ +
+

Content Filtering

+ +
+
+ +
+ Blur mature (NSFW) content preview images +
+
+
+ +
+
+ +
+
+ +
+ Filter out all NSFW content when browsing and searching +
+
+
+ +
+
+