Merge branch 'main' into dev

This commit is contained in:
Will Miao
2025-03-13 11:45:43 +08:00
48 changed files with 3592 additions and 269 deletions

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -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.

View File

@@ -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.

View File

@@ -13,6 +13,21 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes ## 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 ### v0.7.35-beta
* Added base model filtering * Added base model filtering
* Implemented bulk operations (copy syntax, move multiple LoRAs) * Implemented bulk operations (copy syntax, move multiple LoRAs)

View File

@@ -1,10 +1,12 @@
from .py.lora_manager import LoraManager from .py.lora_manager import LoraManager
from .py.nodes.lora_loader import LoraManagerLoader from .py.nodes.lora_loader import LoraManagerLoader
from .py.nodes.trigger_word_toggle import TriggerWordToggle from .py.nodes.trigger_word_toggle import TriggerWordToggle
from .py.nodes.lora_stacker import LoraStacker
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
LoraManagerLoader.NAME: LoraManagerLoader, LoraManagerLoader.NAME: LoraManagerLoader,
TriggerWordToggle.NAME: TriggerWordToggle TriggerWordToggle.NAME: TriggerWordToggle,
LoraStacker.NAME: LoraStacker
} }
WEB_DIRECTORY = "./web/comfyui" WEB_DIRECTORY = "./web/comfyui"

View File

@@ -8,7 +8,7 @@ from .utils import FlexibleOptionalInputType, any_type
class LoraManagerLoader: class LoraManagerLoader:
NAME = "Lora Loader (LoraManager)" NAME = "Lora Loader (LoraManager)"
CATEGORY = "loaders" CATEGORY = "Lora Manager/loaders"
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
@@ -49,11 +49,32 @@ class LoraManagerLoader:
return relative_path, trigger_words return relative_path, trigger_words
return lora_name, [] # Fallback if not found 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): 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 = [] loaded_loras = []
all_trigger_words = [] 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: if 'loras' in kwargs:
for lora in kwargs['loras']: for lora in kwargs['loras']:
if not lora.get('active', False): if not lora.get('active', False):
@@ -72,6 +93,7 @@ class LoraManagerLoader:
# Add trigger words to collection # Add trigger words to collection
all_trigger_words.extend(trigger_words) 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) return (model, clip, trigger_words_text)

91
py/nodes/lora_stacker.py Normal file
View File

@@ -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: <lora:lora_name:strength> separated by spaces or punctuation",
"placeholder": "LoRA syntax input: <lora:name:strength>"
}),
},
"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)

View File

@@ -1,17 +1,18 @@
import json import json
import re
from server import PromptServer # type: ignore from server import PromptServer # type: ignore
from .utils import FlexibleOptionalInputType, any_type from .utils import FlexibleOptionalInputType, any_type
class TriggerWordToggle: class TriggerWordToggle:
NAME = "TriggerWord Toggle (LoraManager)" NAME = "TriggerWord Toggle (LoraManager)"
CATEGORY = "lora manager" CATEGORY = "Lora Manager/utils"
DESCRIPTION = "Toggle trigger words on/off" DESCRIPTION = "Toggle trigger words on/off"
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"trigger_words": ("STRING", {"defaultInput": True, "forceInput": True}), "group_mode": ("BOOLEAN", {"default": True}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
"hidden": { "hidden": {
@@ -23,7 +24,8 @@ class TriggerWordToggle:
RETURN_NAMES = ("filtered_trigger_words",) RETURN_NAMES = ("filtered_trigger_words",)
FUNCTION = "process_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 # Send trigger words to frontend
PromptServer.instance.send_sync("trigger_word_update", { PromptServer.instance.send_sync("trigger_word_update", {
"id": id, "id": id,
@@ -41,20 +43,33 @@ class TriggerWordToggle:
if isinstance(trigger_data, str): if isinstance(trigger_data, str):
trigger_data = json.loads(trigger_data) 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} active_state = {item['text']: item.get('active', False) for item in trigger_data}
# Split original trigger words if group_mode:
original_words = [word.strip() for word in trigger_words.split(',')] # Split by two or more consecutive commas to get groups
groups = re.split(r',{2,}', trigger_words)
# Filter words: keep those not in toggle_trigger_words or those that are active # Remove leading/trailing whitespace from each group
filtered_words = [word for word in original_words if word not in active_state or active_state[word]] groups = [group.strip() for group in groups]
# Join them in the same format as input # Filter groups: keep those not in toggle_trigger_words or those that are active
if filtered_words: filtered_groups = [group for group in groups if group not in active_state or active_state[group]]
filtered_triggers = ', '.join(filtered_words)
if filtered_groups:
filtered_triggers = ', '.join(filtered_groups)
else:
filtered_triggers = ""
else: 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: except Exception as e:
print(f"Error processing trigger words: {e}") print(f"Error processing trigger words: {e}")

View File

@@ -4,6 +4,7 @@ class AnyType(str):
def __ne__(self, __value: object) -> bool: def __ne__(self, __value: object) -> bool:
return False return False
# Credit to Regis Gaughan, III (rgthree)
class FlexibleOptionalInputType(dict): class FlexibleOptionalInputType(dict):
"""A special class to make flexible nodes that pass data to our python handlers. """A special class to make flexible nodes that pass data to our python handlers.

View File

@@ -4,6 +4,8 @@ import logging
from aiohttp import web from aiohttp import web
from typing import Dict, List from typing import Dict, List
from ..utils.model_utils import determine_base_model
from ..services.file_monitor import LoraFileMonitor from ..services.file_monitor import LoraFileMonitor
from ..services.download_manager import DownloadManager from ..services.download_manager import DownloadManager
from ..services.civitai_client import CivitaiClient 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/download-lora', routes.download_lora)
app.router.add_post('/api/settings', routes.update_settings) app.router.add_post('/api/settings', routes.update_settings)
app.router.add_post('/api/move_model', routes.move_model) 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_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_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_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) app.router.add_get('/api/recipes', cls.handle_get_recipes)
# Add update check routes # Add update check routes
@@ -132,6 +136,11 @@ class ApiRoutes:
base_models = request.query.get('base_models', '').split(',') base_models = request.query.get('base_models', '').split(',')
base_models = [model.strip() for model in base_models if model.strip()] 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 # Validate parameters
if page < 1 or page_size < 1 or page_size > 100: if page < 1 or page_size < 1 or page_size > 100:
return web.json_response({ return web.json_response({
@@ -143,6 +152,10 @@ class ApiRoutes:
'error': 'Invalid sort parameter' 'error': 'Invalid sort parameter'
}, status=400) }, 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 # Get paginated data with search and filters
result = await self.scanner.get_paginated_data( result = await self.scanner.get_paginated_data(
page=page, page=page,
@@ -152,7 +165,13 @@ class ApiRoutes:
search=search, search=search,
fuzzy=fuzzy, fuzzy=fuzzy,
recursive=recursive, 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 # Format the response data
@@ -185,12 +204,15 @@ class ApiRoutes:
"model_name": lora["model_name"], "model_name": lora["model_name"],
"file_name": lora["file_name"], "file_name": lora["file_name"],
"preview_url": config.get_preview_static_url(lora["preview_url"]), "preview_url": config.get_preview_static_url(lora["preview_url"]),
"preview_nsfw_level": lora.get("preview_nsfw_level", 0),
"base_model": lora["base_model"], "base_model": lora["base_model"],
"folder": lora["folder"], "folder": lora["folder"],
"sha256": lora["sha256"], "sha256": lora["sha256"],
"file_path": lora["file_path"].replace(os.sep, "/"), "file_path": lora["file_path"].replace(os.sep, "/"),
"file_size": lora["size"], "file_size": lora["size"],
"modified": lora["modified"], "modified": lora["modified"],
"tags": lora["tags"],
"modelDescription": lora["modelDescription"],
"from_civitai": lora.get("from_civitai", True), "from_civitai": lora.get("from_civitai", True),
"usage_tips": lora.get("usage_tips", ""), "usage_tips": lora.get("usage_tips", ""),
"notes": lora.get("notes", ""), "notes": lora.get("notes", ""),
@@ -333,8 +355,16 @@ class ApiRoutes:
# Update model name if available # Update model name if available
if 'model' in civitai_metadata: if 'model' in civitai_metadata:
local_metadata['model_name'] = civitai_metadata['model'].get('name', if civitai_metadata.get('model', {}).get('name'):
local_metadata.get('model_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 # Update base model
local_metadata['base_model'] = civitai_metadata.get('baseModel') 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): if await client.download_preview_image(first_preview['url'], preview_path):
local_metadata['preview_url'] = preview_path.replace(os.sep, '/') local_metadata['preview_url'] = preview_path.replace(os.sep, '/')
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
# Save updated metadata # Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f: with open(metadata_path, 'w', encoding='utf-8') as f:
@@ -369,7 +400,7 @@ class ApiRoutes:
# 准备要处理的 loras # 准备要处理的 loras
to_process = [ to_process = [
lora for lora in cache.raw_data 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) total_to_process = len(to_process)
@@ -547,6 +578,8 @@ class ApiRoutes:
# Validate and update settings # Validate and update settings
if 'civitai_api_key' in data: if 'civitai_api_key' in data:
settings.set('civitai_api_key', data['civitai_api_key']) 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}) return web.json_response({'success': True})
except Exception as e: except Exception as e:
@@ -602,8 +635,15 @@ class ApiRoutes:
else: else:
metadata = {} metadata = {}
# Update metadata with new values # Handle nested updates (for civitai.trainedWords)
metadata.update(metadata_updates) 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 # Save updated metadata
with open(metadata_path, 'w', encoding='utf-8') as f: 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) logger.error(f"Error moving models in bulk: {e}", exc_info=True)
return web.Response(text=str(e), status=500) 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 "<p>No model description available.</p>",
'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 @staticmethod
async def handle_get_recipes(request): async def handle_get_recipes(request):
"""API endpoint for getting paginated recipes""" """API endpoint for getting paginated recipes"""

View File

@@ -28,10 +28,16 @@ class LoraRoutes:
"model_name": lora["model_name"], "model_name": lora["model_name"],
"file_name": lora["file_name"], "file_name": lora["file_name"],
"preview_url": config.get_preview_static_url(lora["preview_url"]), "preview_url": config.get_preview_static_url(lora["preview_url"]),
"preview_nsfw_level": lora.get("preview_nsfw_level", 0),
"base_model": lora["base_model"], "base_model": lora["base_model"],
"folder": lora["folder"], "folder": lora["folder"],
"sha256": lora["sha256"], "sha256": lora["sha256"],
"file_path": lora["file_path"].replace(os.sep, "/"), "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"], "modified": lora["modified"],
"from_civitai": lora.get("from_civitai", True), "from_civitai": lora.get("from_civitai", True),
"civitai": self._filter_civitai_data(lora.get("civitai", {})) "civitai": self._filter_civitai_data(lora.get("civitai", {}))

View File

@@ -163,6 +163,53 @@ class CivitaiClient:
logger.error(f"Error fetching model version info: {e}") logger.error(f"Error fetching model version info: {e}")
return None 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): async def close(self):
"""Close the session if it exists""" """Close the session if it exists"""
if self._session is not None: if self._session is not None:

View File

@@ -51,6 +51,16 @@ class DownloadManager:
# 5. 准备元数据 # 5. 准备元数据
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path) 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. 开始下载流程 # 6. 开始下载流程
result = await self._execute_download( result = await self._execute_download(
download_url=download_url, download_url=download_url,
@@ -86,6 +96,7 @@ class DownloadManager:
preview_path = os.path.splitext(save_path)[0] + '.preview' + preview_ext preview_path = os.path.splitext(save_path)[0] + '.preview' + preview_ext
if await self.civitai_client.download_preview_image(images[0]['url'], preview_path): if await self.civitai_client.download_preview_image(images[0]['url'], preview_path):
metadata.preview_url = preview_path.replace(os.sep, '/') 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: with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False) json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False)

View File

@@ -98,6 +98,10 @@ class LoraFileHandler(FileSystemEventHandler):
# 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:
# 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) cache.raw_data.append(lora_data)
new_folders.add(lora_data['folder']) new_folders.add(lora_data['folder'])
# Update hash index # Update hash index
@@ -109,6 +113,16 @@ class LoraFileHandler(FileSystemEventHandler):
needs_resort = True needs_resort = True
elif action == 'remove': 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 # Remove from cache and hash index
logger.info(f"Removing {file_path} from cache") logger.info(f"Removing {file_path} from cache")
self.scanner._hash_index.remove_by_path(file_path) self.scanner._hash_index.remove_by_path(file_path)

View File

@@ -11,6 +11,8 @@ from ..utils.file_utils import load_metadata, get_file_info
from .lora_cache import LoraCache from .lora_cache import LoraCache
from difflib import SequenceMatcher from difflib import SequenceMatcher
from .lora_hash_index import LoraHashIndex from .lora_hash_index import LoraHashIndex
from .settings_manager import settings
from ..utils.constants import NSFW_LEVELS
import sys import sys
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,6 +37,7 @@ class LoraScanner:
self._initialization_task: Optional[asyncio.Task] = None self._initialization_task: Optional[asyncio.Task] = None
self._initialized = True self._initialized = True
self.file_monitor = None # Add this line self.file_monitor = None # Add this line
self._tags_count = {} # Add a dictionary to store tag counts
def set_file_monitor(self, monitor): def set_file_monitor(self, monitor):
"""Set file monitor instance""" """Set file monitor instance"""
@@ -91,13 +94,21 @@ class LoraScanner:
# Clear existing hash index # Clear existing hash index
self._hash_index.clear() self._hash_index.clear()
# Clear existing tags count
self._tags_count = {}
# Scan for new data # Scan for new data
raw_data = await self.scan_all_loras() raw_data = await self.scan_all_loras()
# Build hash index # Build hash index and tags count
for lora_data in raw_data: for lora_data in raw_data:
if 'sha256' in lora_data and 'file_path' in lora_data: if 'sha256' in lora_data and 'file_path' in lora_data:
self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path']) 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 # Update cache
self._cache = LoraCache( self._cache = LoraCache(
@@ -159,7 +170,8 @@ 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: 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 """Get paginated and filtered lora data
Args: Args:
@@ -171,22 +183,39 @@ class LoraScanner:
fuzzy: Use fuzzy matching for search fuzzy: Use fuzzy matching for search
recursive: Include subfolders when folder filter is applied recursive: Include subfolders when folder filter is applied
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
search_options: Dictionary with search options (filename, modelname, tags)
""" """
cache = await self.get_cached_data() 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 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 folder is not None:
if recursive: if recursive:
# 递归模式:匹配所有以该文件夹开头的路径 # Recursive mode: match all paths starting with this folder
filtered_data = [ filtered_data = [
item for item in filtered_data item for item in filtered_data
if item['folder'].startswith(folder + '/') or item['folder'] == folder if item['folder'].startswith(folder + '/') or item['folder'] == folder
] ]
else: else:
# 非递归模式:只匹配确切的文件夹 # Non-recursive mode: match exact folder
filtered_data = [ filtered_data = [
item for item in filtered_data item for item in filtered_data
if item['folder'] == folder if item['folder'] == folder
@@ -199,28 +228,27 @@ class LoraScanner:
if item.get('base_model') in base_models 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 search:
if fuzzy: if fuzzy:
filtered_data = [ filtered_data = [
item for item in filtered_data item for item in filtered_data
if any( if self._fuzzy_search_match(item, search, search_options)
self.fuzzy_match(str(value), search)
for value in [
item.get('model_name', ''),
item.get('base_model', '')
]
if value
)
] ]
else: else:
# Original exact search logic
filtered_data = [ filtered_data = [
item for item in 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) total_items = len(filtered_data)
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
end_idx = min(start_idx + page_size, total_items) end_idx = min(start_idx + page_size, total_items)
@@ -235,6 +263,44 @@ class LoraScanner:
return result 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): def invalidate_cache(self):
"""Invalidate the current cache""" """Invalidate the current cache"""
self._cache = None self._cache = None
@@ -312,12 +378,86 @@ class LoraScanner:
# Convert to dict and add folder info # Convert to dict and add folder info
lora_data = metadata.to_dict() 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) rel_path = os.path.relpath(file_path, root_path)
folder = os.path.dirname(rel_path) folder = os.path.dirname(rel_path)
lora_data['folder'] = folder.replace(os.path.sep, '/') lora_data['folder'] = folder.replace(os.path.sep, '/')
return lora_data 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: async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
"""Update preview URL in cache for a specific lora """Update preview URL in cache for a specific lora
@@ -428,6 +568,15 @@ class LoraScanner:
async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool: async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
cache = await self.get_cached_data() 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 # Remove old path from hash index if exists
self._hash_index.remove_by_path(original_path) self._hash_index.remove_by_path(original_path)
@@ -461,6 +610,11 @@ class LoraScanner:
# Update folders list # Update folders list
all_folders = set(item['folder'] for item in cache.raw_data) all_folders = set(item['folder'] for item in cache.raw_data)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower()) 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 # Resort cache
await cache.resort() await cache.resort()
@@ -506,6 +660,29 @@ class LoraScanner:
"""Get hash for a LoRA by its file path""" """Get hash for a LoRA by its file path"""
return self._hash_index.get_hash(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): async def diagnose_hash_index(self):
"""Diagnostic method to verify hash index functionality""" """Diagnostic method to verify hash index functionality"""
print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr) print("\n\n*** DIAGNOSING LORA HASH INDEX ***\n\n", file=sys.stderr)

View File

@@ -37,7 +37,8 @@ class SettingsManager:
def _get_default_settings(self) -> Dict[str, Any]: def _get_default_settings(self) -> Dict[str, Any]:
"""Return default settings""" """Return default settings"""
return { return {
"civitai_api_key": "" "civitai_api_key": "",
"show_only_sfw": False
} }
def get(self, key: str, default: Any = None) -> Any: def get(self, key: str, default: Any = None) -> Any:

8
py/utils/constants.py Normal file
View File

@@ -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?
}

View File

@@ -4,6 +4,8 @@ import hashlib
import json import json
from typing import Dict, Optional from typing import Dict, Optional
from .model_utils import determine_base_model
from .lora_metadata import extract_lora_metadata from .lora_metadata import extract_lora_metadata
from .models import LoraMetadata from .models import LoraMetadata
@@ -69,6 +71,8 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]:
notes="", notes="",
from_civitai=True, from_civitai=True,
preview_url=normalize_path(preview_url), preview_url=normalize_path(preview_url),
tags=[],
modelDescription=""
) )
# create metadata file # create metadata file
@@ -103,9 +107,18 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
data = json.load(f) data = json.load(f)
needs_update = False 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']): # Compare paths without extensions
data['file_path'] = normalize_path(data['file_path']) 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 needs_update = True
preview_url = data.get('preview_url', '') 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: if new_preview_url != preview_url:
data['preview_url'] = new_preview_url data['preview_url'] = new_preview_url
needs_update = True needs_update = True
elif preview_url != normalize_path(preview_url): else:
data['preview_url'] = normalize_path(preview_url) # 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 needs_update = True
if needs_update: if needs_update:

View File

@@ -8,7 +8,8 @@ BASE_MODEL_MAPPING = {
"sd-v2": "SD 2.0", "sd-v2": "SD 2.0",
"flux1": "Flux.1 D", "flux1": "Flux.1 D",
"flux.1 d": "Flux.1 D", "flux.1 d": "Flux.1 D",
"illustrious": "IL", "illustrious": "Illustrious",
"il": "Illustrious",
"pony": "Pony", "pony": "Pony",
"Hunyuan Video": "Hunyuan Video" "Hunyuan Video": "Hunyuan Video"
} }

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Dict, Optional from typing import Dict, Optional, List
from datetime import datetime from datetime import datetime
import os import os
from .model_utils import determine_base_model from .model_utils import determine_base_model
@@ -15,10 +15,18 @@ class LoraMetadata:
sha256: str # SHA256 hash of the file sha256: str # SHA256 hash of the file
base_model: str # Base model (SD1.5/SD2.1/SDXL/etc.) base_model: str # Base model (SD1.5/SD2.1/SDXL/etc.)
preview_url: str # Preview image URL 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 usage_tips: str = "{}" # Usage tips for the model, json string
notes: str = "" # Additional notes 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 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 @classmethod
def from_dict(cls, data: Dict) -> 'LoraMetadata': def from_dict(cls, data: Dict) -> 'LoraMetadata':
@@ -42,6 +50,7 @@ class LoraMetadata:
sha256=file_info['hashes'].get('SHA256', ''), sha256=file_info['hashes'].get('SHA256', ''),
base_model=base_model, base_model=base_model,
preview_url=None, # Will be updated after preview download 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, from_civitai=True,
civitai=version_info civitai=version_info
) )

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration." description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.7.35-beta" version = "0.7.37-bugfix"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -262,6 +262,83 @@
background: var(--lora-accent); 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 */ /* Mobile optimizations */
@media (max-width: 768px) { @media (max-width: 768px) {
.selected-thumbnails-strip { .selected-thumbnails-strip {

View File

@@ -60,6 +60,96 @@
object-position: center top; /* Align the top of the image with the top of the container */ 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 { .card-footer {
position: absolute; position: absolute;
bottom: 0; bottom: 0;

View File

@@ -1,8 +1,9 @@
/* Lora Modal Header */ /* Lora Modal Header */
.modal-header { .modal-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; justify-content: flex-start;
align-items: flex-start;
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
padding-bottom: var(--space-2); padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border); border-bottom: 1px solid var(--lora-border);
@@ -162,12 +163,57 @@
border: 1px solid var(--lora-border); 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 { .trigger-words-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
align-items: flex-start; 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 */ /* Update Trigger Words styles */
@@ -181,6 +227,7 @@
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
gap: 6px; gap: 6px;
position: relative;
} }
/* Update trigger word content color to use theme accent */ /* Update trigger word content color to use theme accent */
@@ -206,6 +253,123 @@
transition: opacity 0.2s; 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 Fields */
.editable-field { .editable-field {
position: relative; position: relative;
@@ -479,4 +643,395 @@
/* Ensure close button is accessible */ /* Ensure close button is accessible */
.modal-content .close { .modal-content .close {
z-index: 10; /* Ensure close button is above other elements */ 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;
} }

View File

@@ -323,4 +323,124 @@ body.modal-open {
[data-theme="dark"] .path-preview { [data-theme="dark"] .path-preview {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border); 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);
} }

View File

@@ -237,6 +237,44 @@
border-color: var(--lora-accent); 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 */
.filter-actions { .filter-actions {
display: flex; display: flex;
@@ -276,4 +314,197 @@
right: 20px; right: 20px;
top: 140px; top: 140px;
} }
} }
/* 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%;
}

View File

@@ -153,56 +153,43 @@
border-top: 1px solid var(--lora-border); border-top: 1px solid var(--lora-border);
margin-top: var(--space-2); margin-top: var(--space-2);
padding-top: var(--space-2); padding-top: var(--space-2);
}
/* Toggle switch styles */
.toggle-switch {
display: flex; display: flex;
align-items: center; 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; cursor: pointer;
user-select: none;
} }
.toggle-switch input { .update-preferences .toggle-slider {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-slider {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 40px; width: 50px;
height: 20px; height: 24px;
background-color: var(--border-color);
border-radius: 20px;
transition: .4s;
flex-shrink: 0; flex-shrink: 0;
margin-right: 10px;
} }
.toggle-slider:before { .update-preferences .toggle-label {
position: absolute; margin-left: 0;
content: ""; white-space: nowrap;
height: 16px; line-height: 24px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
border-radius: 50%;
transition: .4s;
} }
input:checked + .toggle-slider { @media (max-width: 480px) {
background-color: var(--lora-accent); .update-preferences {
} flex-direction: row;
flex-wrap: wrap;
input:checked + .toggle-slider:before { }
transform: translateX(20px);
} .update-preferences .toggle-label {
margin-top: 5px;
.toggle-label { }
font-size: 0.9em;
color: var(--text-color);
} }

View File

@@ -32,9 +32,15 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
} }
// Add filter parameters if active // Add filter parameters if active
if (state.filters && state.filters.baseModel && state.filters.baseModel.length > 0) { if (state.filters) {
// Convert the array of base models to a comma-separated string if (state.filters.tags && state.filters.tags.length > 0) {
params.append('base_models', state.filters.baseModel.join(',')); // 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()); console.log('Loading loras with params:', params.toString());
@@ -107,7 +113,8 @@ export async function fetchCivitai() {
await state.loadingManager.showWithProgress(async (loading) => { await state.loadingManager.showWithProgress(async (loading) => {
try { 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) => { const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => { ws.onmessage = (event) => {
@@ -325,4 +332,19 @@ export async function refreshSingleLoraMetadata(filePath) {
state.loadingManager.hide(); state.loadingManager.hide();
state.loadingManager.restoreProgressBar(); 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;
}
} }

View File

@@ -1,9 +1,12 @@
import { refreshSingleLoraMetadata } from '../api/loraApi.js'; import { refreshSingleLoraMetadata } from '../api/loraApi.js';
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../utils/constants.js';
export class LoraContextMenu { export class LoraContextMenu {
constructor() { constructor() {
this.menu = document.getElementById('loraContextMenu'); this.menu = document.getElementById('loraContextMenu');
this.currentCard = null; this.currentCard = null;
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.init(); this.init();
} }
@@ -58,10 +61,274 @@ export class LoraContextMenu {
case 'refresh-metadata': case 'refresh-metadata':
refreshSingleLoraMetadata(this.currentCard.dataset.filepath); refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
break; break;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
break;
} }
this.hideMenu(); 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 = `
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
`;
// 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 = '<i class="fas fa-eye"></i>';
// 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) { showMenu(x, y, card) {

View File

@@ -2,6 +2,7 @@ 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.js';
import { bulkManager } from '../managers/BulkManager.js'; import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS } from '../utils/constants.js';
export function createLoraCard(lora) { export function createLoraCard(lora) {
const card = document.createElement('div'); const card = document.createElement('div');
@@ -18,6 +19,24 @@ export function createLoraCard(lora) {
card.dataset.usage_tips = lora.usage_tips; card.dataset.usage_tips = lora.usage_tips;
card.dataset.notes = lora.notes; card.dataset.notes = lora.notes;
card.dataset.meta = JSON.stringify(lora.civitai || {}); 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 // Apply selection state if in bulk mode and this card is in the selected set
if (state.bulkMode && state.selectedLoras.has(lora.file_path)) { 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 previewUrl = lora.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl; 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 = ` card.innerHTML = `
<div class="card-preview"> <div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${previewUrl.endsWith('.mp4') ? ${previewUrl.endsWith('.mp4') ?
`<video controls autoplay muted loop> `<video controls autoplay muted loop>
<source src="${versionedPreviewUrl}" type="video/mp4"> <source src="${versionedPreviewUrl}" type="video/mp4">
@@ -37,7 +66,11 @@ export function createLoraCard(lora) {
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">` `<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
} }
<div class="card-header"> <div class="card-header">
<span class="base-model-label" title="${lora.base_model}"> ${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${lora.base_model}">
${lora.base_model} ${lora.base_model}
</span> </span>
<div class="card-actions"> <div class="card-actions">
@@ -53,6 +86,14 @@ export function createLoraCard(lora) {
</i> </i>
</div> </div>
</div> </div>
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
<p>${nsfwText}</p>
<button class="show-content-btn">Show</button>
</div>
</div>
` : ''}
<div class="card-footer"> <div class="card-footer">
<div class="model-info"> <div class="model-info">
<span class="model-name">${lora.model_name}</span> <span class="model-name">${lora.model_name}</span>
@@ -86,12 +127,69 @@ export function createLoraCard(lora) {
base_model: card.dataset.base_model, base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips, usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes, notes: card.dataset.notes,
civitai: JSON.parse(card.dataset.meta || '{}') // Parse civitai metadata from the card's dataset
civitai: (() => {
try {
// Attempt to parse the JSON string
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: JSON.parse(card.dataset.tags || '[]'),
modelDescription: card.dataset.modelDescription || ''
}; };
showLoraModal(loraMeta); showLoraModal(loraMeta);
} }
}); });
// Toggle blur button functionality
const toggleBlurBtn = card.querySelector('.toggle-blur-btn');
if (toggleBlurBtn) {
toggleBlurBtn.addEventListener('click', (e) => {
e.stopPropagation();
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = toggleBlurBtn.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 = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = isBlurred ? 'flex' : 'none';
}
});
}
// Show content button functionality
const showContentBtn = card.querySelector('.show-content-btn');
if (showContentBtn) {
showContentBtn.addEventListener('click', (e) => {
e.stopPropagation();
const preview = card.querySelector('.card-preview');
preview.classList.remove('blurred');
// Update the toggle button icon
const toggleBtn = card.querySelector('.toggle-blur-btn');
if (toggleBtn) {
toggleBtn.querySelector('i').className = 'fas fa-eye-slash';
}
// Hide the overlay
const overlay = card.querySelector('.nsfw-overlay');
if (overlay) {
overlay.style.display = 'none';
}
});
}
// Copy button click event // Copy button click event
card.querySelector('.fa-copy')?.addEventListener('click', async e => { card.querySelector('.fa-copy')?.addEventListener('click', async e => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -1,5 +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 { NSFW_LEVELS } from '../utils/constants.js';
export function showLoraModal(lora) { export function showLoraModal(lora) {
const escapedWords = lora.civitai?.trainedWords?.length ? const escapedWords = lora.civitai?.trainedWords?.length ?
@@ -15,6 +16,7 @@ export function showLoraModal(lora) {
<i class="fas fa-save"></i> <i class="fas fa-save"></i>
</button> </button>
</div> </div>
${renderCompactTags(lora.tags || [])}
</header> </header>
<div class="modal-body"> <div class="modal-body">
@@ -66,7 +68,7 @@ export function showLoraModal(lora) {
</div> </div>
</div> </div>
</div> </div>
${renderTriggerWords(escapedWords)} ${renderTriggerWords(escapedWords, lora.file_path)}
<div class="info-item notes"> <div class="info-item notes">
<label>Additional Notes</label> <label>Additional Notes</label>
<div class="editable-field"> <div class="editable-field">
@@ -81,10 +83,35 @@ export function showLoraModal(lora) {
<div class="description-text">${lora.description || 'N/A'}</div> <div class="description-text">${lora.description || 'N/A'}</div>
</div> </div>
</div> </div>
</div> </div>
${renderShowcaseImages(lora.civitai.images)} <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>
</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>
<button class="back-to-top" onclick="scrollToTop(this)">
<i class="fas fa-arrow-up"></i>
</button>
</div>
</div> </div>
</div> </div>
`; `;
@@ -92,6 +119,231 @@ export function showLoraModal(lora) {
modalManager.showModal('loraModal', content); modalManager.showModal('loraModal', content);
setupEditableFields(); setupEditableFields();
setupShowcaseScroll(); 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 '<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";
}
if (img.type === 'video') {
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>
` : ''}
</div>
`;
}
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>
` : ''}
</div>
`;
}).join('')}
</div>
</div>
`;
}
// 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 = '<div class="no-description">No model description available</div>';
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 = `<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');
}
}
} }
// 添加复制文件名的函数 // 添加复制文件名的函数
@@ -324,85 +576,71 @@ async function saveModelMetadata(filePath, data) {
} }
} }
function renderTriggerWords(words) { function renderTriggerWords(words, filePath) {
if (!words.length) return ` if (!words.length) return `
<div class="info-item full-width trigger-words"> <div class="info-item full-width trigger-words">
<label>Trigger Words</label> <div class="trigger-words-header">
<span>No trigger word needed</span> <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> </div>
`; `;
return ` return `
<div class="info-item full-width trigger-words"> <div class="info-item full-width trigger-words">
<label>Trigger Words</label> <div class="trigger-words-header">
<div class="trigger-words-tags"> <label>Trigger Words</label>
${words.map(word => ` <button class="edit-trigger-words-btn" data-file-path="${filePath}" title="Edit trigger words">
<div class="trigger-word-tag" onclick="copyTriggerWord('${word}')"> <i class="fas fa-pencil-alt"></i>
<span class="trigger-word-content">${word}</span> </button>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
</div>
`).join('')}
</div> </div>
</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}')">
function renderShowcaseImages(images) { <span class="trigger-word-content">${word}</span>
if (!images?.length) return ''; <span class="trigger-word-copy">
<i class="fas fa-copy"></i>
return ` </span>
<div class="showcase-section"> <button class="delete-trigger-word-btn" style="display:none;" onclick="event.stopPropagation();">
<div class="scroll-indicator" onclick="toggleShowcase(this)"> <i class="fas fa-times"></i>
<i class="fas fa-chevron-down"></i> </button>
<span>Scroll or click to show ${images.length} examples</span> </div>
</div> `).join('')}
<div class="carousel collapsed">
<div class="carousel-container">
${images.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)
);
if (img.type === 'video') {
return `
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer" data-src="${img.url}"
class="lazy">
<source data-src="${img.url}" type="video/mp4">
Your browser does not support video playback
</video>
</div>
`;
}
return `
<div class="media-wrapper" style="padding-bottom: ${heightPercent}%">
<img data-src="${img.url}"
alt="Preview"
crossorigin="anonymous"
referrerpolicy="no-referrer"
width="${img.width}"
height="${img.height}"
class="lazy">
</div>
`;
}).join('')}
</div> </div>
</div> </div>
<button class="back-to-top" onclick="scrollToTop(this)"> <div class="trigger-words-edit-controls" style="display:none;">
<i class="fas fa-arrow-up"></i> <button class="add-trigger-word-btn" title="Add a trigger word">
</button> <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> </div>
`; `;
} }
@@ -420,6 +658,9 @@ export function toggleShowcase(element) {
indicator.textContent = `Scroll or click to hide examples`; indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up'); icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initLazyLoading(carousel); initLazyLoading(carousel);
// Initialize NSFW content blur toggle handlers
initNsfwBlurHandlers(carousel);
} else { } else {
const count = carousel.querySelectorAll('.media-wrapper').length; const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`; indicator.textContent = `Scroll or click to show ${count} examples`;
@@ -427,6 +668,57 @@ export function toggleShowcase(element) {
} }
} }
// New function to initialize blur toggle handlers for showcase images/videos
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';
}
});
});
}
// Add lazy loading initialization // Add lazy loading initialization
function initLazyLoading(container) { function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy'); const lazyElements = container.querySelectorAll('.lazy');
@@ -558,4 +850,315 @@ function formatFileSize(bytes) {
} }
return `${size.toFixed(1)} ${units[unitIndex]}`; return `${size.toFixed(1)} ${units[unitIndex]}`;
} }
// Add tag copy functionality
window.copyTag = async function(tag) {
try {
await navigator.clipboard.writeText(tag);
showToast('Tag copied to clipboard', 'success');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
};
// New function to render compact tags with tooltip
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>
`;
}
// Setup tooltip functionality
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');
});
}
}
// Set up trigger words edit mode
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();
});
});
}
// Function to add a new trigger 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';
}
// Function to save updated trigger words
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);
// For debugging, log the updated data to verify it's correct
console.log("Updated civitai data:", 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');
}
}
// Add copy trigger word function
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');
}
};

View File

@@ -2,7 +2,7 @@ import { debounce } from './utils/debounce.js';
import { LoadingManager } from './managers/LoadingManager.js'; 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 { state } from './state/index.js'; import { state, initSettings } from './state/index.js';
import { showLoraModal } from './components/LoraModal.js'; import { showLoraModal } from './components/LoraModal.js';
import { toggleShowcase, scrollToTop } from './components/LoraModal.js'; import { toggleShowcase, scrollToTop } from './components/LoraModal.js';
import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js'; import { loadMoreLoras, fetchCivitai, deleteModel, replacePreview, resetAndReload, refreshLoras } from './api/loraApi.js';
@@ -67,6 +67,9 @@ window.bulkManager = bulkManager;
// Initialize everything when DOM is ready // Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
// Ensure settings are initialized
initSettings();
state.loadingManager = new LoadingManager(); state.loadingManager = new LoadingManager();
modalManager.initialize(); // Initialize modalManager after DOM is loaded modalManager.initialize(); // Initialize modalManager after DOM is loaded
updateService.initialize(); // Initialize updateService after modalManager updateService.initialize(); // Initialize updateService after modalManager

View File

@@ -269,7 +269,8 @@ export class DownloadManager {
this.loadingManager.show('Downloading LoRA...', 0); this.loadingManager.show('Downloading LoRA...', 0);
// Setup WebSocket for progress updates // Setup WebSocket for progress updates
const 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`);
ws.onmessage = (event) => { ws.onmessage = (event) => {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.status === 'progress') { if (data.status === 'progress') {

View File

@@ -6,7 +6,8 @@ import { resetAndReload } from '../api/loraApi.js';
export class FilterManager { export class FilterManager {
constructor() { constructor() {
this.filters = { this.filters = {
baseModel: [] baseModel: [],
tags: []
}; };
this.filterPanel = document.getElementById('filterPanel'); this.filterPanel = document.getElementById('filterPanel');
@@ -34,6 +35,77 @@ export class FilterManager {
this.loadFiltersFromStorage(); this.loadFiltersFromStorage();
} }
async loadTopTags() {
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
}
const response = await fetch('/api/top-tags?limit=20');
if (!response.ok) throw new Error('Failed to fetch tags');
const data = await response.json();
console.log('Top tags:', data);
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
// After creating tag elements, mark any previously selected ones
this.updateTagSelections();
} else {
throw new Error('Invalid response format');
}
} catch (error) {
console.error('Error loading top tags:', error);
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-error">Failed to load tags</div>';
}
}
}
createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter');
if (!tagsContainer) return;
tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = '<div class="no-tags">No tags available</div>';
return;
}
tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
// {tag: "name", count: number}
const tagName = tag.tag;
tagEl.dataset.tag = tagName;
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
// Add click handler to toggle selection and automatically apply
tagEl.addEventListener('click', async () => {
tagEl.classList.toggle('active');
if (tagEl.classList.contains('active')) {
if (!this.filters.tags.includes(tagName)) {
this.filters.tags.push(tagName);
}
} else {
this.filters.tags = this.filters.tags.filter(t => t !== tagName);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
tagsContainer.appendChild(tagEl);
});
}
createBaseModelTags() { createBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags'); const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
@@ -69,10 +141,13 @@ export class FilterManager {
} }
toggleFilterPanel() { toggleFilterPanel() {
const wasHidden = this.filterPanel.classList.contains('hidden');
this.filterPanel.classList.toggle('hidden'); this.filterPanel.classList.toggle('hidden');
// Mark selected filters // If the panel is being opened, load the top tags and update selections
if (!this.filterPanel.classList.contains('hidden')) { if (wasHidden) {
this.loadTopTags();
this.updateTagSelections(); this.updateTagSelections();
} }
} }
@@ -92,10 +167,21 @@ export class FilterManager {
tag.classList.remove('active'); tag.classList.remove('active');
} }
}); });
// Update model tags
const modelTags = document.querySelectorAll('.tag-filter');
modelTags.forEach(tag => {
const tagName = tag.dataset.tag;
if (this.filters.tags.includes(tagName)) {
tag.classList.add('active');
} else {
tag.classList.remove('active');
}
});
} }
updateActiveFiltersCount() { updateActiveFiltersCount() {
const totalActiveFilters = this.filters.baseModel.length; const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
if (totalActiveFilters > 0) { if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters; this.activeFiltersCount.textContent = totalActiveFilters;
@@ -119,7 +205,19 @@ export class FilterManager {
if (this.hasActiveFilters()) { if (this.hasActiveFilters()) {
this.filterButton.classList.add('active'); this.filterButton.classList.add('active');
if (showToastNotification) { if (showToastNotification) {
showToast(`Filtering by ${this.filters.baseModel.length} base models`, 'success'); const baseModelCount = this.filters.baseModel.length;
const tagsCount = this.filters.tags.length;
let message = '';
if (baseModelCount > 0 && tagsCount > 0) {
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
} else if (baseModelCount > 0) {
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''}`;
} else if (tagsCount > 0) {
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
}
showToast(message, 'success');
} }
} else { } else {
this.filterButton.classList.remove('active'); this.filterButton.classList.remove('active');
@@ -132,7 +230,8 @@ export class FilterManager {
async clearFilters() { async clearFilters() {
// Clear all filters // Clear all filters
this.filters = { this.filters = {
baseModel: [] baseModel: [],
tags: []
}; };
// Update state // Update state
@@ -154,7 +253,14 @@ export class FilterManager {
const savedFilters = localStorage.getItem('loraFilters'); const savedFilters = localStorage.getItem('loraFilters');
if (savedFilters) { if (savedFilters) {
try { try {
this.filters = JSON.parse(savedFilters); const parsedFilters = JSON.parse(savedFilters);
// Ensure backward compatibility with older filter format
this.filters = {
baseModel: parsedFilters.baseModel || [],
tags: parsedFilters.tags || []
};
this.updateTagSelections(); this.updateTagSelections();
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
@@ -168,6 +274,6 @@ export class FilterManager {
} }
hasActiveFilters() { hasActiveFilters() {
return this.filters.baseModel.length > 0; return this.filters.baseModel.length > 0 || this.filters.tags.length > 0;
} }
} }

View File

@@ -1,5 +1,7 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { state, saveSettings } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
export class SettingsManager { export class SettingsManager {
constructor() { constructor() {
@@ -20,6 +22,11 @@ export class SettingsManager {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') { if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
this.isOpen = settingsModal.style.display === 'block'; this.isOpen = settingsModal.style.display === 'block';
// When modal is opened, update checkbox state from current settings
if (this.isOpen) {
this.loadSettingsToUI();
}
} }
}); });
}); });
@@ -30,6 +37,22 @@ export class SettingsManager {
this.initialized = true; this.initialized = true;
} }
loadSettingsToUI() {
// Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
if (blurMatureContentCheckbox) {
blurMatureContentCheckbox.checked = state.settings.blurMatureContent;
}
const showOnlySFWCheckbox = document.getElementById('showOnlySFW');
if (showOnlySFWCheckbox) {
// Sync with state (backend will set this via template)
state.settings.show_only_sfw = showOnlySFWCheckbox.checked;
}
// Backend settings are loaded from the template directly
}
toggleSettings() { toggleSettings() {
if (this.isOpen) { if (this.isOpen) {
modalManager.closeModal('settingsModal'); modalManager.closeModal('settingsModal');
@@ -40,16 +63,28 @@ export class SettingsManager {
} }
async saveSettings() { async saveSettings() {
// Get frontend settings from UI
const blurMatureContent = document.getElementById('blurMatureContent').checked;
// Get backend settings
const apiKey = document.getElementById('civitaiApiKey').value; const apiKey = document.getElementById('civitaiApiKey').value;
const showOnlySFW = document.getElementById('showOnlySFW').checked;
// Update frontend state and save to localStorage
state.settings.blurMatureContent = blurMatureContent;
state.settings.show_only_sfw = showOnlySFW;
saveSettings();
try { try {
// Save backend settings via API
const response = await fetch('/api/settings', { const response = await fetch('/api/settings', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
civitai_api_key: apiKey civitai_api_key: apiKey,
show_only_sfw: showOnlySFW
}) })
}); });
@@ -59,10 +94,31 @@ export class SettingsManager {
showToast('Settings saved successfully', 'success'); showToast('Settings saved successfully', 'success');
modalManager.closeModal('settingsModal'); modalManager.closeModal('settingsModal');
// Apply frontend settings immediately
this.applyFrontendSettings();
// Reload the loras without updating folders
await resetAndReload(false);
} catch (error) { } catch (error) {
showToast('Failed to save settings: ' + error.message, 'error'); showToast('Failed to save settings: ' + error.message, 'error');
} }
} }
applyFrontendSettings() {
// Apply blur setting to existing content
const blurSetting = state.settings.blurMatureContent;
document.querySelectorAll('.lora-card[data-nsfw="true"] .card-image').forEach(img => {
if (blurSetting) {
img.classList.add('nsfw-blur');
} else {
img.classList.remove('nsfw-blur');
}
});
// For show_only_sfw, there's no immediate action needed as it affects content loading
// The setting will take effect on next reload
}
} }
// Helper function for toggling API key visibility // Helper function for toggling API key visibility

View File

@@ -28,7 +28,10 @@ export class UpdateService {
} }
// Perform update check if needed // Perform update check if needed
this.checkForUpdates(); this.checkForUpdates().then(() => {
// Ensure badges are updated after checking
this.updateBadgeVisibility();
});
// Set up event listener for update button // Set up event listener for update button
const updateToggle = document.getElementById('updateToggleBtn'); const updateToggle = document.getElementById('updateToggleBtn');
@@ -43,7 +46,9 @@ export class UpdateService {
async checkForUpdates() { async checkForUpdates() {
// Check if we should perform an update check // Check if we should perform an update check
const now = Date.now(); const now = Date.now();
if (now - this.lastCheckTime < this.updateCheckInterval) { const forceCheck = this.lastCheckTime === 0;
if (!forceCheck && now - this.lastCheckTime < this.updateCheckInterval) {
// If we already have update info, just update the UI // If we already have update info, just update the UI
if (this.updateAvailable) { if (this.updateAvailable) {
this.updateBadgeVisibility(); this.updateBadgeVisibility();
@@ -61,8 +66,8 @@ export class UpdateService {
this.latestVersion = data.latest_version || "v0.0.0"; this.latestVersion = data.latest_version || "v0.0.0";
this.updateInfo = data; this.updateInfo = data;
// Determine if update is available // Explicitly set update availability based on version comparison
this.updateAvailable = data.update_available; this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion);
// Update last check time // Update last check time
this.lastCheckTime = now; this.lastCheckTime = now;
@@ -83,6 +88,37 @@ export class UpdateService {
} }
} }
// Helper method to compare version strings
isNewerVersion(latestVersion, currentVersion) {
if (!latestVersion || !currentVersion) return false;
// Remove 'v' prefix if present
const latest = latestVersion.replace(/^v/, '');
const current = currentVersion.replace(/^v/, '');
// Split version strings into components
const latestParts = latest.split(/[-\.]/);
const currentParts = current.split(/[-\.]/);
// Compare major, minor, patch versions
for (let i = 0; i < 3; i++) {
const latestNum = parseInt(latestParts[i] || '0', 10);
const currentNum = parseInt(currentParts[i] || '0', 10);
if (latestNum > currentNum) return true;
if (latestNum < currentNum) return false;
}
// If numeric versions are the same, check for beta/alpha status
const latestIsBeta = latest.includes('beta') || latest.includes('alpha');
const currentIsBeta = current.includes('beta') || current.includes('alpha');
// Release version is newer than beta/alpha
if (!latestIsBeta && currentIsBeta) return true;
return false;
}
updateBadgeVisibility() { updateBadgeVisibility() {
const updateToggle = document.querySelector('.update-toggle'); const updateToggle = document.querySelector('.update-toggle');
const updateBadge = document.querySelector('.update-toggle .update-badge'); const updateBadge = document.querySelector('.update-toggle .update-badge');
@@ -94,14 +130,17 @@ export class UpdateService {
: "Check Updates"; : "Check Updates";
} }
// Force updating badges visibility based on current state
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
if (updateBadge) { if (updateBadge) {
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
updateBadge.classList.toggle('hidden', !shouldShow); updateBadge.classList.toggle('hidden', !shouldShow);
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
} }
if (cornerBadge) { if (cornerBadge) {
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
cornerBadge.classList.toggle('hidden', !shouldShow); cornerBadge.classList.toggle('hidden', !shouldShow);
console.log("Corner badge visibility:", !shouldShow ? "hidden" : "visible");
} }
} }
@@ -194,6 +233,8 @@ export class UpdateService {
async manualCheckForUpdates() { async manualCheckForUpdates() {
this.lastCheckTime = 0; // Reset last check time to force check this.lastCheckTime = 0; // Reset last check time to force check
await this.checkForUpdates(); await this.checkForUpdates();
// Ensure badge visibility is updated after manual check
this.updateBadgeVisibility();
} }
} }

View File

@@ -8,10 +8,46 @@ export const state = {
observer: null, observer: null,
previewVersions: new Map(), previewVersions: new Map(),
searchManager: null, searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
recursive: false
},
filters: { filters: {
baseModel: [] baseModel: [],
tags: []
}, },
bulkMode: false, bulkMode: false,
selectedLoras: new Set(), selectedLoras: new Set(),
loraMetadataCache: new Map() loraMetadataCache: new Map(),
}; settings: {
blurMatureContent: true,
show_only_sfw: false
}
};
// Initialize settings from localStorage if available
export function initSettings() {
try {
const savedSettings = localStorage.getItem('loraManagerSettings');
if (savedSettings) {
const parsedSettings = JSON.parse(savedSettings);
state.settings = { ...state.settings, ...parsedSettings };
}
} catch (error) {
console.error('Error loading settings from localStorage:', error);
}
}
// Save settings to localStorage
export function saveSettings() {
try {
localStorage.setItem('loraManagerSettings', JSON.stringify(state.settings));
} catch (error) {
console.error('Error saving settings to localStorage:', error);
}
}
// Initialize settings on load
initSettings();

View File

@@ -31,7 +31,7 @@ export const BASE_MODELS = {
LUMINA: "Lumina", LUMINA: "Lumina",
KOLORS: "Kolors", KOLORS: "Kolors",
NOOBAI: "NoobAI", NOOBAI: "NoobAI",
IL: "IL", ILLUSTRIOUS: "Illustrious",
PONY: "Pony", PONY: "Pony",
// Video models // Video models
@@ -82,9 +82,19 @@ export const BASE_MODEL_CLASSES = {
[BASE_MODELS.LUMINA]: "lumina", [BASE_MODELS.LUMINA]: "lumina",
[BASE_MODELS.KOLORS]: "kolors", [BASE_MODELS.KOLORS]: "kolors",
[BASE_MODELS.NOOBAI]: "noobai", [BASE_MODELS.NOOBAI]: "noobai",
[BASE_MODELS.IL]: "il", [BASE_MODELS.ILLUSTRIOUS]: "il",
[BASE_MODELS.PONY]: "pony", [BASE_MODELS.PONY]: "pony",
// Default // Default
[BASE_MODELS.UNKNOWN]: "unknown" [BASE_MODELS.UNKNOWN]: "unknown"
};
export const NSFW_LEVELS = {
UNKNOWN: 0,
PG: 1,
PG13: 2,
R: 4,
X: 8,
XXX: 16,
BLOCKED: 32
}; };

View File

@@ -22,15 +22,8 @@ export function initializeInfiniteScroll() {
} else { } else {
const sentinel = document.createElement('div'); const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel'; sentinel.id = 'scroll-sentinel';
sentinel.style.height = '20px'; // Increase height a bit sentinel.style.height = '10px';
sentinel.style.width = '100%'; // Ensure full width
sentinel.style.position = 'relative'; // Ensure it's in the normal flow
document.getElementById('loraGrid').appendChild(sentinel); document.getElementById('loraGrid').appendChild(sentinel);
state.observer.observe(sentinel); state.observer.observe(sentinel);
} }
}
// Force layout recalculation
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 100);
}

View File

@@ -7,11 +7,12 @@ export class SearchManager {
constructor() { constructor() {
// Initialize search manager // Initialize search manager
this.searchInput = document.getElementById('searchInput'); this.searchInput = document.getElementById('searchInput');
this.searchModeToggle = document.getElementById('searchModeToggle'); this.searchOptionsToggle = document.getElementById('searchOptionsToggle');
this.searchOptionsPanel = document.getElementById('searchOptionsPanel');
this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle');
this.searchDebounceTimeout = null; this.searchDebounceTimeout = null;
this.currentSearchTerm = ''; this.currentSearchTerm = '';
this.isSearching = false; this.isSearching = false;
this.isRecursiveSearch = false;
// Add clear button // Add clear button
this.createClearButton(); this.createClearButton();
@@ -27,15 +28,19 @@ export class SearchManager {
}); });
} }
if (this.searchModeToggle) { // Initialize search options
// Initialize toggle state from localStorage or default to false this.initSearchOptions();
this.isRecursiveSearch = localStorage.getItem('recursiveSearch') === 'true'; }
this.updateToggleUI();
this.searchModeToggle.addEventListener('click', () => { initSearchOptions() {
this.isRecursiveSearch = !this.isRecursiveSearch; // Load recursive search state from localStorage
localStorage.setItem('recursiveSearch', this.isRecursiveSearch); state.searchOptions.recursive = localStorage.getItem('recursiveSearch') === 'true';
this.updateToggleUI();
if (this.recursiveSearchToggle) {
this.recursiveSearchToggle.checked = state.searchOptions.recursive;
this.recursiveSearchToggle.addEventListener('change', (e) => {
state.searchOptions.recursive = e.target.checked;
localStorage.setItem('recursiveSearch', state.searchOptions.recursive);
// Rerun search if there's an active search term // Rerun search if there's an active search term
if (this.currentSearchTerm) { if (this.currentSearchTerm) {
@@ -43,6 +48,108 @@ export class SearchManager {
} }
}); });
} }
// Setup search options toggle
if (this.searchOptionsToggle) {
this.searchOptionsToggle.addEventListener('click', () => {
this.toggleSearchOptionsPanel();
});
}
// Close button for search options panel
const closeButton = document.getElementById('closeSearchOptions');
if (closeButton) {
closeButton.addEventListener('click', () => {
this.closeSearchOptionsPanel();
});
}
// Setup search option tags
const optionTags = document.querySelectorAll('.search-option-tag');
optionTags.forEach(tag => {
const option = tag.dataset.option;
// Initialize tag state from state
tag.classList.toggle('active', state.searchOptions[option]);
tag.addEventListener('click', () => {
// Check if clicking would deselect the last active option
const activeOptions = document.querySelectorAll('.search-option-tag.active');
if (activeOptions.length === 1 && activeOptions[0] === tag) {
// Don't allow deselecting the last option and show toast
showToast('At least one search option must be selected', 'info');
return;
}
tag.classList.toggle('active');
state.searchOptions[option] = tag.classList.contains('active');
// Save to localStorage
localStorage.setItem(`searchOption_${option}`, state.searchOptions[option]);
// Rerun search if there's an active search term
if (this.currentSearchTerm) {
this.performSearch(this.currentSearchTerm);
}
});
// Load option state from localStorage or use default
const savedState = localStorage.getItem(`searchOption_${option}`);
if (savedState !== null) {
state.searchOptions[option] = savedState === 'true';
tag.classList.toggle('active', state.searchOptions[option]);
}
});
// Ensure at least one search option is selected
this.validateSearchOptions();
// Close panel when clicking outside
document.addEventListener('click', (e) => {
if (this.searchOptionsPanel &&
!this.searchOptionsPanel.contains(e.target) &&
e.target !== this.searchOptionsToggle &&
!this.searchOptionsToggle.contains(e.target)) {
this.closeSearchOptionsPanel();
}
});
}
// Add method to validate search options
validateSearchOptions() {
const hasActiveOption = Object.values(state.searchOptions)
.some(value => value === true && value !== state.searchOptions.recursive);
// If no search options are active, activate at least one default option
if (!hasActiveOption) {
state.searchOptions.filename = true;
localStorage.setItem('searchOption_filename', 'true');
// Update UI to match
const fileNameTag = document.querySelector('.search-option-tag[data-option="filename"]');
if (fileNameTag) {
fileNameTag.classList.add('active');
}
}
}
toggleSearchOptionsPanel() {
if (this.searchOptionsPanel) {
const isHidden = this.searchOptionsPanel.classList.contains('hidden');
if (isHidden) {
this.searchOptionsPanel.classList.remove('hidden');
this.searchOptionsToggle.classList.add('active');
} else {
this.closeSearchOptionsPanel();
}
}
}
closeSearchOptionsPanel() {
if (this.searchOptionsPanel) {
this.searchOptionsPanel.classList.add('hidden');
this.searchOptionsToggle.classList.remove('active');
}
} }
createClearButton() { createClearButton() {
@@ -74,21 +181,6 @@ export class SearchManager {
} }
} }
updateToggleUI() {
if (this.searchModeToggle) {
this.searchModeToggle.classList.toggle('active', this.isRecursiveSearch);
this.searchModeToggle.title = this.isRecursiveSearch
? 'Recursive folder search (including subfolders)'
: 'Current folder search only';
// Update the icon to indicate the mode
const icon = this.searchModeToggle.querySelector('i');
if (icon) {
icon.className = this.isRecursiveSearch ? 'fas fa-folder-tree' : 'fas fa-folder';
}
}
}
handleSearch(event) { handleSearch(event) {
if (this.searchDebounceTimeout) { if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout); clearTimeout(this.searchDebounceTimeout);
@@ -126,12 +218,17 @@ export class SearchManager {
url.searchParams.set('sort_by', state.sortBy); url.searchParams.set('sort_by', state.sortBy);
url.searchParams.set('search', searchTerm); url.searchParams.set('search', searchTerm);
url.searchParams.set('fuzzy', 'true'); url.searchParams.set('fuzzy', 'true');
// Add search options
url.searchParams.set('search_filename', state.searchOptions.filename.toString());
url.searchParams.set('search_modelname', state.searchOptions.modelname.toString());
url.searchParams.set('search_tags', state.searchOptions.tags.toString());
// Always send folder parameter if there is an active folder // Always send folder parameter if there is an active folder
if (state.activeFolder) { if (state.activeFolder) {
url.searchParams.set('folder', state.activeFolder); url.searchParams.set('folder', state.activeFolder);
// Add recursive parameter when recursive search is enabled // Add recursive parameter when recursive search is enabled
url.searchParams.set('recursive', this.isRecursiveSearch.toString()); url.searchParams.set('recursive', state.searchOptions.recursive.toString());
} }
const response = await fetch(url); const response = await fetch(url);

View File

@@ -151,4 +151,15 @@ export function initBackToTop() {
// Initial check // Initial check
toggleBackToTop(); toggleBackToTop();
}
export function getNSFWLevelName(level) {
if (level === 0) return 'Unknown';
if (level >= 32) return 'Blocked';
if (level >= 16) return 'XXX';
if (level >= 8) return 'X';
if (level >= 4) return 'R';
if (level >= 2) return 'PG13';
if (level >= 1) return 'PG';
return 'Unknown';
} }

View File

@@ -14,6 +14,9 @@
<div class="context-menu-item" data-action="preview"> <div class="context-menu-item" data-action="preview">
<i class="fas fa-image"></i> Replace Preview <i class="fas fa-image"></i> Replace Preview
</div> </div>
<div class="context-menu-item" data-action="set-nsfw">
<i class="fas fa-exclamation-triangle"></i> Set Content Rating
</div>
<div class="context-menu-separator"></div> <div class="context-menu-separator"></div>
<div class="context-menu-item" data-action="move"> <div class="context-menu-item" data-action="move">
<i class="fas fa-folder-open"></i> Move to Folder <i class="fas fa-folder-open"></i> Move to Folder
@@ -21,4 +24,21 @@
<div class="context-menu-item delete-item" data-action="delete"> <div class="context-menu-item delete-item" data-action="delete">
<i class="fas fa-trash"></i> Delete Model <i class="fas fa-trash"></i> Delete Model
</div> </div>
</div>
<div id="nsfwLevelSelector" class="nsfw-level-selector">
<div class="nsfw-level-header">
<h3>Set Content Rating</h3>
<button class="close-nsfw-selector"><i class="fas fa-times"></i></button>
</div>
<div class="nsfw-level-content">
<div class="current-level">Current: <span id="currentNSFWLevel">Unknown</span></div>
<div class="nsfw-level-options">
<button class="nsfw-level-btn" data-level="1">PG</button>
<button class="nsfw-level-btn" data-level="2">PG13</button>
<button class="nsfw-level-btn" data-level="4">R</button>
<button class="nsfw-level-btn" data-level="8">X</button>
<button class="nsfw-level-btn" data-level="16">XXX</button>
</div>
</div>
</div> </div>

View File

@@ -37,8 +37,8 @@
<input type="text" id="searchInput" placeholder="Search models..." /> <input type="text" id="searchInput" placeholder="Search models..." />
<!-- 清空按钮将由JavaScript动态添加到这里 --> <!-- 清空按钮将由JavaScript动态添加到这里 -->
<i class="fas fa-search search-icon"></i> <i class="fas fa-search search-icon"></i>
<button class="search-mode-toggle" id="searchModeToggle" title="Toggle recursive search in folders"> <button class="search-options-toggle" id="searchOptionsToggle" title="Search Options">
<i class="fas fa-folder"></i> <i class="fas fa-sliders-h"></i>
</button> </button>
<button class="search-filter-toggle" id="filterButton" onclick="filterManager.toggleFilterPanel()" title="Filter models"> <button class="search-filter-toggle" id="filterButton" onclick="filterManager.toggleFilterPanel()" title="Filter models">
<i class="fas fa-filter"></i> <i class="fas fa-filter"></i>
@@ -48,6 +48,33 @@
</div> </div>
</div> </div>
<!-- Add search options panel -->
<div id="searchOptionsPanel" class="search-options-panel hidden">
<div class="options-header">
<h3>Search Options</h3>
<button class="close-options-btn" id="closeSearchOptions">
<i class="fas fa-times"></i>
</button>
</div>
<div class="options-section">
<h4>Search In:</h4>
<div class="search-option-tags">
<div class="search-option-tag active" data-option="filename">Filename</div>
<div class="search-option-tag active" data-option="tags">Tags</div>
<div class="search-option-tag active" data-option="modelname">Model Name</div>
</div>
</div>
<div class="options-section">
<div class="search-option-switch">
<span>Include Subfolders</span>
<label class="switch">
<input type="checkbox" id="recursiveSearchToggle">
<span class="slider round"></span>
</label>
</div>
</div>
</div>
<!-- Add filter panel --> <!-- Add filter panel -->
<div id="filterPanel" class="filter-panel hidden"> <div id="filterPanel" class="filter-panel hidden">
<div class="filter-header"> <div class="filter-header">
@@ -62,6 +89,13 @@
<!-- Tags will be dynamically inserted here --> <!-- Tags will be dynamically inserted here -->
</div> </div>
</div> </div>
<div class="filter-section">
<h4>Tags (Top 20)</h4>
<div class="filter-tags" id="modelTagsFilter">
<!-- Top tags will be dynamically inserted here -->
<div class="tags-loading">Loading tags...</div>
</div>
</div>
<div class="filter-actions"> <div class="filter-actions">
<button class="clear-filters-btn" onclick="filterManager.clearFilters()"> <button class="clear-filters-btn" onclick="filterManager.clearFilters()">
Clear All Filters Clear All Filters

View File

@@ -147,6 +147,40 @@
Used for authentication when downloading models from Civitai Used for authentication when downloading models from Civitai
</div> </div>
</div> </div>
<div class="settings-section">
<h3>Content Filtering</h3>
<div class="setting-item">
<div class="setting-info">
<label for="blurMatureContent">Blur NSFW Content</label>
<div class="input-help">
Blur mature (NSFW) content preview images
</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="blurMatureContent" checked>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item">
<div class="setting-info">
<label for="showOnlySFW">Show Only SFW Results</label>
<div class="input-help">
Filter out all NSFW content when browsing and searching
</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="showOnlySFW" value="{{ settings.get('show_only_sfw', False) }}" {% if settings.get('show_only_sfw', False) %}checked{% endif %}>
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="primary-btn" onclick="settingsManager.saveSettings()">Save</button> <button class="primary-btn" onclick="settingsManager.saveSettings()">Save</button>

View File

@@ -86,7 +86,7 @@
{% else %} {% else %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
<!-- Lora卡片容器 --> <!-- Lora卡片容器 -->
<div class="card-grid" id="loraGrid" style="height: calc(100vh - [header-height]px); overflow-y: auto;"> <div class="card-grid" id="loraGrid">
<!-- Cards will be dynamically inserted here --> <!-- Cards will be dynamically inserted here -->
</div> </div>
<!-- Bulk operations panel will be inserted here by JavaScript --> <!-- Bulk operations panel will be inserted here by JavaScript -->

View File

@@ -35,8 +35,12 @@ app.registerExtension({
// Enable widget serialization // Enable widget serialization
node.serialize_widgets = true; node.serialize_widgets = true;
node.addInput("lora_stack", 'LORA_STACK', {
"shape": 7 // 7 is the shape of the optional input
});
// Wait for node to be properly initialized // Wait for node to be properly initialized
requestAnimationFrame(() => { requestAnimationFrame(() => {
// 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) {

114
web/comfyui/lora_stacker.js Normal file
View File

@@ -0,0 +1,114 @@
import { app } from "../../scripts/app.js";
import { addLorasWidget } from "./loras_widget.js";
// Extract pattern into a constant for consistent use
const LORA_PATTERN = /<lora:([^:]+):([-\d\.]+)>/g;
function mergeLoras(lorasText, lorasArr) {
const result = [];
let match;
// Parse text input and create initial entries
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
const name = match[1];
const inputStrength = Number(match[2]);
// Find if this lora exists in the array data
const existingLora = lorasArr.find(l => l.name === name);
result.push({
name: name,
// Use existing strength if available, otherwise use input strength
strength: existingLora ? existingLora.strength : inputStrength,
active: existingLora ? existingLora.active : true
});
}
return result;
}
app.registerExtension({
name: "LoraManager.LoraStacker",
async nodeCreated(node) {
if (node.comfyClass === "Lora Stacker (LoraManager)") {
// Enable widget serialization
node.serialize_widgets = true;
node.addInput("lora_stack", 'LORA_STACK', {
"shape": 7 // 7 is the shape of the optional input
});
// Wait for node to be properly initialized
requestAnimationFrame(() => {
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
const savedValue = node.widgets_values[1];
// TODO: clean up this code
try {
// Check if the value is already an array/object
if (typeof savedValue === 'object' && savedValue !== null) {
existingLoras = savedValue;
} else if (typeof savedValue === 'string') {
existingLoras = JSON.parse(savedValue);
}
} catch (e) {
console.warn("Failed to parse loras data:", e);
existingLoras = [];
}
}
// Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
// Add flag to prevent callback loops
let isUpdating = false;
// Get the widget object directly from the returned object
const result = addLorasWidget(node, "loras", {
defaultVal: mergedLoras // Pass object directly
}, (value) => {
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = node.widgets[0];
const currentLoras = value.map(l => l.name);
// Use the constant pattern here as well
let newText = inputWidget.value.replace(LORA_PATTERN, (match, name, strength) => {
return currentLoras.includes(name) ? match : '';
});
// Clean up multiple spaces and trim
newText = newText.replace(/\s+/g, ' ').trim();
inputWidget.value = newText;
} finally {
isUpdating = false;
}
});
node.lorasWidget = result.widget;
// Update input widget callback
const inputWidget = node.widgets[0];
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = node.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
node.lorasWidget.value = mergedLoras;
} finally {
isUpdating = false;
}
};
});
}
},
});

View File

@@ -142,13 +142,38 @@ export function addTagsWidget(node, name, opts, callback) {
}); });
}, },
getMinHeight: function() { getMinHeight: function() {
// Calculate height based on content with minimal extra space const minHeight = 150;
// Use a small buffer instead of explicit padding calculation // If no tags or only showing the empty message, return a minimum height
const buffer = 10; // Small buffer to ensure no overflow if (widgetValue.length === 0) {
return Math.max( return minHeight; // Height for empty state with message
150, }
Math.ceil((container.scrollHeight + buffer) / 5) * 5 // Round up to nearest 5px
); // 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);
}, },
}); });

View File

@@ -2,6 +2,8 @@ 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 { addTagsWidget } from "./tags_widget.js";
const CONVERTED_TYPE = 'converted-widget'
// TriggerWordToggle extension for ComfyUI // TriggerWordToggle extension for ComfyUI
app.registerExtension({ app.registerExtension({
name: "LoraManager.TriggerWordToggle", name: "LoraManager.TriggerWordToggle",
@@ -18,9 +20,13 @@ app.registerExtension({
if (node.comfyClass === "TriggerWord Toggle (LoraManager)") { if (node.comfyClass === "TriggerWord Toggle (LoraManager)") {
// Enable widget serialization // Enable widget serialization
node.serialize_widgets = true; node.serialize_widgets = true;
node.addInput("trigger_words", 'string', {
"shape": 7 // 7 is the shape of the optional input
});
// Wait for node to be properly initialized // Wait for node to be properly initialized
requestAnimationFrame(() => { requestAnimationFrame(() => {
// 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: []
@@ -28,13 +34,30 @@ app.registerExtension({
node.tagWidget = result.widget; node.tagWidget = result.widget;
// Add hidden widget to store original message
const hiddenWidget = node.addWidget('text', 'orinalMessage', '');
hiddenWidget.type = CONVERTED_TYPE;
hiddenWidget.hidden = true;
hiddenWidget.computeSize = () => [0, -4];
// Restore saved value if exists // Restore saved value if exists
if (node.widgets_values && node.widgets_values.length > 0) { if (node.widgets_values && node.widgets_values.length > 0) {
// 0 is input, 1 is tag widget // 0 is group mode, 1 is input, 2 is tag widget, 3 is original message
const savedValue = node.widgets_values[1]; const savedValue = node.widgets_values[1];
if (savedValue) { if (savedValue) {
result.widget.value = savedValue; result.widget.value = savedValue;
} }
const originalMessage = node.widgets_values[2];
if (originalMessage) {
hiddenWidget.value = originalMessage;
}
}
const groupModeWidget = node.widgets[0];
groupModeWidget.callback = (value) => {
if (node.widgets[2].value) {
this.updateTagsBasedOnMode(node, node.widgets[2].value, value);
}
} }
}); });
} }
@@ -48,31 +71,63 @@ app.registerExtension({
return; return;
} }
// Store the original message for mode switching
node.widgets[2].value = message;
if (node.tagWidget) { if (node.tagWidget) {
// Convert comma-separated message to tag object format // Parse tags based on current group mode
if (typeof message === 'string') { const groupMode = node.widgets[0] ? node.widgets[0].value : false;
// Get existing tags to preserve active states this.updateTagsBasedOnMode(node, message, groupMode);
const existingTags = node.tagWidget.value || [];
// Create a map of existing tags and their active states
const existingTagMap = {};
existingTags.forEach(tag => {
existingTagMap[tag.text] = tag.active;
});
// Process the incoming message
const tagArray = message
.split(',')
.map(word => word.trim())
.filter(word => word)
.map(word => ({
text: word,
// Keep previous active state if exists, otherwise default to true
active: existingTagMap[word] !== undefined ? existingTagMap[word] : true
}));
node.tagWidget.value = tagArray;
}
} }
}, },
// Update tags display based on group mode
updateTagsBasedOnMode(node, message, groupMode) {
if (!node.tagWidget) return;
const existingTags = node.tagWidget.value || [];
const existingTagMap = {};
// Create a map of existing tags and their active states
existingTags.forEach(tag => {
existingTagMap[tag.text] = tag.active;
});
let tagArray = [];
if (groupMode) {
if (message.trim() === '') {
tagArray = [];
}
// Group mode: split by ',,' and treat each group as a single tag
else if (message.includes(',,')) {
const groups = message.split(/,{2,}/); // Match 2 or more consecutive commas
tagArray = groups
.map(group => group.trim())
.filter(group => group)
.map(group => ({
text: group,
active: existingTagMap[group] !== undefined ? existingTagMap[group] : true
}));
} else {
// If no ',,' delimiter, treat the entire message as one group
tagArray = [{
text: message.trim(),
active: existingTagMap[message.trim()] !== undefined ? existingTagMap[message.trim()] : true
}];
}
} else {
// Normal mode: split by commas and treat each word as a separate tag
tagArray = message
.split(',')
.map(word => word.trim())
.filter(word => word)
.map(word => ({
text: word,
active: existingTagMap[word] !== undefined ? existingTagMap[word] : true
}));
}
node.tagWidget.value = tagArray;
}
}); });