mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
30 Commits
v0.7.35-be
...
v0.7.37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea0fa8471 | ||
|
|
7f088e58bc | ||
|
|
e992ace11c | ||
|
|
0cad6b5cbc | ||
|
|
e9a703451c | ||
|
|
03ddd51a91 | ||
|
|
9142cc4cde | ||
|
|
8e5e16ce68 | ||
|
|
9a4124c709 | ||
|
|
6f49a73f9c | ||
|
|
a2c51f4779 | ||
|
|
79ebe25bc2 | ||
|
|
ad56cafd62 | ||
|
|
5a6c412845 | ||
|
|
be5e8bad17 | ||
|
|
d63a70474b | ||
|
|
f48b954fb7 | ||
|
|
c48da5300e | ||
|
|
2783782669 | ||
|
|
cbb76580e4 | ||
|
|
2bdecf571b | ||
|
|
72a82707ea | ||
|
|
564e507fa9 | ||
|
|
721bef3ff8 | ||
|
|
1803a9085d | ||
|
|
c3fe58f055 | ||
|
|
0069f84630 | ||
|
|
e8e5012f0c | ||
|
|
d6ed5b7fec | ||
|
|
2186b7ee26 |
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
||||
15
README.md
15
README.md
@@ -13,6 +13,21 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.7.37
|
||||
* Added NSFW content control settings (blur mature content and SFW-only filter)
|
||||
* Implemented intelligent blur effects for previews and showcase media
|
||||
* Added manual content rating option through context menu
|
||||
* Enhanced user experience with configurable content visibility
|
||||
* Fixed various bugs and improved stability
|
||||
|
||||
### v0.7.36
|
||||
* Enhanced LoRA details view with model descriptions and tags display
|
||||
* Added tag filtering system for improved model discovery
|
||||
* Implemented editable trigger words functionality
|
||||
* Improved TriggerWord Toggle node with new group mode option for granular control
|
||||
* Added new Lora Stacker node with cross-compatibility support (works with efficiency nodes, ComfyRoll, easy-use, etc.)
|
||||
* Fixed several bugs
|
||||
|
||||
### v0.7.35-beta
|
||||
* Added base model filtering
|
||||
* Implemented bulk operations (copy syntax, move multiple LoRAs)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from .py.lora_manager import LoraManager
|
||||
from .py.nodes.lora_loader import LoraManagerLoader
|
||||
from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||
from .py.nodes.lora_stacker import LoraStacker
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
LoraManagerLoader.NAME: LoraManagerLoader,
|
||||
TriggerWordToggle.NAME: TriggerWordToggle
|
||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||
LoraStacker.NAME: LoraStacker
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
@@ -8,7 +8,7 @@ from .utils import FlexibleOptionalInputType, any_type
|
||||
|
||||
class LoraManagerLoader:
|
||||
NAME = "Lora Loader (LoraManager)"
|
||||
CATEGORY = "loaders"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -49,11 +49,32 @@ class LoraManagerLoader:
|
||||
return relative_path, trigger_words
|
||||
return lora_name, [] # Fallback if not found
|
||||
|
||||
def extract_lora_name(self, lora_path):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
basename = os.path.basename(lora_path)
|
||||
return os.path.splitext(basename)[0]
|
||||
|
||||
def load_loras(self, model, clip, text, **kwargs):
|
||||
"""Loads multiple LoRAs based on the kwargs input."""
|
||||
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
|
||||
loaded_loras = []
|
||||
all_trigger_words = []
|
||||
|
||||
lora_stack = kwargs.get('lora_stack', None)
|
||||
# First process lora_stack if available
|
||||
if lora_stack:
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
# Apply the LoRA using the provided path and strengths
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||
|
||||
# Extract lora name for trigger words lookup
|
||||
lora_name = self.extract_lora_name(lora_path)
|
||||
_, trigger_words = asyncio.run(self.get_lora_info(lora_name))
|
||||
|
||||
all_trigger_words.extend(trigger_words)
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
# Then process loras from kwargs
|
||||
if 'loras' in kwargs:
|
||||
for lora in kwargs['loras']:
|
||||
if not lora.get('active', False):
|
||||
@@ -72,6 +93,7 @@ class LoraManagerLoader:
|
||||
# Add trigger words to collection
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
trigger_words_text = ", ".join(all_trigger_words) if all_trigger_words else ""
|
||||
# use ',, ' to separate trigger words for group mode
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
return (model, clip, trigger_words_text)
|
||||
91
py/nodes/lora_stacker.py
Normal file
91
py/nodes/lora_stacker.py
Normal 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)
|
||||
@@ -1,17 +1,18 @@
|
||||
import json
|
||||
import re
|
||||
from server import PromptServer # type: ignore
|
||||
from .utils import FlexibleOptionalInputType, any_type
|
||||
|
||||
class TriggerWordToggle:
|
||||
NAME = "TriggerWord Toggle (LoraManager)"
|
||||
CATEGORY = "lora manager"
|
||||
CATEGORY = "Lora Manager/utils"
|
||||
DESCRIPTION = "Toggle trigger words on/off"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"trigger_words": ("STRING", {"defaultInput": True, "forceInput": True}),
|
||||
"group_mode": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
"hidden": {
|
||||
@@ -23,7 +24,8 @@ class TriggerWordToggle:
|
||||
RETURN_NAMES = ("filtered_trigger_words",)
|
||||
FUNCTION = "process_trigger_words"
|
||||
|
||||
def process_trigger_words(self, trigger_words, id, **kwargs):
|
||||
def process_trigger_words(self, id, group_mode, **kwargs):
|
||||
trigger_words = kwargs.get("trigger_words", "")
|
||||
# Send trigger words to frontend
|
||||
PromptServer.instance.send_sync("trigger_word_update", {
|
||||
"id": id,
|
||||
@@ -41,20 +43,33 @@ class TriggerWordToggle:
|
||||
if isinstance(trigger_data, str):
|
||||
trigger_data = json.loads(trigger_data)
|
||||
|
||||
# Create dictionaries to track active state of words
|
||||
# Create dictionaries to track active state of words or groups
|
||||
active_state = {item['text']: item.get('active', False) for item in trigger_data}
|
||||
|
||||
# Split original trigger words
|
||||
original_words = [word.strip() for word in trigger_words.split(',')]
|
||||
|
||||
# Filter words: keep those not in toggle_trigger_words or those that are active
|
||||
filtered_words = [word for word in original_words if word not in active_state or active_state[word]]
|
||||
|
||||
# Join them in the same format as input
|
||||
if filtered_words:
|
||||
filtered_triggers = ', '.join(filtered_words)
|
||||
if group_mode:
|
||||
# Split by two or more consecutive commas to get groups
|
||||
groups = re.split(r',{2,}', trigger_words)
|
||||
# Remove leading/trailing whitespace from each group
|
||||
groups = [group.strip() for group in groups]
|
||||
|
||||
# Filter groups: keep those not in toggle_trigger_words or those that are active
|
||||
filtered_groups = [group for group in groups if group not in active_state or active_state[group]]
|
||||
|
||||
if filtered_groups:
|
||||
filtered_triggers = ', '.join(filtered_groups)
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
# Original behavior for individual words mode
|
||||
original_words = [word.strip() for word in trigger_words.split(',')]
|
||||
# Filter out empty strings
|
||||
original_words = [word for word in original_words if word]
|
||||
filtered_words = [word for word in original_words if word not in active_state or active_state[word]]
|
||||
|
||||
if filtered_words:
|
||||
filtered_triggers = ', '.join(filtered_words)
|
||||
else:
|
||||
filtered_triggers = ""
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing trigger words: {e}")
|
||||
|
||||
@@ -4,6 +4,7 @@ class AnyType(str):
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
# Credit to Regis Gaughan, III (rgthree)
|
||||
class FlexibleOptionalInputType(dict):
|
||||
"""A special class to make flexible nodes that pass data to our python handlers.
|
||||
|
||||
|
||||
@@ -41,9 +41,11 @@ class ApiRoutes:
|
||||
app.router.add_post('/api/download-lora', routes.download_lora)
|
||||
app.router.add_post('/api/settings', routes.update_settings)
|
||||
app.router.add_post('/api/move_model', routes.move_model)
|
||||
app.router.add_get('/api/lora-model-description', routes.get_lora_model_description) # Add new route
|
||||
app.router.add_post('/loras/api/save-metadata', routes.save_metadata)
|
||||
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
|
||||
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
|
||||
app.router.add_get('/api/top-tags', routes.get_top_tags) # Add new route for top tags
|
||||
|
||||
# Add update check routes
|
||||
UpdateRoutes.setup_routes(app)
|
||||
@@ -130,6 +132,11 @@ class ApiRoutes:
|
||||
base_models = request.query.get('base_models', '').split(',')
|
||||
base_models = [model.strip() for model in base_models if model.strip()]
|
||||
|
||||
# Parse search options
|
||||
search_filename = request.query.get('search_filename', 'true').lower() == 'true'
|
||||
search_modelname = request.query.get('search_modelname', 'true').lower() == 'true'
|
||||
search_tags = request.query.get('search_tags', 'false').lower() == 'true'
|
||||
|
||||
# Validate parameters
|
||||
if page < 1 or page_size < 1 or page_size > 100:
|
||||
return web.json_response({
|
||||
@@ -141,6 +148,10 @@ class ApiRoutes:
|
||||
'error': 'Invalid sort parameter'
|
||||
}, status=400)
|
||||
|
||||
# Parse tags filter parameter
|
||||
tags = request.query.get('tags', '').split(',')
|
||||
tags = [tag.strip() for tag in tags if tag.strip()]
|
||||
|
||||
# Get paginated data with search and filters
|
||||
result = await self.scanner.get_paginated_data(
|
||||
page=page,
|
||||
@@ -150,7 +161,13 @@ class ApiRoutes:
|
||||
search=search,
|
||||
fuzzy=fuzzy,
|
||||
recursive=recursive,
|
||||
base_models=base_models # Pass base models filter
|
||||
base_models=base_models, # Pass base models filter
|
||||
tags=tags, # Add tags parameter
|
||||
search_options={
|
||||
'filename': search_filename,
|
||||
'modelname': search_modelname,
|
||||
'tags': search_tags
|
||||
}
|
||||
)
|
||||
|
||||
# Format the response data
|
||||
@@ -183,12 +200,15 @@ class ApiRoutes:
|
||||
"model_name": lora["model_name"],
|
||||
"file_name": lora["file_name"],
|
||||
"preview_url": config.get_preview_static_url(lora["preview_url"]),
|
||||
"preview_nsfw_level": lora.get("preview_nsfw_level", 0),
|
||||
"base_model": lora["base_model"],
|
||||
"folder": lora["folder"],
|
||||
"sha256": lora["sha256"],
|
||||
"file_path": lora["file_path"].replace(os.sep, "/"),
|
||||
"file_size": lora["size"],
|
||||
"modified": lora["modified"],
|
||||
"tags": lora["tags"],
|
||||
"modelDescription": lora["modelDescription"],
|
||||
"from_civitai": lora.get("from_civitai", True),
|
||||
"usage_tips": lora.get("usage_tips", ""),
|
||||
"notes": lora.get("notes", ""),
|
||||
@@ -334,6 +354,14 @@ class ApiRoutes:
|
||||
local_metadata['model_name'] = civitai_metadata['model'].get('name',
|
||||
local_metadata.get('model_name'))
|
||||
|
||||
# Fetch additional model metadata (description and tags) if we have model ID
|
||||
model_id = civitai_metadata['modelId']
|
||||
if model_id:
|
||||
model_metadata, _ = await client.get_model_metadata(str(model_id))
|
||||
if model_metadata:
|
||||
local_metadata['modelDescription'] = model_metadata.get('description', '')
|
||||
local_metadata['tags'] = model_metadata.get('tags', [])
|
||||
|
||||
# Update base model
|
||||
local_metadata['base_model'] = civitai_metadata.get('baseModel')
|
||||
|
||||
@@ -348,6 +376,7 @@ class ApiRoutes:
|
||||
|
||||
if await client.download_preview_image(first_preview['url'], preview_path):
|
||||
local_metadata['preview_url'] = preview_path.replace(os.sep, '/')
|
||||
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
|
||||
|
||||
# Save updated metadata
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
@@ -367,7 +396,7 @@ class ApiRoutes:
|
||||
# 准备要处理的 loras
|
||||
to_process = [
|
||||
lora for lora in cache.raw_data
|
||||
if lora.get('sha256') and not lora.get('civitai') and lora.get('from_civitai')
|
||||
if lora.get('sha256') and (not lora.get('civitai') or 'id' not in lora.get('civitai')) and lora.get('from_civitai') # TODO: for lora not from CivitAI but added traineWords
|
||||
]
|
||||
total_to_process = len(to_process)
|
||||
|
||||
@@ -545,6 +574,8 @@ class ApiRoutes:
|
||||
# Validate and update settings
|
||||
if 'civitai_api_key' in data:
|
||||
settings.set('civitai_api_key', data['civitai_api_key'])
|
||||
if 'show_only_sfw' in data:
|
||||
settings.set('show_only_sfw', data['show_only_sfw'])
|
||||
|
||||
return web.json_response({'success': True})
|
||||
except Exception as e:
|
||||
@@ -600,8 +631,15 @@ class ApiRoutes:
|
||||
else:
|
||||
metadata = {}
|
||||
|
||||
# Update metadata with new values
|
||||
metadata.update(metadata_updates)
|
||||
# Handle nested updates (for civitai.trainedWords)
|
||||
for key, value in metadata_updates.items():
|
||||
if isinstance(value, dict) and key in metadata and isinstance(metadata[key], dict):
|
||||
# Deep update for nested dictionaries
|
||||
for nested_key, nested_value in value.items():
|
||||
metadata[key][nested_key] = nested_value
|
||||
else:
|
||||
# Regular update for top-level keys
|
||||
metadata[key] = value
|
||||
|
||||
# Save updated metadata
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
@@ -691,3 +729,94 @@ class ApiRoutes:
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving models in bulk: {e}", exc_info=True)
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
async def get_lora_model_description(self, request: web.Request) -> web.Response:
|
||||
"""Get model description for a Lora model"""
|
||||
try:
|
||||
# Get parameters
|
||||
model_id = request.query.get('model_id')
|
||||
file_path = request.query.get('file_path')
|
||||
|
||||
if not model_id:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Model ID is required'
|
||||
}, status=400)
|
||||
|
||||
# Check if we already have the description stored in metadata
|
||||
description = None
|
||||
tags = []
|
||||
if file_path:
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
description = metadata.get('modelDescription')
|
||||
tags = metadata.get('tags', [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading metadata from {metadata_path}: {e}")
|
||||
|
||||
# If description is not in metadata, fetch from CivitAI
|
||||
if not description:
|
||||
logger.info(f"Fetching model metadata for model ID: {model_id}")
|
||||
model_metadata, _ = await self.civitai_client.get_model_metadata(model_id)
|
||||
|
||||
if model_metadata:
|
||||
description = model_metadata.get('description')
|
||||
tags = model_metadata.get('tags', [])
|
||||
|
||||
# Save the metadata to file if we have a file path and got metadata
|
||||
if file_path:
|
||||
try:
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
if os.path.exists(metadata_path):
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
metadata['modelDescription'] = description
|
||||
metadata['tags'] = tags
|
||||
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
logger.info(f"Saved model metadata to file for {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving model metadata: {e}")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'description': description or "<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)
|
||||
|
||||
@@ -26,10 +26,16 @@ class LoraRoutes:
|
||||
"model_name": lora["model_name"],
|
||||
"file_name": lora["file_name"],
|
||||
"preview_url": config.get_preview_static_url(lora["preview_url"]),
|
||||
"preview_nsfw_level": lora.get("preview_nsfw_level", 0),
|
||||
"base_model": lora["base_model"],
|
||||
"folder": lora["folder"],
|
||||
"sha256": lora["sha256"],
|
||||
"file_path": lora["file_path"].replace(os.sep, "/"),
|
||||
"size": lora["size"],
|
||||
"tags": lora["tags"],
|
||||
"modelDescription": lora["modelDescription"],
|
||||
"usage_tips": lora["usage_tips"],
|
||||
"notes": lora["notes"],
|
||||
"modified": lora["modified"],
|
||||
"from_civitai": lora.get("from_civitai", True),
|
||||
"civitai": self._filter_civitai_data(lora.get("civitai", {}))
|
||||
|
||||
@@ -163,6 +163,53 @@ class CivitaiClient:
|
||||
logger.error(f"Error fetching model version info: {e}")
|
||||
return None
|
||||
|
||||
async def get_model_metadata(self, model_id: str) -> Tuple[Optional[Dict], int]:
|
||||
"""Fetch model metadata (description and tags) from Civitai API
|
||||
|
||||
Args:
|
||||
model_id: The Civitai model ID
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[Dict], int]: A tuple containing:
|
||||
- A dictionary with model metadata or None if not found
|
||||
- The HTTP status code from the request
|
||||
"""
|
||||
try:
|
||||
session = await self.session
|
||||
headers = self._get_request_headers()
|
||||
url = f"{self.base_url}/models/{model_id}"
|
||||
|
||||
async with session.get(url, headers=headers) as response:
|
||||
status_code = response.status
|
||||
|
||||
if status_code != 200:
|
||||
logger.warning(f"Failed to fetch model metadata: Status {status_code}")
|
||||
return None, status_code
|
||||
|
||||
data = await response.json()
|
||||
|
||||
# Extract relevant metadata
|
||||
metadata = {
|
||||
"description": data.get("description") or "No model description available",
|
||||
"tags": data.get("tags", [])
|
||||
}
|
||||
|
||||
if metadata["description"] or metadata["tags"]:
|
||||
return metadata, status_code
|
||||
else:
|
||||
logger.warning(f"No metadata found for model {model_id}")
|
||||
return None, status_code
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model metadata: {e}", exc_info=True)
|
||||
return None, 0
|
||||
|
||||
# Keep old method for backward compatibility, delegating to the new one
|
||||
async def get_model_description(self, model_id: str) -> Optional[str]:
|
||||
"""Fetch the model description from Civitai API (Legacy method)"""
|
||||
metadata, _ = await self.get_model_metadata(model_id)
|
||||
return metadata.get("description") if metadata else None
|
||||
|
||||
async def close(self):
|
||||
"""Close the session if it exists"""
|
||||
if self._session is not None:
|
||||
|
||||
@@ -51,6 +51,16 @@ class DownloadManager:
|
||||
# 5. 准备元数据
|
||||
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
|
||||
# 5.1 获取并更新模型标签和描述信息
|
||||
model_id = version_info.get('modelId')
|
||||
if model_id:
|
||||
model_metadata, _ = await self.civitai_client.get_model_metadata(str(model_id))
|
||||
if model_metadata:
|
||||
if model_metadata.get("tags"):
|
||||
metadata.tags = model_metadata.get("tags", [])
|
||||
if model_metadata.get("description"):
|
||||
metadata.modelDescription = model_metadata.get("description", "")
|
||||
|
||||
# 6. 开始下载流程
|
||||
result = await self._execute_download(
|
||||
download_url=download_url,
|
||||
@@ -86,6 +96,7 @@ class DownloadManager:
|
||||
preview_path = os.path.splitext(save_path)[0] + '.preview' + preview_ext
|
||||
if await self.civitai_client.download_preview_image(images[0]['url'], preview_path):
|
||||
metadata.preview_url = preview_path.replace(os.sep, '/')
|
||||
metadata.preview_nsfw_level = images[0].get('nsfwLevel', 0)
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
# Scan new file
|
||||
lora_data = await self.scanner.scan_single_lora(file_path)
|
||||
if lora_data:
|
||||
# Update tags count
|
||||
for tag in lora_data.get('tags', []):
|
||||
self.scanner._tags_count[tag] = self.scanner._tags_count.get(tag, 0) + 1
|
||||
|
||||
cache.raw_data.append(lora_data)
|
||||
new_folders.add(lora_data['folder'])
|
||||
# Update hash index
|
||||
@@ -109,6 +113,16 @@ class LoraFileHandler(FileSystemEventHandler):
|
||||
needs_resort = True
|
||||
|
||||
elif action == 'remove':
|
||||
# Find the lora to remove so we can update tags count
|
||||
lora_to_remove = next((item for item in cache.raw_data if item['file_path'] == file_path), None)
|
||||
if lora_to_remove:
|
||||
# Update tags count by reducing counts
|
||||
for tag in lora_to_remove.get('tags', []):
|
||||
if tag in self.scanner._tags_count:
|
||||
self.scanner._tags_count[tag] = max(0, self.scanner._tags_count[tag] - 1)
|
||||
if self.scanner._tags_count[tag] == 0:
|
||||
del self.scanner._tags_count[tag]
|
||||
|
||||
# Remove from cache and hash index
|
||||
logger.info(f"Removing {file_path} from cache")
|
||||
self.scanner._hash_index.remove_by_path(file_path)
|
||||
|
||||
@@ -11,6 +11,8 @@ from ..utils.file_utils import load_metadata, get_file_info
|
||||
from .lora_cache import LoraCache
|
||||
from difflib import SequenceMatcher
|
||||
from .lora_hash_index import LoraHashIndex
|
||||
from .settings_manager import settings
|
||||
from ..utils.constants import NSFW_LEVELS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +36,7 @@ class LoraScanner:
|
||||
self._initialization_task: Optional[asyncio.Task] = None
|
||||
self._initialized = True
|
||||
self.file_monitor = None # Add this line
|
||||
self._tags_count = {} # Add a dictionary to store tag counts
|
||||
|
||||
def set_file_monitor(self, monitor):
|
||||
"""Set file monitor instance"""
|
||||
@@ -90,13 +93,21 @@ class LoraScanner:
|
||||
# Clear existing hash index
|
||||
self._hash_index.clear()
|
||||
|
||||
# Clear existing tags count
|
||||
self._tags_count = {}
|
||||
|
||||
# Scan for new data
|
||||
raw_data = await self.scan_all_loras()
|
||||
|
||||
# Build hash index
|
||||
# Build hash index and tags count
|
||||
for lora_data in raw_data:
|
||||
if 'sha256' in lora_data and 'file_path' in lora_data:
|
||||
self._hash_index.add_entry(lora_data['sha256'], lora_data['file_path'])
|
||||
|
||||
# Count tags
|
||||
if 'tags' in lora_data and lora_data['tags']:
|
||||
for tag in lora_data['tags']:
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
# Update cache
|
||||
self._cache = LoraCache(
|
||||
@@ -158,7 +169,8 @@ class LoraScanner:
|
||||
|
||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
|
||||
folder: str = None, search: str = None, fuzzy: bool = False,
|
||||
recursive: bool = False, base_models: list = None):
|
||||
recursive: bool = False, base_models: list = None, tags: list = None,
|
||||
search_options: dict = None) -> Dict:
|
||||
"""Get paginated and filtered lora data
|
||||
|
||||
Args:
|
||||
@@ -170,22 +182,39 @@ class LoraScanner:
|
||||
fuzzy: Use fuzzy matching for search
|
||||
recursive: Include subfolders when folder filter is applied
|
||||
base_models: List of base models to filter by
|
||||
tags: List of tags to filter by
|
||||
search_options: Dictionary with search options (filename, modelname, tags)
|
||||
"""
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# 先获取基础数据集
|
||||
# Get default search options if not provided
|
||||
if search_options is None:
|
||||
search_options = {
|
||||
'filename': True,
|
||||
'modelname': True,
|
||||
'tags': False
|
||||
}
|
||||
|
||||
# Get the base data set
|
||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
||||
|
||||
# 应用文件夹过滤
|
||||
# Apply SFW filtering if enabled
|
||||
if settings.get('show_only_sfw', False):
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if not item.get('preview_nsfw_level') or item.get('preview_nsfw_level') < NSFW_LEVELS['R']
|
||||
]
|
||||
|
||||
# Apply folder filtering
|
||||
if folder is not None:
|
||||
if recursive:
|
||||
# 递归模式:匹配所有以该文件夹开头的路径
|
||||
# Recursive mode: match all paths starting with this folder
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'].startswith(folder + '/') or item['folder'] == folder
|
||||
]
|
||||
else:
|
||||
# 非递归模式:只匹配确切的文件夹
|
||||
# Non-recursive mode: match exact folder
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if item['folder'] == folder
|
||||
@@ -198,28 +227,27 @@ class LoraScanner:
|
||||
if item.get('base_model') in base_models
|
||||
]
|
||||
|
||||
# 应用搜索过滤
|
||||
# Apply tag filtering
|
||||
if tags and len(tags) > 0:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(tag in item.get('tags', []) for tag in tags)
|
||||
]
|
||||
|
||||
# Apply search filtering
|
||||
if search:
|
||||
if fuzzy:
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if any(
|
||||
self.fuzzy_match(str(value), search)
|
||||
for value in [
|
||||
item.get('model_name', ''),
|
||||
item.get('base_model', '')
|
||||
]
|
||||
if value
|
||||
)
|
||||
if self._fuzzy_search_match(item, search, search_options)
|
||||
]
|
||||
else:
|
||||
# Original exact search logic
|
||||
filtered_data = [
|
||||
item for item in filtered_data
|
||||
if search in str(item.get('model_name', '')).lower()
|
||||
if self._exact_search_match(item, search, search_options)
|
||||
]
|
||||
|
||||
# 计算分页
|
||||
# Calculate pagination
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = min(start_idx + page_size, total_items)
|
||||
@@ -234,6 +262,44 @@ class LoraScanner:
|
||||
|
||||
return result
|
||||
|
||||
def _fuzzy_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
|
||||
"""Check if an item matches the search term using fuzzy matching with search options"""
|
||||
# Check filename if enabled
|
||||
if search_options.get('filename', True) and self.fuzzy_match(item.get('file_name', ''), search):
|
||||
return True
|
||||
|
||||
# Check model name if enabled
|
||||
if search_options.get('modelname', True) and self.fuzzy_match(item.get('model_name', ''), search):
|
||||
return True
|
||||
|
||||
# Check tags if enabled
|
||||
if search_options.get('tags', False) and item.get('tags'):
|
||||
for tag in item['tags']:
|
||||
if self.fuzzy_match(tag, search):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _exact_search_match(self, item: Dict, search: str, search_options: Dict) -> bool:
|
||||
"""Check if an item matches the search term using exact matching with search options"""
|
||||
search = search.lower()
|
||||
|
||||
# Check filename if enabled
|
||||
if search_options.get('filename', True) and search in item.get('file_name', '').lower():
|
||||
return True
|
||||
|
||||
# Check model name if enabled
|
||||
if search_options.get('modelname', True) and search in item.get('model_name', '').lower():
|
||||
return True
|
||||
|
||||
# Check tags if enabled
|
||||
if search_options.get('tags', False) and item.get('tags'):
|
||||
for tag in item['tags']:
|
||||
if search in tag.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def invalidate_cache(self):
|
||||
"""Invalidate the current cache"""
|
||||
self._cache = None
|
||||
@@ -311,12 +377,86 @@ class LoraScanner:
|
||||
|
||||
# Convert to dict and add folder info
|
||||
lora_data = metadata.to_dict()
|
||||
# Try to fetch missing metadata from Civitai if needed
|
||||
await self._fetch_missing_metadata(file_path, lora_data)
|
||||
rel_path = os.path.relpath(file_path, root_path)
|
||||
folder = os.path.dirname(rel_path)
|
||||
lora_data['folder'] = folder.replace(os.path.sep, '/')
|
||||
|
||||
return lora_data
|
||||
|
||||
async def _fetch_missing_metadata(self, file_path: str, lora_data: Dict) -> None:
|
||||
"""Fetch missing description and tags from Civitai if needed
|
||||
|
||||
Args:
|
||||
file_path: Path to the lora file
|
||||
lora_data: Lora metadata dictionary to update
|
||||
"""
|
||||
try:
|
||||
# Skip if already marked as deleted on Civitai
|
||||
if lora_data.get('civitai_deleted', False):
|
||||
logger.debug(f"Skipping metadata fetch for {file_path}: marked as deleted on Civitai")
|
||||
return
|
||||
|
||||
# Check if we need to fetch additional metadata from Civitai
|
||||
needs_metadata_update = False
|
||||
model_id = None
|
||||
|
||||
# Check if we have Civitai model ID but missing metadata
|
||||
if lora_data.get('civitai'):
|
||||
# Try to get model ID directly from the correct location
|
||||
model_id = lora_data['civitai'].get('modelId')
|
||||
|
||||
if model_id:
|
||||
model_id = str(model_id)
|
||||
# Check if tags are missing or empty
|
||||
tags_missing = not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0
|
||||
|
||||
# Check if description is missing or empty
|
||||
desc_missing = not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")
|
||||
|
||||
needs_metadata_update = tags_missing or desc_missing
|
||||
|
||||
# Fetch missing metadata if needed
|
||||
if needs_metadata_update and model_id:
|
||||
logger.debug(f"Fetching missing metadata for {file_path} with model ID {model_id}")
|
||||
from ..services.civitai_client import CivitaiClient
|
||||
client = CivitaiClient()
|
||||
|
||||
# Get metadata and status code
|
||||
model_metadata, status_code = await client.get_model_metadata(model_id)
|
||||
await client.close()
|
||||
|
||||
# Handle 404 status (model deleted from Civitai)
|
||||
if status_code == 404:
|
||||
logger.warning(f"Model {model_id} appears to be deleted from Civitai (404 response)")
|
||||
# Mark as deleted to avoid future API calls
|
||||
lora_data['civitai_deleted'] = True
|
||||
|
||||
# Save the updated metadata back to file
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(lora_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# Process valid metadata if available
|
||||
elif model_metadata:
|
||||
logger.debug(f"Updating metadata for {file_path} with model ID {model_id}")
|
||||
|
||||
# Update tags if they were missing
|
||||
if model_metadata.get('tags') and (not lora_data.get('tags') or len(lora_data.get('tags', [])) == 0):
|
||||
lora_data['tags'] = model_metadata['tags']
|
||||
|
||||
# Update description if it was missing
|
||||
if model_metadata.get('description') and (not lora_data.get('modelDescription') or lora_data.get('modelDescription') in (None, "")):
|
||||
lora_data['modelDescription'] = model_metadata['description']
|
||||
|
||||
# Save the updated metadata back to file
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(lora_data, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")
|
||||
|
||||
async def update_preview_in_cache(self, file_path: str, preview_url: str) -> bool:
|
||||
"""Update preview URL in cache for a specific lora
|
||||
|
||||
@@ -427,6 +567,15 @@ class LoraScanner:
|
||||
async def update_single_lora_cache(self, original_path: str, new_path: str, metadata: Dict) -> bool:
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
# Find the existing item to remove its tags from count
|
||||
existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
|
||||
if existing_item and 'tags' in existing_item:
|
||||
for tag in existing_item.get('tags', []):
|
||||
if tag in self._tags_count:
|
||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||
if self._tags_count[tag] == 0:
|
||||
del self._tags_count[tag]
|
||||
|
||||
# Remove old path from hash index if exists
|
||||
self._hash_index.remove_by_path(original_path)
|
||||
|
||||
@@ -460,6 +609,11 @@ class LoraScanner:
|
||||
# Update folders list
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# Update tags count with the new/updated tags
|
||||
if 'tags' in metadata:
|
||||
for tag in metadata.get('tags', []):
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
# Resort cache
|
||||
await cache.resort()
|
||||
@@ -505,3 +659,26 @@ class LoraScanner:
|
||||
"""Get hash for a LoRA by its file path"""
|
||||
return self._hash_index.get_hash(file_path)
|
||||
|
||||
# Add new method to get top tags
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
"""Get top tags sorted by count
|
||||
|
||||
Args:
|
||||
limit: Maximum number of tags to return
|
||||
|
||||
Returns:
|
||||
List of dictionaries with tag name and count, sorted by count
|
||||
"""
|
||||
# Make sure cache is initialized
|
||||
await self.get_cached_data()
|
||||
|
||||
# Sort tags by count in descending order
|
||||
sorted_tags = sorted(
|
||||
[{"tag": tag, "count": count} for tag, count in self._tags_count.items()],
|
||||
key=lambda x: x['count'],
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# Return limited number
|
||||
return sorted_tags[:limit]
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ class SettingsManager:
|
||||
def _get_default_settings(self) -> Dict[str, Any]:
|
||||
"""Return default settings"""
|
||||
return {
|
||||
"civitai_api_key": ""
|
||||
"civitai_api_key": "",
|
||||
"show_only_sfw": False
|
||||
}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
|
||||
8
py/utils/constants.py
Normal file
8
py/utils/constants.py
Normal 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?
|
||||
}
|
||||
@@ -69,6 +69,8 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]:
|
||||
notes="",
|
||||
from_civitai=True,
|
||||
preview_url=normalize_path(preview_url),
|
||||
tags=[],
|
||||
modelDescription=""
|
||||
)
|
||||
|
||||
# create metadata file
|
||||
@@ -104,8 +106,11 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
|
||||
|
||||
needs_update = False
|
||||
|
||||
if data['file_path'] != normalize_path(data['file_path']):
|
||||
data['file_path'] = normalize_path(data['file_path'])
|
||||
# Compare paths without extensions
|
||||
stored_path_base = os.path.splitext(data['file_path'])[0]
|
||||
current_path_base = os.path.splitext(normalize_path(file_path))[0]
|
||||
if stored_path_base != current_path_base:
|
||||
data['file_path'] = normalize_path(file_path)
|
||||
needs_update = True
|
||||
|
||||
preview_url = data.get('preview_url', '')
|
||||
@@ -116,8 +121,21 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
|
||||
if new_preview_url != preview_url:
|
||||
data['preview_url'] = new_preview_url
|
||||
needs_update = True
|
||||
elif preview_url != normalize_path(preview_url):
|
||||
data['preview_url'] = normalize_path(preview_url)
|
||||
else:
|
||||
# Compare preview paths without extensions
|
||||
stored_preview_base = os.path.splitext(preview_url)[0]
|
||||
current_preview_base = os.path.splitext(normalize_path(preview_url))[0]
|
||||
if stored_preview_base != current_preview_base:
|
||||
data['preview_url'] = normalize_path(preview_url)
|
||||
needs_update = True
|
||||
|
||||
# Ensure all fields are present
|
||||
if 'tags' not in data:
|
||||
data['tags'] = []
|
||||
needs_update = True
|
||||
|
||||
if 'modelDescription' not in data:
|
||||
data['modelDescription'] = ""
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime
|
||||
import os
|
||||
from .model_utils import determine_base_model
|
||||
@@ -15,10 +15,18 @@ class LoraMetadata:
|
||||
sha256: str # SHA256 hash of the file
|
||||
base_model: str # Base model (SD1.5/SD2.1/SDXL/etc.)
|
||||
preview_url: str # Preview image URL
|
||||
preview_nsfw_level: int = 0 # NSFW level of the preview image
|
||||
usage_tips: str = "{}" # Usage tips for the model, json string
|
||||
notes: str = "" # Additional notes
|
||||
from_civitai: bool = True # Whether the lora is from Civitai
|
||||
from_civitai: bool = True # Whether the lora is from Civitai
|
||||
civitai: Optional[Dict] = None # Civitai API data if available
|
||||
tags: List[str] = None # Model tags
|
||||
modelDescription: str = "" # Full model description
|
||||
|
||||
def __post_init__(self):
|
||||
# Initialize empty lists to avoid mutable default parameter issue
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict) -> 'LoraMetadata':
|
||||
@@ -42,6 +50,7 @@ class LoraMetadata:
|
||||
sha256=file_info['hashes'].get('SHA256', ''),
|
||||
base_model=base_model,
|
||||
preview_url=None, # Will be updated after preview download
|
||||
preview_nsfw_level=0, # Will be updated after preview download, it is decided by the nsfw level of the preview image
|
||||
from_civitai=True,
|
||||
civitai=version_info
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
|
||||
version = "0.7.35-beta"
|
||||
version = "0.7.37"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -262,6 +262,83 @@
|
||||
background: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* NSFW Level Selector */
|
||||
.nsfw-level-selector {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
z-index: var(--z-modal);
|
||||
width: 300px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nsfw-level-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.nsfw-level-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-nsfw-selector {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.close-nsfw-selector:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.current-level {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: var(--bg-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.nsfw-level-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nsfw-level-btn {
|
||||
flex: 1 0 calc(33% - 8px);
|
||||
padding: 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nsfw-level-btn:hover {
|
||||
background: var(--lora-border);
|
||||
}
|
||||
|
||||
.nsfw-level-btn.active {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 768px) {
|
||||
.selected-thumbnails-strip {
|
||||
|
||||
@@ -57,6 +57,97 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
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 {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* Lora Modal Header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
@@ -162,12 +163,57 @@
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
/* New header style for trigger words */
|
||||
.trigger-words-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.edit-trigger-words-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-trigger-words-btn:hover {
|
||||
opacity: 0.8;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .edit-trigger-words-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Edit mode active state */
|
||||
.trigger-words.edit-mode .edit-trigger-words-btn {
|
||||
opacity: 0.8;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.trigger-words-content {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.trigger-words-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* No trigger words message */
|
||||
.no-trigger-words {
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Update Trigger Words styles */
|
||||
@@ -181,6 +227,7 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Update trigger word content color to use theme accent */
|
||||
@@ -206,6 +253,123 @@
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
/* Delete button for trigger word */
|
||||
.delete-trigger-word-btn {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--lora-error);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-trigger-word-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Edit controls */
|
||||
.trigger-words-edit-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.trigger-words-edit-controls button {
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.trigger-words-edit-controls button:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.trigger-words-edit-controls button i {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.save-trigger-words-btn {
|
||||
background: var(--lora-accent) !important;
|
||||
color: white !important;
|
||||
border-color: var(--lora-accent) !important;
|
||||
}
|
||||
|
||||
.save-trigger-words-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Add trigger word form */
|
||||
.add-trigger-word-form {
|
||||
margin-top: var(--space-2);
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.new-trigger-word-input {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.new-trigger-word-input:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.confirm-add-trigger-word-btn,
|
||||
.cancel-add-trigger-word-btn {
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.confirm-add-trigger-word-btn {
|
||||
background: var(--lora-accent);
|
||||
color: white;
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.confirm-add-trigger-word-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.cancel-add-trigger-word-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cancel-add-trigger-word-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Editable Fields */
|
||||
.editable-field {
|
||||
position: relative;
|
||||
@@ -479,4 +643,395 @@
|
||||
/* Ensure close button is accessible */
|
||||
.modal-content .close {
|
||||
z-index: 10; /* Ensure close button is above other elements */
|
||||
}
|
||||
|
||||
/* Tab System Styling */
|
||||
.showcase-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--lora-border);
|
||||
margin-bottom: var(--space-2);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.95em;
|
||||
transition: all 0.2s;
|
||||
opacity: 0.7;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
opacity: 1;
|
||||
background: oklch(var(--lora-accent) / 0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
border-bottom: 2px solid var(--lora-accent);
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
position: relative;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.tab-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-pane.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Model Description Styling */
|
||||
.model-description-container {
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
/* Remove the max-height and overflow-y to allow content to expand naturally */
|
||||
}
|
||||
|
||||
.model-description-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.model-description-loading .fa-spinner {
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.model-description-content {
|
||||
padding: var(--space-2);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.model-description-content h1,
|
||||
.model-description-content h2,
|
||||
.model-description-content h3,
|
||||
.model-description-content h4,
|
||||
.model-description-content h5,
|
||||
.model-description-content h6 {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-description-content p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.model-description-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--border-radius-xs);
|
||||
display: block;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.model-description-content pre {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-1);
|
||||
white-space: pre-wrap;
|
||||
margin: 1em 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.model-description-content code {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.model-description-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.model-description-content ul,
|
||||
.model-description-content ol {
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.model-description-content li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.model-description-content blockquote {
|
||||
border-left: 3px solid var(--lora-accent);
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Adjust dark mode for model description */
|
||||
[data-theme="dark"] .model-description-content pre,
|
||||
[data-theme="dark"] .model-description-content code {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--lora-error);
|
||||
text-align: center;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.no-examples {
|
||||
text-align: center;
|
||||
padding: var(--space-3);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Adjust the media wrapper for tab system */
|
||||
#showcase-tab .carousel-container {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Enhanced Model Description Styling */
|
||||
.model-description-container {
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
position: relative;
|
||||
/* Remove the max-height and overflow-y to allow content to expand naturally */
|
||||
}
|
||||
|
||||
.model-description-content {
|
||||
padding: var(--space-2);
|
||||
line-height: 1.5;
|
||||
overflow-wrap: break-word;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.model-description-content h1,
|
||||
.model-description-content h2,
|
||||
.model-description-content h3,
|
||||
.model-description-content h4,
|
||||
.model-description-content h5,
|
||||
.model-description-content h6 {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.model-description-content p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.model-description-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--border-radius-xs);
|
||||
display: block;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.model-description-content pre {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: var(--space-1);
|
||||
white-space: pre-wrap;
|
||||
margin: 1em 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.model-description-content code {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.model-description-content pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.model-description-content ul,
|
||||
.model-description-content ol {
|
||||
margin-left: 1.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.model-description-content li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.model-description-content blockquote {
|
||||
border-left: 3px solid var (--lora-accent);
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Adjust dark mode for model description */
|
||||
[data-theme="dark"] .model-description-content pre,
|
||||
[data-theme="dark"] .model-description-content code {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Model Tags styles */
|
||||
.model-tags {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.model-tag {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Updated Model Tags styles - improved visibility in light theme */
|
||||
.model-tags-container {
|
||||
position: relative;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.model-tags-compact {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.model-tag-compact {
|
||||
/* Updated styles to match info-item appearance */
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Adjust dark theme tag styles */
|
||||
[data-theme="dark"] .model-tag-compact {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.model-tag-more {
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.75em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-tags-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); /* Enhanced shadow for better visibility */
|
||||
padding: 10px 14px;
|
||||
max-width: 400px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-4px);
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.model-tags-tooltip.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto; /* Enable interactions when visible */
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tooltip-tag {
|
||||
/* Updated styles to match info-item appearance */
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 3px 8px;
|
||||
font-size: 0.75em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Adjust dark theme tooltip tag styles */
|
||||
[data-theme="dark"] .tooltip-tag {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
/* Add styles for blurred showcase content */
|
||||
.nsfw-media-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.media-wrapper img.blurred,
|
||||
.media-wrapper video.blurred {
|
||||
filter: blur(25px);
|
||||
}
|
||||
|
||||
.media-wrapper .nsfw-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Position the toggle button at the top left of showcase media */
|
||||
.showcase-toggle-btn {
|
||||
position: absolute;
|
||||
left: var(--space-1);
|
||||
top: var(--space-1);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* Make sure media wrapper maintains position: relative for absolute positioning of children */
|
||||
.carousel .media-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
@@ -323,4 +323,124 @@ body.modal-open {
|
||||
[data-theme="dark"] .path-preview {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
/* Settings Styles */
|
||||
.settings-section {
|
||||
margin-top: var(--space-3);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--text-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--space-2);
|
||||
padding: var(--space-1);
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.setting-item:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .setting-item:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-info label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
padding-left: var(--space-2);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--border-color);
|
||||
transition: .3s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider {
|
||||
background-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
input:checked + .toggle-slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
margin-left: 60px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* Add small animation for the toggle */
|
||||
.toggle-slider:active:before {
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
/* Update input help styles */
|
||||
.input-help {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Blur effect for NSFW content */
|
||||
.nsfw-blur {
|
||||
filter: blur(12px);
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
.nsfw-blur:hover {
|
||||
filter: blur(8px);
|
||||
}
|
||||
@@ -237,6 +237,44 @@
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Tag filter styles */
|
||||
.tag-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.tag-count {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8em;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tag-count {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tag-filter.active .tag-count {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tags-loading, .tags-error, .no-tags {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tags-error {
|
||||
color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Filter actions */
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
@@ -276,4 +314,197 @@
|
||||
right: 20px;
|
||||
top: 140px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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%;
|
||||
}
|
||||
@@ -32,9 +32,15 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
|
||||
}
|
||||
|
||||
// Add filter parameters if active
|
||||
if (state.filters && state.filters.baseModel && state.filters.baseModel.length > 0) {
|
||||
// Convert the array of base models to a comma-separated string
|
||||
params.append('base_models', state.filters.baseModel.join(','));
|
||||
if (state.filters) {
|
||||
if (state.filters.tags && state.filters.tags.length > 0) {
|
||||
// Convert the array of tags to a comma-separated string
|
||||
params.append('tags', state.filters.tags.join(','));
|
||||
}
|
||||
if (state.filters.baseModel && state.filters.baseModel.length > 0) {
|
||||
// Convert the array of base models to a comma-separated string
|
||||
params.append('base_models', state.filters.baseModel.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Loading loras with params:', params.toString());
|
||||
@@ -107,7 +113,8 @@ export async function fetchCivitai() {
|
||||
|
||||
await state.loadingManager.showWithProgress(async (loading) => {
|
||||
try {
|
||||
ws = new WebSocket(`ws://${window.location.host}/ws/fetch-progress`);
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
||||
|
||||
const operationComplete = new Promise((resolve, reject) => {
|
||||
ws.onmessage = (event) => {
|
||||
@@ -325,4 +332,19 @@ export async function refreshSingleLoraMetadata(filePath) {
|
||||
state.loadingManager.hide();
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchModelDescription(modelId, filePath) {
|
||||
try {
|
||||
const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch model description: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching model description:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { refreshSingleLoraMetadata } from '../api/loraApi.js';
|
||||
import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
|
||||
export class LoraContextMenu {
|
||||
constructor() {
|
||||
this.menu = document.getElementById('loraContextMenu');
|
||||
this.currentCard = null;
|
||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -58,10 +61,274 @@ export class LoraContextMenu {
|
||||
case 'refresh-metadata':
|
||||
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'set-nsfw':
|
||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||
break;
|
||||
}
|
||||
|
||||
this.hideMenu();
|
||||
});
|
||||
|
||||
// Initialize NSFW Level Selector events
|
||||
this.initNSFWSelector();
|
||||
}
|
||||
|
||||
initNSFWSelector() {
|
||||
// Close button
|
||||
const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
});
|
||||
|
||||
// Level buttons
|
||||
const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn');
|
||||
levelButtons.forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const level = parseInt(btn.dataset.level);
|
||||
const filePath = this.nsfwSelector.dataset.cardPath;
|
||||
|
||||
if (!filePath) return;
|
||||
|
||||
try {
|
||||
await this.saveModelMetadata(filePath, { preview_nsfw_level: level });
|
||||
|
||||
// Update card data
|
||||
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
|
||||
if (card) {
|
||||
let metaData = {};
|
||||
try {
|
||||
metaData = JSON.parse(card.dataset.meta || '{}');
|
||||
} catch (err) {
|
||||
console.error('Error parsing metadata:', err);
|
||||
}
|
||||
|
||||
metaData.preview_nsfw_level = level;
|
||||
card.dataset.meta = JSON.stringify(metaData);
|
||||
card.dataset.nsfwLevel = level.toString();
|
||||
|
||||
// Apply blur effect immediately
|
||||
this.updateCardBlurEffect(card, level);
|
||||
}
|
||||
|
||||
showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success');
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
} catch (error) {
|
||||
showToast(`Failed to set content rating: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.nsfwSelector.style.display === 'block' &&
|
||||
!this.nsfwSelector.contains(e.target) &&
|
||||
!e.target.closest('.context-menu-item[data-action="set-nsfw"]')) {
|
||||
this.nsfwSelector.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async saveModelMetadata(filePath, data) {
|
||||
const response = await fetch('/loras/api/save-metadata', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
...data
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save metadata');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
updateCardBlurEffect(card, level) {
|
||||
// Get user settings for blur threshold
|
||||
const blurThreshold = parseInt(localStorage.getItem('nsfwBlurLevel') || '4');
|
||||
|
||||
// Get card preview container
|
||||
const previewContainer = card.querySelector('.card-preview');
|
||||
if (!previewContainer) return;
|
||||
|
||||
// Get preview media element
|
||||
const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video');
|
||||
if (!previewMedia) return;
|
||||
|
||||
// Check if blur should be applied
|
||||
if (level >= blurThreshold) {
|
||||
// Add blur class to the preview container
|
||||
previewContainer.classList.add('blurred');
|
||||
|
||||
// Get or create the NSFW overlay
|
||||
let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay');
|
||||
if (!nsfwOverlay) {
|
||||
// Create new overlay
|
||||
nsfwOverlay = document.createElement('div');
|
||||
nsfwOverlay.className = 'nsfw-overlay';
|
||||
|
||||
// Create and configure the warning content
|
||||
const warningContent = document.createElement('div');
|
||||
warningContent.className = 'nsfw-warning';
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
if (level >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
} else if (level >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
} else if (level >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
|
||||
// Add warning text and show button
|
||||
warningContent.innerHTML = `
|
||||
<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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { showLoraModal } from './LoraModal.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
|
||||
export function createLoraCard(lora) {
|
||||
const card = document.createElement('div');
|
||||
@@ -18,6 +19,24 @@ export function createLoraCard(lora) {
|
||||
card.dataset.usage_tips = lora.usage_tips;
|
||||
card.dataset.notes = lora.notes;
|
||||
card.dataset.meta = JSON.stringify(lora.civitai || {});
|
||||
|
||||
// Store tags and model description
|
||||
if (lora.tags && Array.isArray(lora.tags)) {
|
||||
card.dataset.tags = JSON.stringify(lora.tags);
|
||||
}
|
||||
if (lora.modelDescription) {
|
||||
card.dataset.modelDescription = lora.modelDescription;
|
||||
}
|
||||
|
||||
// Store NSFW level if available
|
||||
const nsfwLevel = lora.preview_nsfw_level !== undefined ? lora.preview_nsfw_level : 0;
|
||||
card.dataset.nsfwLevel = nsfwLevel;
|
||||
|
||||
// Determine if the preview should be blurred based on NSFW level and user settings
|
||||
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
if (shouldBlur) {
|
||||
card.classList.add('nsfw-content');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set
|
||||
if (state.bulkMode && state.selectedLoras.has(lora.file_path)) {
|
||||
@@ -28,8 +47,18 @@ export function createLoraCard(lora) {
|
||||
const previewUrl = lora.preview_url || '/loras_static/images/no-preview.png';
|
||||
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
|
||||
|
||||
// Determine NSFW warning text based on level
|
||||
let nsfwText = "Mature Content";
|
||||
if (nsfwLevel >= NSFW_LEVELS.XXX) {
|
||||
nsfwText = "XXX-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.X) {
|
||||
nsfwText = "X-rated Content";
|
||||
} else if (nsfwLevel >= NSFW_LEVELS.R) {
|
||||
nsfwText = "R-rated Content";
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-preview">
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${previewUrl.endsWith('.mp4') ?
|
||||
`<video controls autoplay muted loop>
|
||||
<source src="${versionedPreviewUrl}" type="video/mp4">
|
||||
@@ -37,7 +66,11 @@ export function createLoraCard(lora) {
|
||||
`<img src="${versionedPreviewUrl}" alt="${lora.model_name}">`
|
||||
}
|
||||
<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}
|
||||
</span>
|
||||
<div class="card-actions">
|
||||
@@ -53,6 +86,14 @@ export function createLoraCard(lora) {
|
||||
</i>
|
||||
</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="model-info">
|
||||
<span class="model-name">${lora.model_name}</span>
|
||||
@@ -86,12 +127,69 @@ export function createLoraCard(lora) {
|
||||
base_model: card.dataset.base_model,
|
||||
usage_tips: card.dataset.usage_tips,
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
card.querySelector('.fa-copy')?.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
@@ -159,11 +257,26 @@ export function updateCardsForBulkMode(isBulkMode) {
|
||||
|
||||
document.body.classList.toggle('bulk-mode', isBulkMode);
|
||||
|
||||
document.querySelectorAll('.lora-card').forEach(card => {
|
||||
// Get all lora cards
|
||||
const loraCards = document.querySelectorAll('.lora-card');
|
||||
|
||||
loraCards.forEach(card => {
|
||||
// Get all action containers for this card
|
||||
const actions = card.querySelectorAll('.card-actions');
|
||||
actions.forEach(actionGroup => {
|
||||
actionGroup.style.display = isBulkMode ? 'none' : 'flex';
|
||||
});
|
||||
|
||||
// Handle display property based on mode
|
||||
if (isBulkMode) {
|
||||
// Hide actions when entering bulk mode
|
||||
actions.forEach(actionGroup => {
|
||||
actionGroup.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
// Ensure actions are visible when exiting bulk mode
|
||||
actions.forEach(actionGroup => {
|
||||
// We need to reset to default display style which is flex
|
||||
actionGroup.style.display = 'flex';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Apply selection state to cards if entering bulk mode
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { NSFW_LEVELS } from '../utils/constants.js';
|
||||
|
||||
export function showLoraModal(lora) {
|
||||
const escapedWords = lora.civitai?.trainedWords?.length ?
|
||||
@@ -15,6 +16,7 @@ export function showLoraModal(lora) {
|
||||
<i class="fas fa-save"></i>
|
||||
</button>
|
||||
</div>
|
||||
${renderCompactTags(lora.tags || [])}
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
@@ -66,7 +68,7 @@ export function showLoraModal(lora) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${renderTriggerWords(escapedWords)}
|
||||
${renderTriggerWords(escapedWords, lora.file_path)}
|
||||
<div class="info-item notes">
|
||||
<label>Additional Notes</label>
|
||||
<div class="editable-field">
|
||||
@@ -81,10 +83,35 @@ export function showLoraModal(lora) {
|
||||
<div class="description-text">${lora.description || 'N/A'}</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>
|
||||
`;
|
||||
@@ -92,6 +119,231 @@ export function showLoraModal(lora) {
|
||||
modalManager.showModal('loraModal', content);
|
||||
setupEditableFields();
|
||||
setupShowcaseScroll();
|
||||
setupTabSwitching();
|
||||
setupTagTooltip();
|
||||
setupTriggerWordsEditMode();
|
||||
|
||||
// If we have a model ID but no description, fetch it
|
||||
if (lora.civitai?.modelId && !lora.modelDescription) {
|
||||
loadModelDescription(lora.civitai.modelId, lora.file_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to render showcase content
|
||||
function renderShowcaseContent(images) {
|
||||
if (!images?.length) return '<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 `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<label>Trigger Words</label>
|
||||
<span>No trigger word needed</span>
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="trigger-words-content">
|
||||
<span class="no-trigger-words">No trigger word needed</span>
|
||||
<div class="trigger-words-tags" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="trigger-words-edit-controls" style="display:none;">
|
||||
<button class="add-trigger-word-btn" title="Add a trigger word">
|
||||
<i class="fas fa-plus"></i> Add
|
||||
</button>
|
||||
<button class="save-trigger-words-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
<div class="add-trigger-word-form" style="display:none;">
|
||||
<input type="text" class="new-trigger-word-input" placeholder="Enter trigger word">
|
||||
<button class="confirm-add-trigger-word-btn">Add</button>
|
||||
<button class="cancel-add-trigger-word-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="info-item full-width trigger-words">
|
||||
<label>Trigger Words</label>
|
||||
<div class="trigger-words-tags">
|
||||
${words.map(word => `
|
||||
<div class="trigger-word-tag" onclick="copyTriggerWord('${word}')">
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div class="trigger-words-header">
|
||||
<label>Trigger Words</label>
|
||||
<button class="edit-trigger-words-btn" data-file-path="${filePath}" title="Edit trigger words">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderShowcaseImages(images) {
|
||||
if (!images?.length) return '';
|
||||
|
||||
return `
|
||||
<div class="showcase-section">
|
||||
<div class="scroll-indicator" onclick="toggleShowcase(this)">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
<span>Scroll or click to show ${images.length} examples</span>
|
||||
</div>
|
||||
<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 class="trigger-words-content">
|
||||
<div class="trigger-words-tags">
|
||||
${words.map(word => `
|
||||
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')">
|
||||
<span class="trigger-word-content">${word}</span>
|
||||
<span class="trigger-word-copy">
|
||||
<i class="fas fa-copy"></i>
|
||||
</span>
|
||||
<button class="delete-trigger-word-btn" style="display:none;" onclick="event.stopPropagation();">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<button class="back-to-top" onclick="scrollToTop(this)">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@@ -420,6 +658,9 @@ export function toggleShowcase(element) {
|
||||
indicator.textContent = `Scroll or click to hide examples`;
|
||||
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
|
||||
initLazyLoading(carousel);
|
||||
|
||||
// Initialize NSFW content blur toggle handlers
|
||||
initNsfwBlurHandlers(carousel);
|
||||
} else {
|
||||
const count = carousel.querySelectorAll('.media-wrapper').length;
|
||||
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
|
||||
function initLazyLoading(container) {
|
||||
const lazyElements = container.querySelectorAll('.lazy');
|
||||
@@ -558,4 +850,315 @@ function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { debounce } from './utils/debounce.js';
|
||||
import { LoadingManager } from './managers/LoadingManager.js';
|
||||
import { modalManager } from './managers/ModalManager.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 { toggleShowcase, scrollToTop } from './components/LoraModal.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
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Ensure settings are initialized
|
||||
initSettings();
|
||||
|
||||
state.loadingManager = new LoadingManager();
|
||||
modalManager.initialize(); // Initialize modalManager after DOM is loaded
|
||||
updateService.initialize(); // Initialize updateService after modalManager
|
||||
|
||||
@@ -56,12 +56,20 @@ export class BulkManager {
|
||||
this.hideThumbnailStrip();
|
||||
}
|
||||
|
||||
// Update all cards
|
||||
// First update all cards' visual state before clearing selection
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
// Clear selection if exiting bulk mode
|
||||
// Clear selection if exiting bulk mode - do this after updating cards
|
||||
if (!state.bulkMode) {
|
||||
this.clearSelection();
|
||||
|
||||
// Force a lightweight refresh of the cards to ensure proper display
|
||||
// This is less disruptive than a full resetAndReload()
|
||||
document.querySelectorAll('.lora-card').forEach(card => {
|
||||
// Re-apply normal display mode to all card actions
|
||||
const actions = card.querySelectorAll('.card-actions, .card-button');
|
||||
actions.forEach(action => action.style.display = 'flex');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +269,8 @@ export class DownloadManager {
|
||||
this.loadingManager.show('Downloading LoRA...', 0);
|
||||
|
||||
// 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) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { resetAndReload } from '../api/loraApi.js';
|
||||
export class FilterManager {
|
||||
constructor() {
|
||||
this.filters = {
|
||||
baseModel: []
|
||||
baseModel: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
this.filterPanel = document.getElementById('filterPanel');
|
||||
@@ -34,6 +35,77 @@ export class FilterManager {
|
||||
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() {
|
||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||
if (!baseModelTagsContainer) return;
|
||||
@@ -69,10 +141,13 @@ export class FilterManager {
|
||||
}
|
||||
|
||||
toggleFilterPanel() {
|
||||
const wasHidden = this.filterPanel.classList.contains('hidden');
|
||||
|
||||
this.filterPanel.classList.toggle('hidden');
|
||||
|
||||
// Mark selected filters
|
||||
if (!this.filterPanel.classList.contains('hidden')) {
|
||||
// If the panel is being opened, load the top tags and update selections
|
||||
if (wasHidden) {
|
||||
this.loadTopTags();
|
||||
this.updateTagSelections();
|
||||
}
|
||||
}
|
||||
@@ -92,10 +167,21 @@ export class FilterManager {
|
||||
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() {
|
||||
const totalActiveFilters = this.filters.baseModel.length;
|
||||
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
|
||||
|
||||
if (totalActiveFilters > 0) {
|
||||
this.activeFiltersCount.textContent = totalActiveFilters;
|
||||
@@ -119,7 +205,19 @@ export class FilterManager {
|
||||
if (this.hasActiveFilters()) {
|
||||
this.filterButton.classList.add('active');
|
||||
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 {
|
||||
this.filterButton.classList.remove('active');
|
||||
@@ -132,7 +230,8 @@ export class FilterManager {
|
||||
async clearFilters() {
|
||||
// Clear all filters
|
||||
this.filters = {
|
||||
baseModel: []
|
||||
baseModel: [],
|
||||
tags: []
|
||||
};
|
||||
|
||||
// Update state
|
||||
@@ -154,7 +253,14 @@ export class FilterManager {
|
||||
const savedFilters = localStorage.getItem('loraFilters');
|
||||
if (savedFilters) {
|
||||
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.updateActiveFiltersCount();
|
||||
|
||||
@@ -168,6 +274,6 @@ export class FilterManager {
|
||||
}
|
||||
|
||||
hasActiveFilters() {
|
||||
return this.filters.baseModel.length > 0;
|
||||
return this.filters.baseModel.length > 0 || this.filters.tags.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state, saveSettings } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/loraApi.js';
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
@@ -20,6 +22,11 @@ export class SettingsManager {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
if (this.isOpen) {
|
||||
modalManager.closeModal('settingsModal');
|
||||
@@ -40,16 +63,28 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
// Get frontend settings from UI
|
||||
const blurMatureContent = document.getElementById('blurMatureContent').checked;
|
||||
|
||||
// Get backend settings
|
||||
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 {
|
||||
// Save backend settings via API
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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');
|
||||
modalManager.closeModal('settingsModal');
|
||||
|
||||
// Apply frontend settings immediately
|
||||
this.applyFrontendSettings();
|
||||
|
||||
// Reload the loras without updating folders
|
||||
await resetAndReload(false);
|
||||
} catch (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
|
||||
|
||||
@@ -28,7 +28,10 @@ export class UpdateService {
|
||||
}
|
||||
|
||||
// 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
|
||||
const updateToggle = document.getElementById('updateToggleBtn');
|
||||
@@ -43,7 +46,9 @@ export class UpdateService {
|
||||
async checkForUpdates() {
|
||||
// Check if we should perform an update check
|
||||
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 (this.updateAvailable) {
|
||||
this.updateBadgeVisibility();
|
||||
@@ -61,8 +66,8 @@ export class UpdateService {
|
||||
this.latestVersion = data.latest_version || "v0.0.0";
|
||||
this.updateInfo = data;
|
||||
|
||||
// Determine if update is available
|
||||
this.updateAvailable = data.update_available;
|
||||
// Explicitly set update availability based on version comparison
|
||||
this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion);
|
||||
|
||||
// Update last check time
|
||||
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() {
|
||||
const updateToggle = document.querySelector('.update-toggle');
|
||||
const updateBadge = document.querySelector('.update-toggle .update-badge');
|
||||
@@ -94,14 +130,17 @@ export class UpdateService {
|
||||
: "Check Updates";
|
||||
}
|
||||
|
||||
// Force updating badges visibility based on current state
|
||||
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
|
||||
if (updateBadge) {
|
||||
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
updateBadge.classList.toggle('hidden', !shouldShow);
|
||||
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
|
||||
}
|
||||
|
||||
if (cornerBadge) {
|
||||
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
|
||||
cornerBadge.classList.toggle('hidden', !shouldShow);
|
||||
console.log("Corner badge visibility:", !shouldShow ? "hidden" : "visible");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +233,8 @@ export class UpdateService {
|
||||
async manualCheckForUpdates() {
|
||||
this.lastCheckTime = 0; // Reset last check time to force check
|
||||
await this.checkForUpdates();
|
||||
// Ensure badge visibility is updated after manual check
|
||||
this.updateBadgeVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,46 @@ export const state = {
|
||||
observer: null,
|
||||
previewVersions: new Map(),
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
tags: false,
|
||||
recursive: false
|
||||
},
|
||||
filters: {
|
||||
baseModel: []
|
||||
baseModel: [],
|
||||
tags: []
|
||||
},
|
||||
bulkMode: false,
|
||||
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();
|
||||
@@ -87,4 +87,14 @@ export const BASE_MODEL_CLASSES = {
|
||||
|
||||
// Default
|
||||
[BASE_MODELS.UNKNOWN]: "unknown"
|
||||
};
|
||||
|
||||
export const NSFW_LEVELS = {
|
||||
UNKNOWN: 0,
|
||||
PG: 1,
|
||||
PG13: 2,
|
||||
R: 4,
|
||||
X: 8,
|
||||
XXX: 16,
|
||||
BLOCKED: 32
|
||||
};
|
||||
@@ -22,15 +22,8 @@ export function initializeInfiniteScroll() {
|
||||
} else {
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
sentinel.style.height = '20px'; // Increase height a bit
|
||||
sentinel.style.width = '100%'; // Ensure full width
|
||||
sentinel.style.position = 'relative'; // Ensure it's in the normal flow
|
||||
sentinel.style.height = '10px';
|
||||
document.getElementById('loraGrid').appendChild(sentinel);
|
||||
state.observer.observe(sentinel);
|
||||
}
|
||||
|
||||
// Force layout recalculation
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@ export class SearchManager {
|
||||
constructor() {
|
||||
// Initialize search manager
|
||||
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.currentSearchTerm = '';
|
||||
this.isSearching = false;
|
||||
this.isRecursiveSearch = false;
|
||||
|
||||
// Add clear button
|
||||
this.createClearButton();
|
||||
@@ -27,15 +28,19 @@ export class SearchManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.searchModeToggle) {
|
||||
// Initialize toggle state from localStorage or default to false
|
||||
this.isRecursiveSearch = localStorage.getItem('recursiveSearch') === 'true';
|
||||
this.updateToggleUI();
|
||||
// Initialize search options
|
||||
this.initSearchOptions();
|
||||
}
|
||||
|
||||
this.searchModeToggle.addEventListener('click', () => {
|
||||
this.isRecursiveSearch = !this.isRecursiveSearch;
|
||||
localStorage.setItem('recursiveSearch', this.isRecursiveSearch);
|
||||
this.updateToggleUI();
|
||||
initSearchOptions() {
|
||||
// Load recursive search state from localStorage
|
||||
state.searchOptions.recursive = localStorage.getItem('recursiveSearch') === 'true';
|
||||
|
||||
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
|
||||
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() {
|
||||
@@ -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) {
|
||||
if (this.searchDebounceTimeout) {
|
||||
clearTimeout(this.searchDebounceTimeout);
|
||||
@@ -126,12 +218,17 @@ export class SearchManager {
|
||||
url.searchParams.set('sort_by', state.sortBy);
|
||||
url.searchParams.set('search', searchTerm);
|
||||
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
|
||||
if (state.activeFolder) {
|
||||
url.searchParams.set('folder', state.activeFolder);
|
||||
// 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);
|
||||
|
||||
@@ -151,4 +151,15 @@ export function initBackToTop() {
|
||||
|
||||
// Initial check
|
||||
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';
|
||||
}
|
||||
@@ -14,6 +14,9 @@
|
||||
<div class="context-menu-item" data-action="preview">
|
||||
<i class="fas fa-image"></i> Replace Preview
|
||||
</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-item" data-action="move">
|
||||
<i class="fas fa-folder-open"></i> Move to Folder
|
||||
@@ -21,4 +24,21 @@
|
||||
<div class="context-menu-item delete-item" data-action="delete">
|
||||
<i class="fas fa-trash"></i> Delete Model
|
||||
</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>
|
||||
@@ -37,8 +37,8 @@
|
||||
<input type="text" id="searchInput" placeholder="Search models..." />
|
||||
<!-- 清空按钮将由JavaScript动态添加到这里 -->
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<button class="search-mode-toggle" id="searchModeToggle" title="Toggle recursive search in folders">
|
||||
<i class="fas fa-folder"></i>
|
||||
<button class="search-options-toggle" id="searchOptionsToggle" title="Search Options">
|
||||
<i class="fas fa-sliders-h"></i>
|
||||
</button>
|
||||
<button class="search-filter-toggle" id="filterButton" onclick="filterManager.toggleFilterPanel()" title="Filter models">
|
||||
<i class="fas fa-filter"></i>
|
||||
@@ -48,6 +48,33 @@
|
||||
</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 -->
|
||||
<div id="filterPanel" class="filter-panel hidden">
|
||||
<div class="filter-header">
|
||||
@@ -62,6 +89,13 @@
|
||||
<!-- Tags will be dynamically inserted here -->
|
||||
</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">
|
||||
<button class="clear-filters-btn" onclick="filterManager.clearFilters()">
|
||||
Clear All Filters
|
||||
|
||||
@@ -147,6 +147,40 @@
|
||||
Used for authentication when downloading models from Civitai
|
||||
</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 class="modal-actions">
|
||||
<button class="primary-btn" onclick="settingsManager.saveSettings()">Save</button>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{% else %}
|
||||
{% include 'components/controls.html' %}
|
||||
<!-- 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 -->
|
||||
</div>
|
||||
<!-- Bulk operations panel will be inserted here by JavaScript -->
|
||||
|
||||
@@ -35,8 +35,12 @@ app.registerExtension({
|
||||
// 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(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// Restore saved value if exists
|
||||
let existingLoras = [];
|
||||
if (node.widgets_values && node.widgets_values.length > 0) {
|
||||
|
||||
114
web/comfyui/lora_stacker.js
Normal file
114
web/comfyui/lora_stacker.js
Normal 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;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -142,13 +142,38 @@ export function addTagsWidget(node, name, opts, callback) {
|
||||
});
|
||||
},
|
||||
getMinHeight: function() {
|
||||
// Calculate height based on content with minimal extra space
|
||||
// Use a small buffer instead of explicit padding calculation
|
||||
const buffer = 10; // Small buffer to ensure no overflow
|
||||
return Math.max(
|
||||
150,
|
||||
Math.ceil((container.scrollHeight + buffer) / 5) * 5 // Round up to nearest 5px
|
||||
);
|
||||
const minHeight = 150;
|
||||
// If no tags or only showing the empty message, return a minimum height
|
||||
if (widgetValue.length === 0) {
|
||||
return minHeight; // Height for empty state with message
|
||||
}
|
||||
|
||||
// Get all tag elements
|
||||
const tagElements = container.querySelectorAll('.comfy-tag');
|
||||
|
||||
if (tagElements.length === 0) {
|
||||
return minHeight; // Fallback if elements aren't rendered yet
|
||||
}
|
||||
|
||||
// Calculate the actual height based on tag positions
|
||||
let maxBottom = 0;
|
||||
|
||||
tagElements.forEach(tag => {
|
||||
const rect = tag.getBoundingClientRect();
|
||||
const tagBottom = rect.bottom - container.getBoundingClientRect().top;
|
||||
maxBottom = Math.max(maxBottom, tagBottom);
|
||||
});
|
||||
|
||||
// Add padding (top and bottom padding of container)
|
||||
const computedStyle = window.getComputedStyle(container);
|
||||
const paddingTop = parseInt(computedStyle.paddingTop, 10) || 0;
|
||||
const paddingBottom = parseInt(computedStyle.paddingBottom, 10) || 0;
|
||||
|
||||
// Add extra buffer for potential wrapping issues and to ensure no clipping
|
||||
const extraBuffer = 20;
|
||||
|
||||
// Round up to nearest 5px for clean sizing and ensure minimum height
|
||||
return Math.max(minHeight, Math.ceil((maxBottom + paddingBottom + extraBuffer) / 5) * 5);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { addTagsWidget } from "./tags_widget.js";
|
||||
|
||||
const CONVERTED_TYPE = 'converted-widget'
|
||||
|
||||
// TriggerWordToggle extension for ComfyUI
|
||||
app.registerExtension({
|
||||
name: "LoraManager.TriggerWordToggle",
|
||||
@@ -18,9 +20,13 @@ app.registerExtension({
|
||||
if (node.comfyClass === "TriggerWord Toggle (LoraManager)") {
|
||||
// Enable widget serialization
|
||||
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
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
// Get the widget object directly from the returned object
|
||||
const result = addTagsWidget(node, "toggle_trigger_words", {
|
||||
defaultVal: []
|
||||
@@ -28,13 +34,30 @@ app.registerExtension({
|
||||
|
||||
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
|
||||
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];
|
||||
if (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;
|
||||
}
|
||||
|
||||
// Store the original message for mode switching
|
||||
node.widgets[2].value = message;
|
||||
|
||||
if (node.tagWidget) {
|
||||
// Convert comma-separated message to tag object format
|
||||
if (typeof message === 'string') {
|
||||
// Get existing tags to preserve active states
|
||||
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;
|
||||
}
|
||||
// Parse tags based on current group mode
|
||||
const groupMode = node.widgets[0] ? node.widgets[0].value : false;
|
||||
this.updateTagsBasedOnMode(node, message, groupMode);
|
||||
}
|
||||
},
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user