Compare commits

...

64 Commits

Author SHA1 Message Date
Will Miao
09f5e2961e Bump version to 0.7.39 2025-03-15 10:58:55 +08:00
Will Miao
756ad399bf Enhance LoraManagerLoader to include formatted loaded_loras in return values, improving data output for loaded LoRAs. 2025-03-15 10:45:32 +08:00
Will Miao
02adced7b8 Fix path formatting in LoraStacker to ensure compatibility across different operating systems by replacing '/' with os.sep. 2025-03-15 10:45:16 +08:00
Will Miao
9059795816 Enhance DownloadManager to update hash index with new LoRA entries, improving file tracking during downloads. 2025-03-15 10:16:52 +08:00
Will Miao
6920944724 Refactor API and DownloadManager to utilize version-level properties for model file existence and size, improving data handling and UI responsiveness. 2025-03-15 09:56:41 +08:00
Will Miao
c76b287aed Normalize SHA256 hash handling by converting to lowercase in LoraScanner and LoraMetadata classes for consistency. 2025-03-15 09:56:28 +08:00
Will Miao
f7c946778d Bump version to 0.7.38. fix: correct LoRA naming issue when fetching data from Civitai 2025-03-14 11:23:07 +08:00
Will Miao
81599b8f43 Fix: correct LoRA naming issue when fetching data from Civitai 2025-03-14 11:22:21 +08:00
Will Miao
db04c349a7 Bump version to 0.7.37-bugfix for release preparation 2025-03-13 11:11:51 +08:00
Will Miao
e57a72d12b Fixed an issue caused by inconsistent base model name for Illustrious. It fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/37 2025-03-13 11:00:55 +08:00
Will Miao
c88388da67 Refactor toggle switch styles for update preferences in the modal 2025-03-13 10:00:32 +08:00
Will Miao
2ea0fa8471 Update README and version for NSFW content control enhancements 2025-03-12 22:58:04 +08:00
Will Miao
7f088e58bc Implement SFW content filtering in LoraModal and update settings management 2025-03-12 22:57:21 +08:00
Will Miao
e992ace11c Add NSFW browse control functionality - Done 2025-03-12 22:21:30 +08:00
Will Miao
0cad6b5cbc Add nsfw browse control part 1 2025-03-12 21:06:31 +08:00
Will Miao
e9a703451c Fix the problem of repeatedly trying to fetch model description metadata when the model has a null description. 2025-03-12 15:25:58 +08:00
Will Miao
03ddd51a91 Fetch and update model metadata including tags and description in ApiRoutes and DownloadManager 2025-03-12 14:50:06 +08:00
Will Miao
9142cc4cde Enhance CivitaiClient to return HTTP status code with model metadata; update LoraScanner to handle deleted models 2025-03-12 11:18:19 +08:00
Will Miao
8e5e16ce68 Refactor logging and update badge visibility in UpdateService; improve path normalization in file_utils 2025-03-12 10:06:15 +08:00
Will Miao
9a4124c709 Bump version to 0.7.36 2025-03-11 22:01:40 +08:00
Will Miao
6f49a73f9c Enhance README with v0.7.36 release notes and update utils.py with credit attribution 2025-03-11 22:00:46 +08:00
Will Miao
a2c51f4779 Fix trigger word toggle node 2025-03-11 22:00:25 +08:00
Will Miao
79ebe25bc2 Add lora stacker support and fix TriggerWord Toggle node 2025-03-11 20:46:40 +08:00
Will Miao
ad56cafd62 checkpoint 2025-03-11 19:29:31 +08:00
Will Miao
5a6c412845 Update WebSocket connection to use secure protocol based on current window location 2025-03-11 15:08:52 +08:00
Will Miao
be5e8bad17 Revert "Enhance infinite scroll functionality; increase sentinel height and ensure full width, trigger layout recalculation on initialization."
This reverts commit a1e9e440ed.
2025-03-11 14:47:15 +08:00
Will Miao
d63a70474b Improve height calculation for tags widget to handle empty states and dynamic content 2025-03-11 14:45:50 +08:00
Will Miao
f48b954fb7 Add group mode for TriggerWord Toggle 2025-03-11 11:12:37 +08:00
Will Miao
c48da5300e Make trigger_words input optional for TriggerWord Toggle node. This will fix https://github.com/willmiao/ComfyUI-Lora-Manager/issues/31 2025-03-10 20:19:01 +08:00
Will Miao
2783782669 Add search options panel and functionality for filename, model name, and tags 2025-03-10 19:33:45 +08:00
Will Miao
cbb76580e4 Enhance error handling for civitai metadata parsing and update logic; add empty state messages for model descriptions 2025-03-10 17:32:28 +08:00
Will Miao
2bdecf571b Add trigger words editting 2025-03-10 17:10:58 +08:00
Will Miao
72a82707ea Merge branch 'main' of https://github.com/willmiao/ComfyUI-Lora-Manager 2025-03-10 15:32:42 +08:00
Will Miao
564e507fa9 Add tag info and filtering 2025-03-10 15:32:27 +08:00
Will Miao
721bef3ff8 Add tag filtering checkpoint 2025-03-10 13:18:56 +08:00
pixelpaws
1803a9085d Update issue templates 2025-03-10 00:53:31 +08:00
pixelpaws
c3fe58f055 Update issue templates 2025-03-10 00:50:51 +08:00
Will Miao
0069f84630 Add model description in lora details 2025-03-10 00:20:31 +08:00
Will Miao
e8e5012f0c Update flux workflow for v0.7.34 2025-03-07 21:23:50 +08:00
Will Miao
d6ed5b7fec Align image position in card component for better display 2025-03-07 17:43:48 +08:00
Will Miao
2186b7ee26 Improve bulk mode card display handling 2025-03-07 17:34:54 +08:00
Will Miao
7bb6a470b7 Update release notes and version to v0.7.35-beta 2025-03-07 16:07:46 +08:00
Will Miao
14aef237a9 Add interactive thumbnail strip for bulk LoRA selection 2025-03-07 15:36:07 +08:00
Will Miao
a01a336259 Implement LoRA metadata caching for bulk selection 2025-03-07 14:23:30 +08:00
Will Miao
59716ce3c3 Fix bulk multi select 2025-03-07 14:00:15 +08:00
Will Miao
95dfcee90c Fix layout 2025-03-07 13:29:43 +08:00
Will Miao
0c4914909a Add bulk operation 2025-03-07 13:15:39 +08:00
Will Miao
69b1773ced Add inline model name editing with validation and resortable cache 2025-03-07 10:32:27 +08:00
Will Miao
a9b3131e64 Enhance modal toggle functionality and update service interaction 2025-03-07 09:33:58 +08:00
Will Miao
2e47b30ed5 Refactor layout.css 2025-03-07 09:23:11 +08:00
Will Miao
53ab6d8ab4 Refactor LoraCard.js code 2025-03-07 09:11:00 +08:00
Will Miao
5c917b88c7 Improve download modal UX with local version handling and disabled button state 2025-03-07 08:40:52 +08:00
Will Miao
a1e9e440ed Enhance infinite scroll functionality; increase sentinel height and ensure full width, trigger layout recalculation on initialization. Try to fix: https://github.com/willmiao/ComfyUI-Lora-Manager/issues/24 2025-03-07 06:50:31 +08:00
Will Miao
fb75757d6c Refactor modal styles for improved consistency and responsiveness; adjust padding and background for dark theme support 2025-03-06 21:09:31 +08:00
Will Miao
c9c86d8c0f Add local availability check for Civitai model versions; enhance download manager UI to indicate local status 2025-03-06 20:45:09 +08:00
Will Miao
5c521e40d4 Enhance modal styles for better visibility and responsiveness; add initialization logic to sync modal state 2025-03-06 18:37:13 +08:00
Will Miao
cfcc954ffe Add Civitai link and icon styles, enhance modal management 2025-03-06 18:17:24 +08:00
Will Miao
61ea42353d Add update check and change log 2025-03-06 17:28:18 +08:00
Will Miao
5e6cce936d Add update notifications 2025-03-05 22:03:54 +08:00
Will Miao
493dff5c19 Add support modal and toggle button for user feedback and support 2025-03-05 20:47:17 +08:00
Will Miao
520a4b10ae Update determine_base_model function to return version_string and add TODO for more mappings 2025-03-05 19:57:13 +08:00
Will Miao
f614dbf700 Add filters - base model only for now 2025-03-05 19:53:52 +08:00
Will Miao
c17f2c885a Add filters - checkpoint 2025-03-05 19:07:11 +08:00
Will Miao
879df0d563 Add "Hunyuan Video" to BASE_MODEL_MAPPING and create new Lora manager example workflow 2025-03-05 10:18:22 +08:00
59 changed files with 7661 additions and 2306 deletions

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

@@ -0,0 +1,36 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
### **LoRA Manager Version**
- Version: `vX.X.X`
### **Environment Information**
- **Operating System**: (e.g., Windows 11, macOS Ventura, Ubuntu 22.04)
- **Browser & Version**: (e.g., Chrome 120.0.0, Edge 115.0.0)
### **Issue Description**
- Describe the issue in detail.
### **Steps to Reproduce**
1. Open LoRA Manager in [your browser].
2. Perform [specific action].
3. Observe the issue.
### **Expected Behavior**
- What did you expect to happen?
### **Screenshots** *(If applicable)*
- Upload screenshots or screen recordings.
### **Logs**
- Provide the **ComfyUI startup log** and any relevant error messages.
- Check the browser developer console (F12 → Console tab) and attach any errors.
### **Additional Context** *(Optional)*
- Any other relevant details.

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -11,43 +11,53 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
---
## [Update 0.7.33] Enhanced UI Controls & Trigger Word Management
- 🎛️ **Enhanced LoRA Loader Node**:
- New visual widgets for easier LoRA strength adjustment
- Toggle switches for quick LoRA enable/disable
- Image tooltips for quick LoRA preview identification
- Improved UI for managing multiple LoRAs
## Release Notes
- 🏷️ **New TriggerWord Toggle Node**:
- Visual display of applicable trigger words
- Click-to-toggle individual trigger words
- Easy exclusion of unwanted trigger words
- Connects directly after LoRA Loader node
### 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
- 🐛 **Bug Fixes and Stability Improvements**
### 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
## [Update 0.7.3] One-Click Integration & Workflow Automation
- **One-Click LoRA Integration**:
- Use "Lora Loader (LoraManager)" custom node in workflows
- Copy LoRA syntax directly from manager interface
- Auto-applies preset strength values
- Auto-loads trigger words when available
- Example workflow included
### v0.7.35-beta
* Added base model filtering
* Implemented bulk operations (copy syntax, move multiple LoRAs)
* Added ability to edit LoRA model names in details view
* Added update checker with notification system
* Added support modal for user feedback and community links
## [Update 0.7.0] Major Features Enhancement
### v0.7.33
* Enhanced LoRA Loader node with visual strength adjustment widgets
* Added toggle switches for LoRA enable/disable
* Implemented image tooltips for LoRA preview
* Added TriggerWord Toggle node with visual word selection
* Fixed various bugs and improved stability
- 🚀 **Direct CivitAI Integration**:
- Download LoRAs directly from CivitAI URLs
- Version selection support for model downloads
- Choose target folder for downloads
- 📋 **New Context Menu Features**:
- Right-click menu for quick actions
- Force refresh CivitAI data
- Move LoRAs between folders
- 📝 **Enhanced Model Details**:
- Save personal usage tips
- Add custom notes for each LoRA
- Improved performance for details window
### v0.7.3
* Added "Lora Loader (LoraManager)" custom node for workflows
* Implemented one-click LoRA integration
* Added direct copying of LoRA syntax from manager interface
* Added automatic preset strength value application
* Added automatic trigger word loading
### v0.7.0
* Added direct CivitAI integration for downloading LoRAs
* Implemented version selection for model downloads
* Added target folder selection for downloads
* Added context menu with quick actions
* Added force refresh for CivitAI data
* Implemented LoRA movement between folders
* Added personal usage tips and notes for LoRAs
* Improved performance for details window
[View Update History](./update_logs.md)

View File

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

View File

@@ -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):
@@ -26,8 +26,8 @@ class LoraManagerLoader:
"optional": FlexibleOptionalInputType(any_type),
}
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING)
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words")
RETURN_TYPES = ("MODEL", "CLIP", IO.STRING, IO.STRING)
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras"
async def get_lora_info(self, lora_name):
@@ -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,11 @@ 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 ""
# Format loaded_loras as <lora:lora_name:strength> separated by spaces
formatted_loras = " ".join([f"<lora:{name.split(':')[0].strip()}:{str(strength).strip()}>"
for name, strength in [item.split(':') for item in loaded_loras]])
return (model, clip, trigger_words_text)
return (model, clip, trigger_words_text, formatted_loras)

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

@@ -0,0 +1,92 @@
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
# replace '/' with os.sep to avoid different OS path format
stack.append((lora_path.replace('/', os.sep), model_strength, clip_strength))
# Add trigger words to collection
all_trigger_words.extend(trigger_words)
# use ',, ' to separate trigger words for group mode
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
return (stack, trigger_words_text)

View File

@@ -1,17 +1,18 @@
import json
import 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}")

View File

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

View File

@@ -4,6 +4,8 @@ import logging
from aiohttp import web
from typing import Dict, List
from ..utils.model_utils import determine_base_model
from ..services.file_monitor import LoraFileMonitor
from ..services.download_manager import DownloadManager
from ..services.civitai_client import CivitaiClient
@@ -13,6 +15,7 @@ from operator import itemgetter
from ..services.websocket_manager import ws_manager
from ..services.settings_manager import settings
import asyncio
from .update_routes import UpdateRoutes
logger = logging.getLogger(__name__)
@@ -40,8 +43,14 @@ 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)
async def delete_model(self, request: web.Request) -> web.Response:
"""Handle model deletion request"""
@@ -121,6 +130,15 @@ class ApiRoutes:
fuzzy = request.query.get('fuzzy', 'false').lower() == 'true'
recursive = request.query.get('recursive', 'false').lower() == 'true'
# Parse base models filter parameter
base_models = request.query.get('base_models', '').split(',')
base_models = [model.strip() for model in base_models if model.strip()]
# Parse search options
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({
@@ -132,7 +150,11 @@ class ApiRoutes:
'error': 'Invalid sort parameter'
}, status=400)
# Get paginated data with search
# 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,
page_size=page_size,
@@ -140,7 +162,14 @@ class ApiRoutes:
folder=folder,
search=search,
fuzzy=fuzzy,
recursive=recursive # 添加递归参数
recursive=recursive,
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
@@ -173,12 +202,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", ""),
@@ -321,11 +353,19 @@ class ApiRoutes:
# Update model name if available
if 'model' in civitai_metadata:
local_metadata['model_name'] = civitai_metadata['model'].get('name',
local_metadata.get('model_name'))
if civitai_metadata.get('model', {}).get('name'):
local_metadata['model_name'] = civitai_metadata['model']['name']
# Fetch additional model metadata (description and tags) if we have model ID
model_id = civitai_metadata['modelId']
if model_id:
model_metadata, _ = await client.get_model_metadata(str(model_id))
if model_metadata:
local_metadata['modelDescription'] = model_metadata.get('description', '')
local_metadata['tags'] = model_metadata.get('tags', [])
# Update base model
local_metadata['base_model'] = civitai_metadata.get('baseModel')
local_metadata['base_model'] = determine_base_model(civitai_metadata.get('baseModel'))
# Update preview if needed
if not local_metadata.get('preview_url') or not os.path.exists(local_metadata['preview_url']):
@@ -338,6 +378,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:
@@ -357,7 +398,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)
@@ -479,12 +520,33 @@ class ApiRoutes:
})
async def get_civitai_versions(self, request: web.Request) -> web.Response:
"""Get available versions for a Civitai model"""
"""Get available versions for a Civitai model with local availability info"""
try:
model_id = request.match_info['model_id']
versions = await self.civitai_client.get_model_versions(model_id)
if not versions:
return web.Response(status=404, text="Model not found")
# Check local availability for each version
for version in versions:
# Find the model file (type="Model") in the files list
model_file = next((file for file in version.get('files', [])
if file.get('type') == 'Model'), None)
if model_file:
sha256 = model_file.get('hashes', {}).get('SHA256')
if sha256:
# Set existsLocally and localPath at the version level
version['existsLocally'] = self.scanner.has_lora_hash(sha256)
if version['existsLocally']:
version['localPath'] = self.scanner.get_lora_path_by_hash(sha256)
# Also set the model file size at the version level for easier access
version['modelSizeKB'] = model_file.get('sizeKB')
else:
# No model file found in this version
version['existsLocally'] = False
return web.json_response(versions)
except Exception as e:
logger.error(f"Error fetching model versions: {e}")
@@ -525,6 +587,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:
@@ -580,8 +644,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:
@@ -590,6 +661,11 @@ class ApiRoutes:
# Update cache
await self.scanner.update_single_lora_cache(file_path, file_path, metadata)
# If model_name was updated, resort the cache
if 'model_name' in metadata_updates:
cache = await self.scanner.get_cached_data()
await cache.resort(name_only=True)
return web.json_response({'success': True})
except Exception as e:
@@ -630,3 +706,130 @@ class ApiRoutes:
except Exception as e:
logger.error(f"Error getting lora preview URL: {e}", exc_info=True)
return web.Response(text=str(e), status=500)
async def move_models_bulk(self, request: web.Request) -> web.Response:
"""Handle bulk model move request"""
try:
data = await request.json()
file_paths = data.get('file_paths', [])
target_path = data.get('target_path')
if not file_paths or not target_path:
return web.Response(text='File paths and target path are required', status=400)
results = []
for file_path in file_paths:
success = await self.scanner.move_model(file_path, target_path)
results.append({"path": file_path, "success": success})
# Count successes
success_count = sum(1 for r in results if r["success"])
if success_count == len(file_paths):
return web.json_response({
'success': True,
'message': f'Successfully moved {success_count} models'
})
elif success_count > 0:
return web.json_response({
'success': True,
'message': f'Moved {success_count} of {len(file_paths)} models',
'results': results
})
else:
return web.Response(text='Failed to move any models', status=500)
except Exception as e:
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)

View File

@@ -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", {}))

180
py/routes/update_routes.py Normal file
View File

@@ -0,0 +1,180 @@
import os
import aiohttp
import logging
import toml
from aiohttp import web
from typing import Dict, Any, List
logger = logging.getLogger(__name__)
class UpdateRoutes:
"""Routes for handling plugin update checks"""
@staticmethod
def setup_routes(app):
"""Register update check routes"""
app.router.add_get('/loras/api/check-updates', UpdateRoutes.check_updates)
@staticmethod
async def check_updates(request):
"""
Check for plugin updates by comparing local version with GitHub
Returns update status and version information
"""
try:
# Read local version from pyproject.toml
local_version = UpdateRoutes._get_local_version()
logger.info(f"Local version: {local_version}")
# Fetch remote version from GitHub
remote_version, changelog = await UpdateRoutes._get_remote_version()
logger.info(f"Remote version: {remote_version}")
# Compare versions
update_available = UpdateRoutes._compare_versions(
local_version.replace('v', ''),
remote_version.replace('v', '')
)
logger.info(f"Update available: {update_available}")
return web.json_response({
'success': True,
'current_version': local_version,
'latest_version': remote_version,
'update_available': update_available,
'changelog': changelog
})
except Exception as e:
logger.error(f"Failed to check for updates: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
})
@staticmethod
def _get_local_version() -> str:
"""Get local plugin version from pyproject.toml"""
try:
# Find the plugin's pyproject.toml file
current_dir = os.path.dirname(os.path.abspath(__file__))
plugin_root = os.path.dirname(os.path.dirname(current_dir))
pyproject_path = os.path.join(plugin_root, 'pyproject.toml')
# Read and parse the toml file
if os.path.exists(pyproject_path):
with open(pyproject_path, 'r', encoding='utf-8') as f:
project_data = toml.load(f)
version = project_data.get('project', {}).get('version', '0.0.0')
return f"v{version}"
else:
logger.warning(f"pyproject.toml not found at {pyproject_path}")
return "v0.0.0"
except Exception as e:
logger.error(f"Failed to get local version: {e}", exc_info=True)
return "v0.0.0"
@staticmethod
async def _get_remote_version() -> tuple[str, List[str]]:
"""
Fetch remote version from GitHub
Returns:
tuple: (version string, changelog list)
"""
repo_owner = "willmiao"
repo_name = "ComfyUI-Lora-Manager"
# Use GitHub API to fetch the latest release
github_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
try:
async with aiohttp.ClientSession() as session:
async with session.get(github_url, headers={'Accept': 'application/vnd.github+json'}) as response:
if response.status != 200:
logger.warning(f"Failed to fetch GitHub release: {response.status}")
return "v0.0.0", []
data = await response.json()
version = data.get('tag_name', '')
if not version.startswith('v'):
version = f"v{version}"
# Extract changelog from release notes
body = data.get('body', '')
changelog = UpdateRoutes._parse_changelog(body)
return version, changelog
except Exception as e:
logger.error(f"Error fetching remote version: {e}", exc_info=True)
return "v0.0.0", []
@staticmethod
def _parse_changelog(release_notes: str) -> List[str]:
"""
Parse GitHub release notes to extract changelog items
Args:
release_notes: GitHub release notes markdown text
Returns:
List of changelog items
"""
changelog = []
# Simple parsing - extract bullet points
lines = release_notes.split('\n')
for line in lines:
line = line.strip()
# Look for bullet points or numbered items
if line.startswith('- ') or line.startswith('* '):
item = line[2:].strip()
if item:
changelog.append(item)
# Match numbered items like "1. Item"
elif len(line) > 2 and line[0].isdigit() and line[1:].startswith('. '):
item = line[line.index('. ')+2:].strip()
if item:
changelog.append(item)
# If we couldn't parse specific items, use the whole text (limited)
if not changelog and release_notes:
# Limit to first 500 chars and add ellipsis
summary = release_notes.strip()[:500]
if len(release_notes) > 500:
summary += "..."
changelog.append(summary)
return changelog
@staticmethod
def _compare_versions(version1: str, version2: str) -> bool:
"""
Compare two semantic version strings
Returns True if version2 is newer than version1
"""
try:
# Split versions into components
v1_parts = [int(x) for x in version1.split('.')]
v2_parts = [int(x) for x in version2.split('.')]
# Ensure both have 3 components (major.minor.patch)
while len(v1_parts) < 3:
v1_parts.append(0)
while len(v2_parts) < 3:
v2_parts.append(0)
# Compare version components
for i in range(3):
if v2_parts[i] > v1_parts[i]:
return True
elif v2_parts[i] < v1_parts[i]:
return False
# Versions are equal
return False
except Exception as e:
logger.error(f"Error comparing versions: {e}", exc_info=True)
return False

View File

@@ -4,7 +4,7 @@ import os
import json
import logging
from email.parser import Parser
from typing import Optional, Dict, Tuple
from typing import Optional, Dict, Tuple, List
from urllib.parse import unquote
from ..utils.models import LoraMetadata
@@ -135,16 +135,15 @@ class CivitaiClient:
print(f"Download Error: {str(e)}")
return False
async def get_model_versions(self, model_id: str) -> Optional[Dict]:
"""Fetch all versions of a model"""
async def get_model_versions(self, model_id: str) -> List[Dict]:
"""Get all versions of a model with local availability info"""
try:
session = await self.session
url = f"{self.base_url}/models/{model_id}"
async with session.get(url, headers=self.headers) as response:
if response.status == 200:
data = await response.json()
return data.get('modelVersions', [])
return None
session = await self.session # 等待获取 session
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
data = await response.json()
return data.get('modelVersions', [])
except Exception as e:
logger.error(f"Error fetching model versions: {e}")
return None
@@ -164,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:

View File

@@ -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)
@@ -125,6 +136,9 @@ class DownloadManager:
all_folders.add(relative_path)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# Update the hash index with the new LoRA entry
self.file_monitor.scanner._hash_index.add_entry(metadata_dict['sha256'], metadata_dict['file_path'])
# Report 100% completion
if progress_callback:
await progress_callback(100)

View File

@@ -86,26 +86,46 @@ class LoraFileHandler(FileSystemEventHandler):
if not changes:
return
logger.info(f"Processing {len(changes)} file changes")
cache = await self.scanner.get_cached_data() # 先完成可能的初始化
cache = await self.scanner.get_cached_data()
needs_resort = False
new_folders = set() # 用于收集新的文件夹
new_folders = set()
for action, file_path in changes:
try:
if action == 'add':
# 扫描新文件
# 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']) # 收集新文件夹
new_folders.add(lora_data['folder'])
# Update hash index
if 'sha256' in lora_data:
self.scanner._hash_index.add_entry(
lora_data['sha256'],
lora_data['file_path']
)
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)
cache.raw_data = [
item for item in cache.raw_data
if item['file_path'] != file_path
@@ -118,7 +138,7 @@ class LoraFileHandler(FileSystemEventHandler):
if needs_resort:
await cache.resort()
# 更新文件夹列表,包括新添加的文件夹
# Update folder list
all_folders = set(cache.folders) | new_folders
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())

View File

@@ -0,0 +1,48 @@
from typing import Dict, Optional
import logging
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class LoraHashIndex:
"""Index for mapping LoRA file hashes to their file paths"""
def __init__(self):
self._hash_to_path: Dict[str, str] = {}
def add_entry(self, sha256: str, file_path: str) -> None:
"""Add or update a hash -> path mapping"""
if not sha256 or not file_path:
return
self._hash_to_path[sha256] = file_path
def remove_entry(self, sha256: str) -> None:
"""Remove a hash entry"""
self._hash_to_path.pop(sha256, None)
def remove_by_path(self, file_path: str) -> None:
"""Remove entry by file path"""
for sha256, path in list(self._hash_to_path.items()):
if path == file_path:
del self._hash_to_path[sha256]
break
def get_path(self, sha256: str) -> Optional[str]:
"""Get file path for a given hash"""
return self._hash_to_path.get(sha256)
def get_hash(self, file_path: str) -> Optional[str]:
"""Get hash for a given file path"""
for sha256, path in self._hash_to_path.items():
if path == file_path:
return sha256
return None
def has_hash(self, sha256: str) -> bool:
"""Check if hash exists in index"""
return sha256 in self._hash_to_path
def clear(self) -> None:
"""Clear all entries"""
self._hash_to_path.clear()

View File

@@ -10,6 +10,9 @@ from ..config import config
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__)
@@ -28,10 +31,12 @@ class LoraScanner:
# 确保初始化只执行一次
if not hasattr(self, '_initialized'):
self._cache: Optional[LoraCache] = None
self._hash_index = LoraHashIndex()
self._initialization_lock = asyncio.Lock()
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"""
@@ -85,9 +90,25 @@ class LoraScanner:
async def _initialize_cache(self) -> None:
"""Initialize or refresh the cache"""
try:
# 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 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'].lower(), 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(
raw_data=raw_data,
@@ -148,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):
recursive: bool = False, base_models: list = None, tags: list = None,
search_options: dict = None) -> Dict:
"""Get paginated and filtered lora data
Args:
@@ -159,49 +181,73 @@ class LoraScanner:
search: Search term
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
]
# 应用搜索过滤
# Apply base model filtering
if base_models and len(base_models) > 0:
filtered_data = [
item for item in filtered_data
if item.get('base_model') in base_models
]
# 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)
@@ -216,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
@@ -293,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
@@ -408,20 +566,59 @@ 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)
# Remove the old entry from raw_data
cache.raw_data = [
item for item in cache.raw_data
if item['file_path'] != original_path
]
item for item in cache.raw_data
if item['file_path'] != original_path
]
if metadata:
metadata['folder'] = self._calculate_folder(new_path)
# If this is an update to an existing path (not a move), ensure folder is preserved
if original_path == new_path:
# Find the folder from existing entries or calculate it
existing_folder = next((item['folder'] for item in cache.raw_data
if item['file_path'] == original_path), None)
if existing_folder:
metadata['folder'] = existing_folder
else:
metadata['folder'] = self._calculate_folder(new_path)
else:
# For moved files, recalculate the folder
metadata['folder'] = self._calculate_folder(new_path)
# Add the updated metadata to raw_data
cache.raw_data.append(metadata)
all_folders = set(cache.folders)
all_folders.add(metadata['folder'])
# Update hash index with new path
if 'sha256' in metadata:
self._hash_index.add_entry(metadata['sha256'], new_path)
# 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()
return True
async def _update_metadata_paths(self, metadata_path: str, lora_path: str) -> Dict:
"""Update file paths in metadata file"""
@@ -449,3 +646,39 @@ class LoraScanner:
except Exception as e:
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
# Add new methods for hash index functionality
def has_lora_hash(self, sha256: str) -> bool:
"""Check if a LoRA with given hash exists"""
return self._hash_index.has_hash(sha256.lower())
def get_lora_path_by_hash(self, sha256: str) -> Optional[str]:
"""Get file path for a LoRA by its hash"""
return self._hash_index.get_path(sha256.lower())
def get_lora_hash_by_path(self, file_path: str) -> Optional[str]:
"""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]

View File

@@ -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
View File

@@ -0,0 +1,8 @@
NSFW_LEVELS = {
"PG": 1,
"PG13": 2,
"R": 4,
"X": 8,
"XXX": 16,
"Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account?
}

View File

@@ -4,6 +4,8 @@ import hashlib
import json
from typing import Dict, Optional
from .model_utils import determine_base_model
from .lora_metadata import extract_lora_metadata
from .models import LoraMetadata
@@ -69,6 +71,8 @@ async def get_file_info(file_path: str) -> Optional[LoraMetadata]:
notes="",
from_civitai=True,
preview_url=normalize_path(preview_url),
tags=[],
modelDescription=""
)
# create metadata file
@@ -103,9 +107,18 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
data = json.load(f)
needs_update = False
# Check and normalize base model name
normalized_base_model = determine_base_model(data['base_model'])
if data['base_model'] != normalized_base_model:
data['base_model'] = normalized_base_model
needs_update = True
if data['file_path'] != normalize_path(data['file_path']):
data['file_path'] = normalize_path(data['file_path'])
# Compare paths without extensions
stored_path_base = os.path.splitext(data['file_path'])[0]
current_path_base = os.path.splitext(normalize_path(file_path))[0]
if stored_path_base != current_path_base:
data['file_path'] = normalize_path(file_path)
needs_update = True
preview_url = data.get('preview_url', '')
@@ -116,8 +129,21 @@ async def load_metadata(file_path: str) -> Optional[LoraMetadata]:
if new_preview_url != preview_url:
data['preview_url'] = new_preview_url
needs_update = True
elif preview_url != normalize_path(preview_url):
data['preview_url'] = normalize_path(preview_url)
else:
# Compare preview paths without extensions
stored_preview_base = os.path.splitext(preview_url)[0]
current_preview_base = os.path.splitext(normalize_path(preview_url))[0]
if stored_preview_base != current_preview_base:
data['preview_url'] = normalize_path(preview_url)
needs_update = True
# Ensure all fields are present
if 'tags' not in data:
data['tags'] = []
needs_update = True
if 'modelDescription' not in data:
data['modelDescription'] = ""
needs_update = True
if needs_update:

View File

@@ -2,14 +2,16 @@ from typing import Optional
# Base model mapping based on version string
BASE_MODEL_MAPPING = {
"sd-v1-5": "SD1.5",
"sd-v2-1": "SD2.1",
"sdxl": "SDXL",
"sd-v2": "SD2.0",
"sd-v1-5": "SD 1.5",
"sd-v2-1": "SD 2.1",
"sdxl": "SDXL 1.0",
"sd-v2": "SD 2.0",
"flux1": "Flux.1 D",
"flux.1 d": "Flux.1 D",
"illustrious": "IL",
"pony": "Pony"
"illustrious": "Illustrious",
"il": "Illustrious",
"pony": "Pony",
"Hunyuan Video": "Hunyuan Video"
}
def determine_base_model(version_string: Optional[str]) -> str:
@@ -22,4 +24,5 @@ def determine_base_model(version_string: Optional[str]) -> str:
if key in version_lower:
return value
return "Unknown"
# TODO: Add more base model mappings
return version_string

View File

@@ -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':
@@ -39,9 +47,10 @@ class LoraMetadata:
file_path=save_path.replace(os.sep, '/'),
size=file_info.get('sizeKB', 0) * 1024,
modified=datetime.now().timestamp(),
sha256=file_info['hashes'].get('SHA256', ''),
sha256=file_info['hashes'].get('SHA256', '').lower(),
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
)

View File

@@ -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.34"
version = "0.7.39"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -0,0 +1,360 @@
/* Bulk Operations Styles */
.bulk-operations-panel {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateY(100px) translateX(-50%);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
min-width: 300px;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
opacity: 0;
}
.bulk-operations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 20px; /* Increase space between count and buttons */
}
#selectedCount {
font-weight: 500;
background: var(--bg-color);
padding: 6px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
min-width: 80px;
text-align: center;
}
.bulk-operations-actions {
display: flex;
gap: 8px;
}
.bulk-operations-actions button {
padding: 6px 12px;
border-radius: var(--border-radius-xs);
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.bulk-operations-actions button:hover {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Style for selected cards */
.lora-card.selected {
box-shadow: 0 0 0 2px var(--lora-accent);
position: relative;
}
.lora-card.selected::after {
content: "✓";
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
background: var(--lora-accent);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
z-index: 1;
}
/* Update bulk operations button to match others when active */
#bulkOperationsBtn.active {
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
@media (max-width: 768px) {
.bulk-operations-panel {
width: calc(100% - 40px);
left: 20px;
transform: none;
border-radius: var(--border-radius-sm);
}
.bulk-operations-actions {
flex-wrap: wrap;
}
}
.bulk-operations-panel.visible {
transform: translateY(0) translateX(-50%);
opacity: 1;
}
/* Thumbnail Strip Styles */
.selected-thumbnails-strip {
position: fixed;
bottom: 80px; /* Position above the bulk operations panel */
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
z-index: calc(var(--z-overlay) - 1); /* Just below the bulk panel z-index */
padding: 16px;
max-width: 80%;
width: auto;
transition: all 0.3s ease;
opacity: 0;
overflow: hidden;
}
.selected-thumbnails-strip.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.thumbnails-container {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 8px; /* Space for scrollbar */
max-width: 100%;
align-items: flex-start;
}
.selected-thumbnail {
position: relative;
width: 80px;
min-width: 80px; /* Prevent shrinking */
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
overflow: hidden;
cursor: pointer;
background: var(--bg-color);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.selected-thumbnail:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.selected-thumbnail img,
.selected-thumbnail video {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
}
.thumbnail-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 10px;
padding: 3px 5px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.thumbnail-remove {
position: absolute;
top: 3px;
right: 3px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 10px;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.thumbnail-remove:hover {
opacity: 1;
background: var(--lora-error);
}
.strip-close-btn {
position: absolute;
top: 5px;
right: 5px;
width: 20px;
height: 20px;
background: none;
border: none;
color: var(--text-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.strip-close-btn:hover {
opacity: 1;
}
/* Style the selectedCount to indicate it's clickable */
.selectable-count {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.selectable-count:hover {
background: var(--lora-border);
}
.dropdown-caret {
font-size: 12px;
visibility: hidden; /* Will be shown via JS when items are selected */
}
/* Scrollbar styling for the thumbnails container */
.thumbnails-container::-webkit-scrollbar {
height: 6px;
}
.thumbnails-container::-webkit-scrollbar-track {
background: var(--bg-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.thumbnails-container::-webkit-scrollbar-thumb:hover {
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 {
width: calc(100% - 40px);
max-width: none;
left: 20px;
transform: translateY(20px);
border-radius: var(--border-radius-sm);
}
.selected-thumbnails-strip.visible {
transform: translateY(0);
}
.selected-thumbnail {
width: 70px;
min-width: 70px;
}
}

View File

@@ -4,6 +4,8 @@
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */
gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */
margin-top: var(--space-2);
padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */
padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */
max-width: 1400px; /* Container width control */
margin-left: auto;
margin-right: auto;
@@ -55,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 {

View File

@@ -0,0 +1,297 @@
/* Download Modal Styles */
.download-step {
margin: var(--space-2) 0;
}
.input-group {
margin-bottom: var(--space-2);
}
.input-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
}
.input-group input,
.input-group select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
}
.error-message {
color: var(--lora-error);
font-size: 0.9em;
margin-top: 4px;
}
/* Version List Styles */
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
margin: 1px;
position: relative;
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
}
.version-item.selected {
border: 2px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.version-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-content {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 0;
}
.version-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-2);
}
.version-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
flex: 1;
}
.version-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 0.9em;
}
.version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.version-meta {
display: flex;
gap: 12px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.version-meta span {
display: flex;
align-items: center;
gap: 4px;
}
/* Local Version Badge */
.local-badge {
display: inline-flex;
align-items: center;
background: var(--lora-accent);
color: var(--lora-text);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
}
.local-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
min-width: 200px;
max-width: 300px;
}
.local-badge:hover .local-path {
display: block;
}
/* Folder Browser Styles */
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
/* Path Preview Styles */
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
/* Dark theme adjustments */
[data-theme="dark"] .version-item {
background: var(--lora-surface);
}
[data-theme="dark"] .local-path {
background: var(--lora-surface);
border-color: var(--lora-border);
}
/* Add disabled button styles */
.primary-btn.disabled {
background-color: var(--border-color);
color: var(--text-color);
opacity: 0.7;
cursor: not-allowed;
}
/* Enhance the local badge to make it more noticeable */
.version-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.local-badge {
display: inline-flex;
align-items: center;
background: var(--lora-accent);
color: var(--lora-text);
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
position: relative;
}
.local-badge i {
margin-right: 4px;
font-size: 0.9em;
}
.local-path {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
margin-top: 4px;
font-size: 0.9em;
color: var(--text-color);
white-space: normal;
word-break: break-all;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
min-width: 200px;
max-width: 300px;
}
.local-badge:hover .local-path {
display: block;
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
background: rgba(0, 0, 0, 0.2); /* 调整为更淡的半透明黑色 */
z-index: var(--z-modal);
overflow: hidden; /* 改为 hidden防止双滚动条 */
}
@@ -29,7 +29,10 @@ body.modal-open {
border-radius: var(--border-radius-base);
padding: var(--space-3);
border: 1px solid var(--lora-border);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 10px 15px -3px rgba(0, 0, 0, 0.05);
overflow-y: auto;
overflow-x: hidden; /* 防止水平滚动条 */
}
@@ -154,71 +157,6 @@ body.modal-open {
font-size: 1.5em;
}
/* Download Modal Styles */
.download-step {
margin: var(--space-2) 0;
}
.input-group {
margin-bottom: var(--space-2);
}
.input-group label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
}
.input-group input,
.input-group select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
}
.error-message {
color: var(--lora-error);
font-size: 0.9em;
margin-top: 4px;
}
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px;
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color);
margin: 1px;
position: relative;
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1;
}
.version-item.selected {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.1);
}
.close {
position: absolute;
top: var(--space-2);
@@ -236,108 +174,6 @@ body.modal-open {
opacity: 1;
}
.version-list {
max-height: 400px;
overflow-y: auto;
margin: var(--space-2) 0;
display: flex;
flex-direction: column;
gap: 12px;
padding: 1px; /* Add padding to prevent border clipping */
}
.version-item {
display: flex;
gap: var(--space-2);
padding: var(--space-2);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.2s ease;
background: var(--bg-color); /* Add background color */
margin: 1px; /* Add margin to ensure hover effect visibility */
position: relative; /* Add position context */
}
.version-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); /* Replace transform with shadow */
z-index: 1; /* Ensure hover state appears above other items */
}
.version-item.selected {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.1);
}
.version-thumbnail {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--bg-color);
}
.version-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.version-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.version-content h3 {
margin: 0;
font-size: 1.1em;
color: var(--text-color);
}
.version-info {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
}
.version-info .base-model {
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
}
.version-meta {
display: flex;
gap: 12px;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
}
.version-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.folder-browser {
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-1);
max-height: 200px;
overflow-y: auto;
}
/* Settings styles */
.settings-toggle {
width: 36px;
@@ -462,514 +298,149 @@ body.modal-open {
margin-top: 4px;
}
.folder-item {
padding: 8px;
cursor: pointer;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.folder-item:hover {
background: var(--lora-surface);
}
.folder-item.selected {
background: oklch(var(--lora-accent) / 0.1);
border: 1px solid var(--lora-accent);
}
/* Path Preview Styles */
/* 统一各个 section 的样式 */
.support-section,
.changelog-section,
.update-info,
.info-item,
.path-preview {
margin-bottom: var(--space-3);
padding: var(--space-2);
background: var(--bg-color);
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
border: 1px dashed var(--border-color);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
opacity: 0.8;
}
.path-display {
padding: var(--space-1);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
opacity: 0.85;
background: var(--lora-surface);
border-radius: var(--border-radius-xs);
}
.path-text {
color: var(--text-color);
}
.path-preview {
margin-top: var(--space-2);
padding: var(--space-2);
}
/* 深色主题统一样式 */
[data-theme="dark"] .modal-content {
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.path-preview label {
display: block;
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.9em;
[data-theme="dark"] .support-section,
[data-theme="dark"] .changelog-section,
[data-theme="dark"] .update-info,
[data-theme="dark"] .info-item,
[data-theme="dark"] .path-preview {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--lora-border);
}
.path-display {
display: flex;
align-items: center;
gap: 8px;
padding: var(--space-1);
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
color: var(--text-color);
font-family: monospace;
font-size: 0.9em;
overflow-x: auto;
white-space: nowrap;
/* Settings Styles */
.settings-section {
margin-top: var(--space-3);
border-top: 1px solid var(--lora-border);
padding-top: var(--space-2);
}
.path-display i {
color: var(--lora-accent);
opacity: 0.8;
}
.path-text {
.settings-section h3 {
font-size: 1.1em;
margin-bottom: var(--space-2);
color: var(--text-color);
opacity: 0.9;
}
/* Lora Modal Header */
.modal-header {
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.info-item {
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item label {
display: block;
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
margin-bottom: 4px;
}
.info-item span {
color: var(--text-color);
word-break: break-word;
}
.info-item.usage-tips,
.info-item.notes {
grid-column: 1 / -1 !important; /* Make notes section full width */
}
/* Add specific styles for notes content */
.info-item.notes .editable-field [contenteditable] {
min-height: 60px; /* Increase height for multiple lines */
max-height: 150px; /* Limit maximum height */
overflow-y: auto; /* Add scrolling for long content */
white-space: pre-wrap; /* Preserve line breaks */
line-height: 1.5; /* Improve readability */
padding: 8px 12px; /* Slightly increase padding */
}
.file-path {
font-family: monospace;
font-size: 0.9em;
}
.description-text {
line-height: 1.5;
max-height: 100px;
overflow-y: auto;
}
/* Showcase Section */
.showcase-section {
position: relative;
margin-top: var(--space-4);
}
.carousel {
transition: max-height 0.3s ease-in-out;
overflow: hidden;
}
.carousel.collapsed {
max-height: 0;
}
.carousel-container {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.media-wrapper {
position: relative;
width: 100%;
background: var(--lora-surface);
align-items: flex-start;
margin-bottom: var(--space-2);
padding: var(--space-1);
border-radius: var(--border-radius-xs);
}
.media-wrapper:last-child {
margin-bottom: 0;
.setting-item:hover {
background: rgba(0, 0, 0, 0.02);
}
.media-wrapper img,
.media-wrapper video {
[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;
width: 100%;
height: 100%;
object-fit: contain;
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: .3s;
border-radius: 24px;
}
/* Scroll Indicator */
.scroll-indicator {
cursor: pointer;
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s;
}
.scroll-indicator:hover {
background: oklch(var(--lora-accent) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em;
color: var(--text-color);
}
.lazy {
opacity: 0;
transition: opacity 0.3s;
}
.lazy[src] {
opacity: 1;
}
/* Update Trigger Words styles */
.info-item.trigger-words {
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.trigger-words-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
margin-top: var(--space-1);
}
/* Update Trigger Words styles */
.trigger-word-tag {
display: inline-flex;
align-items: center;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px 8px;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
}
/* Update trigger word content color to use theme accent */
.trigger-word-content {
color: var(--lora-accent) !important; /* Override general span color */
font-size: 0.85em;
line-height: 1.4;
word-break: break-word;
}
/* Keep the hover effect using accent color */
.trigger-word-tag:hover {
background: oklch(var(--lora-accent) / 0.1);
border-color: var(--lora-accent);
}
.trigger-word-copy {
display: flex;
align-items: center;
color: var(--text-color);
opacity: 0.5;
flex-shrink: 0;
transition: opacity 0.2s;
}
/* Editable Fields */
.editable-field {
position: relative;
display: flex;
gap: 8px;
align-items: flex-start;
}
.editable-field [contenteditable] {
flex: 1;
min-height: 24px;
padding: 4px 8px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
line-height: 1.4;
color: var(--text-color);
transition: border-color 0.2s;
word-break: break-word;
}
.editable-field [contenteditable]:focus {
outline: none;
border-color: var(--lora-accent);
background: var(--bg-color);
}
.editable-field [contenteditable]:empty::before {
content: attr(data-placeholder);
color: var(--text-color);
opacity: 0.5;
}
.save-btn {
padding: 4px 8px;
background: var(--lora-accent);
border: none;
border-radius: var(--border-radius-xs);
color: white;
cursor: pointer;
transition: opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.save-btn:hover {
opacity: 0.9;
}
.save-btn i {
font-size: 0.9em;
}
@media (max-width: 640px) {
.info-item.usage-tips,
.info-item.notes {
grid-column: 1 / -1;
}
}
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
.modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */
float: right; /* 使用 float 确保按钮在右侧 */
bottom: 20px; /* 距离底部的距离 */
margin-right: 20px; /* 右侧间距 */
margin-top: -56px; /* 负边距确保不占用额外空间 */
width: 36px;
height: 36px;
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .3s;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
}
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);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
opacity: 0.7;
margin-top: 4px;
line-height: 1.4;
}
.modal-content .back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
/* Blur effect for NSFW content */
.nsfw-blur {
filter: blur(12px);
transition: filter 0.3s ease;
}
.modal-content .back-to-top:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* Update Preset Controls styles */
.preset-controls {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.preset-controls select,
.preset-controls input {
padding: var(--space-1);
background: var(--bg-color);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
color: var(--text-color);
}
.preset-tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
.preset-tag {
display: flex;
align-items: center;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
padding: calc(var(--space-1) * 0.5) var(--space-1);
gap: var(--space-1);
transition: all 0.2s ease;
}
.preset-tag span {
color: var(--lora-accent);
font-size: 0.9em;
}
.preset-tag i {
color: var(--text-color);
opacity: 0.5;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-tag:hover {
background: oklch(var(--lora-accent) / 0.1);
border-color: var(--lora-accent);
}
.preset-tag i:hover {
color: var(--lora-error);
opacity: 1;
}
.add-preset-btn {
padding: calc(var(--space-1) * 0.5) var(--space-2);
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: opacity 0.2s;
}
.add-preset-btn:hover {
opacity: 0.9;
}
/* File name copy styles */
.file-name-wrapper {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px;
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
}
.file-name-wrapper:hover {
background: oklch(var(--lora-accent) / 0.1);
}
.file-name-wrapper i {
color: var(--text-color);
opacity: 0.5;
transition: opacity 0.2s;
}
.file-name-wrapper:hover i {
opacity: 1;
color: var(--lora-accent);
}
/* Location and Size combined styles */
.info-item.location-size {
display: block;
}
/* Base Model and Size combined styles */
.info-item.base-size {
display: flex;
gap: var(--space-3);
}
.base-wrapper {
flex: 2; /* 分配更多空间给base model */
}
.size-wrapper {
flex: 1;
border-left: 1px solid var(--lora-border);
padding-left: var(--space-3);
}
.base-wrapper label,
.size-wrapper label {
display: block;
margin-bottom: 4px;
}
.size-wrapper span {
font-family: monospace;
font-size: 0.9em;
opacity: 0.9;
.nsfw-blur:hover {
filter: blur(8px);
}

View File

@@ -0,0 +1,510 @@
/* Search Container Styles */
.search-container {
position: relative;
width: 250px;
margin-left: auto;
flex-shrink: 0; /* 防止搜索框被压缩 */
display: flex;
align-items: center;
gap: 4px;
}
/* 调整搜索框样式以匹配其他控件 */
.search-container input {
width: 100%;
padding: 6px 75px 6px 12px; /* Increased right padding to accommodate both buttons */
border: 1px solid oklch(65% 0.02 256); /* 更深的边框颜色,提高对比度 */
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
color: var(--text-color);
font-size: 0.9em;
height: 32px;
box-sizing: border-box; /* 确保padding不会增加总宽度 */
}
.search-container input:focus {
outline: none;
border-color: var(--lora-accent);
}
.search-icon {
position: absolute;
right: 80px; /* Adjusted to make space for both toggle buttons */
top: 50%;
transform: translateY(-50%);
color: oklch(var(--text-color) / 0.5);
pointer-events: none;
line-height: 1; /* 防止图标影响容器高度 */
}
/* 修改清空按钮样式 */
.search-clear {
position: absolute;
right: 105px; /* Adjusted further left to avoid overlapping */
top: 50%;
transform: translateY(-50%);
color: oklch(var(--text-color) / 0.5);
cursor: pointer;
border: none;
background: none;
padding: 4px 8px; /* 增加点击区域 */
display: none; /* 默认隐藏 */
line-height: 1;
transition: color 0.2s ease;
}
.search-clear:hover {
color: var(--lora-accent);
}
.search-clear.visible {
display: block;
}
.search-mode-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-mode-toggle:hover {
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
border-color: var(--lora-accent);
}
.search-mode-toggle.active {
background-color: oklch(95% 0.05 256); /* Lighter background that's more consistent */
color: var(--lora-accent);
border-color: var(--lora-accent);
}
.search-mode-toggle i {
font-size: 0.9em;
}
.search-filter-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;
position: relative;
}
.search-filter-toggle:hover {
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
border-color: var(--lora-accent);
}
.search-filter-toggle.active {
background-color: oklch(95% 0.05 256); /* Lighter background that's more consistent */
color: var(--lora-accent);
border-color: var(--lora-accent);
}
.search-filter-toggle i {
font-size: 0.9em;
}
.filter-badge {
position: absolute;
top: -6px;
right: -6px;
background-color: var(--lora-accent);
color: white;
width: 16px;
height: 16px;
border-radius: 50%;
font-size: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
/* Filter Panel Styles */
.filter-panel {
position: absolute;
top: 140px; /* Adjust to be closer to the filter button */
right: 20px;
width: 300px;
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); /* Increase z-index to be above cards */
padding: 16px;
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: top right;
max-height: calc(100vh - 160px);
overflow-y: auto;
}
.filter-panel.hidden {
opacity: 0;
transform: scale(0.95);
pointer-events: none;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.filter-header h3 {
margin: 0;
font-size: 18px;
color: var(--text-color);
}
.close-filter-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-filter-btn:hover {
color: var(--lora-accent);
}
.filter-section {
margin-bottom: 16px;
}
.filter-section h4 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--text-color);
opacity: 0.8;
}
.filter-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-tag {
padding: 4px 10px;
border-radius: var(--border-radius-sm);
background-color: var(--lora-surface);
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none; /* Prevent text selection */
-webkit-user-select: none; /* For Safari */
-moz-user-select: none; /* For Firefox */
-ms-user-select: none; /* For IE/Edge */
}
.filter-tag:hover {
background-color: var(--lora-surface-hover);
}
.filter-tag.active {
background-color: var(--lora-accent);
color: white;
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;
justify-content: center;
margin-top: 16px;
gap: 8px;
}
.clear-filters-btn {
background-color: transparent;
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 6px 12px;
border-radius: var(--border-radius-sm);
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
width: 100%;
}
.clear-filters-btn:hover {
background-color: var(--lora-surface-hover);
}
/* Mobile Responsive Styles */
@media (max-width: 768px) {
.search-container {
width: 100%;
order: -1;
margin-left: 0;
margin-right: 0;
}
.filter-panel {
width: calc(100% - 40px);
left: 20px;
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%;
}

View File

@@ -0,0 +1,184 @@
/* Support Modal Styles */
.support-modal {
max-width: 550px;
}
.support-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.support-icon {
font-size: 1.8em;
color: var(--lora-error);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.support-content {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.support-content > p {
font-size: 1.1em;
text-align: center;
margin-bottom: var(--space-1);
}
.support-section {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
margin-bottom: var(--space-2);
}
.support-section h3 {
display: flex;
align-items: center;
gap: 8px;
margin-top: 0;
margin-bottom: var(--space-1);
font-size: 1.1em;
color: var(--lora-accent);
}
.support-section h3 i {
opacity: 0.8;
}
.support-section p {
margin-top: 4px;
margin-bottom: var(--space-1);
color: var(--text-color);
opacity: 0.9;
}
.support-links {
display: flex;
gap: var(--space-2);
margin-top: var(--space-1);
}
.social-link {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
text-decoration: none;
color: var(--text-color);
transition: all 0.2s ease;
}
.social-link:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.kofi-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 20px;
background: #FF5E5B;
color: white;
border-radius: var(--border-radius-sm);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
margin-top: var(--space-1);
}
.kofi-button:hover {
background: #E04946;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.support-footer {
text-align: center;
margin-top: var(--space-1);
font-style: italic;
color: var(--text-color);
}
/* Add support toggle button style */
.support-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--lora-error);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.support-toggle:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.support-toggle i {
font-size: 1.1em;
position: relative;
top: 1px;
left: -0.5px;
}
@media (max-width: 480px) {
.support-links {
flex-direction: column;
}
}
/* Civitai link styles */
.civitai-link {
display: flex;
align-items: center;
gap: 8px;
}
.civitai-icon {
width: 20px;
height: 20px;
color: #1b98e4; /* Civitai brand blue color */
}
.social-link:hover .civitai-icon {
color: white; /* Icon color changes to white on hover */
}
/* 增强hover状态的视觉反馈 */
.social-link:hover,
.update-link:hover,
.folder-item:hover {
border-color: var(--lora-accent);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

View File

@@ -0,0 +1,195 @@
/* Update Modal Styles */
.update-modal {
max-width: 600px;
}
.update-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.update-icon {
font-size: 1.8em;
color: var(--lora-accent);
animation: bounce 1.5s infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.update-content {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.update-info {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.version-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.current-version, .new-version {
display: flex;
align-items: center;
gap: 10px;
}
.label {
font-size: 0.9em;
color: var(--text-color);
opacity: 0.8;
}
.version-number {
font-family: monospace;
font-weight: 600;
}
.new-version .version-number {
color: var(--lora-accent);
}
.update-link {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
text-decoration: none;
color: var(--text-color);
transition: all 0.2s ease;
}
.update-link:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.changelog-section {
background: rgba(0, 0, 0, 0.02); /* 轻微的灰色背景 */
border: 1px solid rgba(0, 0, 0, 0.08); /* 更明显的边框 */
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.changelog-section h3 {
margin-top: 0;
margin-bottom: var(--space-2);
color: var(--lora-accent);
font-size: 1.1em;
}
.changelog-content {
max-height: 300px; /* Increased height since we removed instructions */
overflow-y: auto;
}
.changelog-item {
margin-bottom: var(--space-2);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--lora-border);
}
.changelog-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.changelog-item h4 {
margin-top: 0;
margin-bottom: 8px;
font-size: 1em;
color: var(--text-color);
}
.changelog-item ul {
margin: 0;
padding-left: 20px;
}
.changelog-item li {
margin-bottom: 4px;
color: var(--text-color);
}
@media (max-width: 480px) {
.update-info {
flex-direction: column;
gap: var(--space-2);
}
.version-info {
width: 100%;
}
}
/* Update preferences section */
.update-preferences {
border-top: 1px solid var(--lora-border);
margin-top: var(--space-2);
padding-top: var(--space-2);
display: flex;
align-items: center;
justify-content: flex-start;
}
/* Override toggle switch styles for update preferences */
.update-preferences .toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
width: auto;
height: 24px;
cursor: pointer;
}
.update-preferences .toggle-slider {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
flex-shrink: 0;
margin-right: 10px;
}
.update-preferences .toggle-label {
margin-left: 0;
white-space: nowrap;
line-height: 24px;
}
@media (max-width: 480px) {
.update-preferences {
flex-direction: row;
flex-wrap: wrap;
}
.update-preferences .toggle-label {
margin-top: 5px;
}
}

View File

@@ -19,108 +19,111 @@
width: 100%;
}
.search-container {
position: relative;
width: 250px;
margin-left: auto;
flex-shrink: 0; /* 防止搜索框被压缩 */
/* Search and filter styles moved to components/search-filter.css */
/* Update corner-controls for collapsible behavior */
.corner-controls {
position: fixed;
top: 20px;
right: 20px;
z-index: var(--z-overlay);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transition: all 0.3s ease;
}
/* 调整搜索框样式以匹配其他控件 */
.search-container input {
width: 100%;
padding: 6px 40px 6px 12px; /* 减小右侧padding */
border: 1px solid oklch(65% 0.02 256); /* 更深的边框颜色,提高对比度 */
border-radius: var(--border-radius-sm);
background: var(--lora-surface);
.corner-controls-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 0.9em;
height: 32px;
box-sizing: border-box; /* 确保padding不会增加总宽度 */
}
.search-container input:focus {
outline: none;
border-color: var(--lora-accent);
}
.search-icon {
position: absolute;
right: 40px; /* 调整到toggle按钮左侧 */
top: 50%;
transform: translateY(-50%);
color: oklch(var(--text-color) / 0.5);
pointer-events: none;
line-height: 1; /* 防止图标影响容器高度 */
}
/* 修改清空按钮样式 */
.search-clear {
position: absolute;
right: 65px; /* 放到search-icon左侧 */
top: 50%;
transform: translateY(-50%);
color: oklch(var(--text-color) / 0.5);
cursor: pointer;
border: none;
background: none;
padding: 4px 8px; /* 增加点击区域 */
display: none; /* 默认隐藏 */
line-height: 1;
transition: color 0.2s ease;
}
.search-clear:hover {
color: var(--lora-accent);
}
.search-clear.visible {
display: block;
}
.search-mode-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;
z-index: 2;
margin-bottom: 10px;
}
.search-mode-toggle:hover {
.corner-controls-toggle:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
.search-mode-toggle.active {
background: var(--lora-accent);
color: white;
}
.search-mode-toggle i {
font-size: 0.9em;
}
.corner-controls {
position: fixed;
top: 20px;
right: 20px;
.corner-controls-items {
display: flex;
flex-direction: column;
gap: 10px;
z-index: var(--z-overlay);
opacity: 0;
transform: translateY(-10px) scale(0.9);
transition: all 0.3s ease;
pointer-events: none;
}
/* Expanded state */
.corner-controls.expanded .corner-controls-items {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: all;
}
/* Expanded state - only expand on hover if not already expanded by click */
.corner-controls:hover:not(.expanded) .corner-controls-items {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: all;
}
/* Ensure hidden class works properly */
.hidden {
display: none !important;
}
/* Update toggle button styles */
.update-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color); /* Changed from var(--lora-accent) to match other toggles */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.update-toggle:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* Update badge styles */
.update-badge {
position: absolute;
top: -3px;
right: -3px;
background-color: var(--lora-error);
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: 0 0 0 2px var(--card-bg);
}
/* Badge on corner toggle */
.corner-badge {
top: 0;
right: 0;
}
/* Folder Tags Container */
.folder-tags-container {
position: relative;
width: 100%;
@@ -280,6 +283,33 @@
transform: translateY(-2px);
}
.support-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--lora-error);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.support-toggle:hover {
background: var(--lora-error);
color: white;
transform: translateY(-2px);
}
.support-toggle i {
font-size: 1.1em;
position: relative;
top: 1px;
left: -0.5px;
}
.theme-toggle img {
width: 20px;
height: 20px;
@@ -319,12 +349,6 @@
gap: 15px;
}
.search-container {
width: 100%;
order: -1;
margin-left: 0;
}
.folder-tags-container {
order: -1;
}
@@ -346,13 +370,28 @@
}
.corner-controls {
/* Keep the fixed positioning even on mobile */
position: fixed;
top: 20px;
right: 20px;
top: 10px;
right: 10px;
}
.corner-controls-items {
display: none;
}
.corner-controls.expanded .corner-controls-items {
display: flex;
}
.back-to-top {
bottom: 60px; /* Give some extra space from bottom on mobile */
}
}
}
/* Standardize button widths in controls */
.control-group button {
min-width: 100px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}

View File

@@ -7,9 +7,15 @@
/* Import Components */
@import 'components/card.css';
@import 'components/modal.css';
@import 'components/download-modal.css';
@import 'components/toast.css';
@import 'components/loading.css';
@import 'components/menu.css';
@import 'components/update-modal.css';
@import 'components/lora-modal.css';
@import 'components/support-modal.css';
@import 'components/search-filter.css';
@import 'components/bulk.css';
.initialization-notice {
display: flex;

View File

@@ -30,6 +30,18 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
params.append('search', searchInput.value.trim());
params.append('fuzzy', 'true');
}
// Add filter parameters if active
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());
@@ -101,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) => {
@@ -319,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;
}
}

View File

@@ -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) {

View File

@@ -1,6 +1,8 @@
import { showToast } from '../utils/uiHelpers.js';
import { modalManager } from '../managers/ModalManager.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');
@@ -17,13 +19,46 @@ 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)) {
card.classList.add('selected');
}
const version = state.previewVersions.get(lora.file_path);
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">
@@ -31,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">
@@ -47,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>
@@ -60,25 +107,89 @@ export function createLoraCard(lora) {
</div>
`;
// Main card click event
// Main card click event - modified to handle bulk mode
card.addEventListener('click', () => {
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
civitai: JSON.parse(card.dataset.meta || '{}')
};
showLoraModal(loraMeta);
// Check if we're in bulk mode
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
} else {
// Normal behavior - show modal
const loraMeta = {
sha256: card.dataset.sha256,
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
folder: card.dataset.folder,
modified: card.dataset.modified,
file_size: card.dataset.file_size,
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
// 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();
@@ -127,477 +238,49 @@ export function createLoraCard(lora) {
e.stopPropagation();
replacePreview(lora.file_path);
});
// Apply bulk mode styling if currently in bulk mode
if (state.bulkMode) {
const actions = card.querySelectorAll('.card-actions');
actions.forEach(actionGroup => {
actionGroup.style.display = 'none';
});
}
return card;
}
export function showLoraModal(lora) {
const escapedWords = lora.civitai?.trainedWords?.length ?
lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
const content = `
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('loraModal')">&times;</button>
<header class="modal-header">
<h2>${lora.model_name}</h2>
</header>
<div class="modal-body">
<div class="info-section">
<div class="info-grid">
<div class="info-item">
<label>Version</label>
<span>${lora.civitai.name || 'N/A'}</span>
</div>
<div class="info-item">
<label>File Name</label>
<div class="file-name-wrapper" onclick="copyFileName('${lora.file_name}')">
<span id="file-name">${lora.file_name || 'N/A'}</span>
<i class="fas fa-copy" title="Copy file name"></i>
</div>
</div>
<div class="info-item location-size">
<div class="location-wrapper">
<label>Location</label>
<span class="file-path">${lora.file_path.replace(/[^/]+$/, '') || 'N/A'}</span>
</div>
</div>
<div class="info-item base-size">
<div class="base-wrapper">
<label>Base Model</label>
<span>${lora.base_model || 'N/A'}</span>
</div>
<div class="size-wrapper">
<label>Size</label>
<span>${formatFileSize(lora.file_size)}</span>
</div>
</div>
<div class="info-item usage-tips">
<label>Usage Tips</label>
<div class="editable-field">
<div class="preset-controls">
<select id="preset-selector">
<option value="">Add preset parameter...</option>
<option value="strength_min">Strength Min</option>
<option value="strength_max">Strength Max</option>
<option value="strength">Strength</option>
<option value="clip_skip">Clip Skip</option>
</select>
<input type="number" id="preset-value" step="0.01" placeholder="Value" style="display:none;">
<button class="add-preset-btn">Add</button>
</div>
<div class="preset-tags">
${renderPresetTags(parsePresets(lora.usage_tips))}
</div>
</div>
</div>
${renderTriggerWords(escapedWords)}
<div class="info-item notes">
<label>Additional Notes</label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${lora.notes || 'Add your notes here...'}</div>
<button class="save-btn" onclick="saveNotes('${lora.file_path}')">
<i class="fas fa-save"></i>
</button>
</div>
</div>
<div class="info-item full-width">
<label>About this version</label>
<div class="description-text">${lora.description || 'N/A'}</div>
</div>
</div>
</div>
${renderShowcaseImages(lora.civitai.images)}
</div>
</div>
`;
// Add a method to update card appearance based on bulk mode
export function updateCardsForBulkMode(isBulkMode) {
// Update the state
state.bulkMode = isBulkMode;
modalManager.showModal('loraModal', content);
setupEditableFields();
setupShowcaseScroll(); // Add this line
}
// 添加复制文件名的函数
window.copyFileName = async function(fileName) {
try {
await navigator.clipboard.writeText(fileName);
showToast('File name copied', 'success');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
};
function setupEditableFields() {
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
document.body.classList.toggle('bulk-mode', isBulkMode);
editableFields.forEach(field => {
field.addEventListener('focus', function() {
if (this.textContent === 'Add your notes here...' ||
this.textContent === 'Save usage tips here..') {
this.textContent = '';
}
});
field.addEventListener('blur', function() {
if (this.textContent.trim() === '') {
this.textContent = this.classList.contains('usage-tips-content')
? 'Save usage tips here..'
: 'Add your notes here...';
}
});
});
const presetSelector = document.getElementById('preset-selector');
const presetValue = document.getElementById('preset-value');
const addPresetBtn = document.querySelector('.add-preset-btn');
const presetTags = document.querySelector('.preset-tags');
presetSelector.addEventListener('change', function() {
const selected = this.value;
if (selected) {
presetValue.style.display = 'inline-block';
presetValue.min = selected.includes('strength') ? 0 : 1;
presetValue.max = selected.includes('strength') ? 1 : 12;
presetValue.step = selected.includes('strength') ? 0.01 : 1;
if (selected === 'clip_skip') {
presetValue.type = 'number';
presetValue.step = 1;
}
// Add auto-focus
setTimeout(() => presetValue.focus(), 0);
// 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');
// Handle display property based on mode
if (isBulkMode) {
// Hide actions when entering bulk mode
actions.forEach(actionGroup => {
actionGroup.style.display = 'none';
});
} else {
presetValue.style.display = 'none';
// 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';
});
}
});
addPresetBtn.addEventListener('click', async function() {
const key = presetSelector.value;
const value = presetValue.value;
if (!key || !value) return;
const filePath = document.querySelector('.modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
currentPresets[key] = parseFloat(value);
const newPresetsJson = JSON.stringify(currentPresets);
await saveModelMetadata(filePath, {
usage_tips: newPresetsJson
});
loraCard.dataset.usage_tips = newPresetsJson;
presetTags.innerHTML = renderPresetTags(currentPresets);
presetSelector.value = '';
presetValue.value = '';
presetValue.style.display = 'none';
});
// Add keydown event listeners for notes
const notesContent = document.querySelector('.notes-content');
if (notesContent) {
notesContent.addEventListener('keydown', async function(e) {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Allow shift+enter for new line
return;
}
e.preventDefault();
const filePath = document.querySelector('.modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
.querySelector('#file-name').textContent + '.safetensors';
await saveNotes(filePath);
}
});
// Apply selection state to cards if entering bulk mode
if (isBulkMode) {
bulkManager.applySelectionState();
}
// Add keydown event for preset value
presetValue.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addPresetBtn.click();
}
});
}
window.saveNotes = async function(filePath) {
const content = document.querySelector('.notes-content').textContent;
try {
await saveModelMetadata(filePath, { notes: content });
// Update the corresponding lora card's dataset
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (loraCard) {
loraCard.dataset.notes = content;
}
showToast('Notes saved successfully', 'success');
} catch (error) {
showToast('Failed to save notes', 'error');
}
};
async function saveModelMetadata(filePath, data) {
const response = await fetch('/loras/api/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
}
function renderTriggerWords(words) {
if (!words.length) return `
<div class="info-item full-width trigger-words">
<label>Trigger Words</label>
<span>No trigger word needed</span>
</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>
</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>
</div>
<button class="back-to-top" onclick="scrollToTop(this)">
<i class="fas fa-arrow-up"></i>
</button>
</div>
`;
}
// Add this to the window object for global access
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed');
if (isCollapsed) {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initLazyLoading(carousel);
} else {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`;
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
}
};
// Add lazy loading initialization
function initLazyLoading(container) {
const lazyElements = container.querySelectorAll('.lazy');
const lazyLoad = (element) => {
if (element.tagName.toLowerCase() === 'video') {
element.src = element.dataset.src;
element.querySelector('source').src = element.dataset.src;
element.load();
} else {
element.src = element.dataset.src;
}
element.classList.remove('lazy');
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
lazyLoad(entry.target);
observer.unobserve(entry.target);
}
});
});
lazyElements.forEach(element => observer.observe(element));
}
export function setupShowcaseScroll() {
// Change from modal-content to window/document level
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector('.modal-content');
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section');
if (!showcase) return;
const carousel = showcase.querySelector('.carousel');
const scrollIndicator = showcase.querySelector('.scroll-indicator');
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (isNearBottom) {
toggleShowcase(scrollIndicator);
event.preventDefault();
}
}
}, { passive: false }); // Add passive: false option here
// Keep the existing scroll tracking code
const modalContent = document.querySelector('.modal-content');
if (modalContent) {
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
}
});
}
}
export function scrollToTop(button) {
const modalContent = button.closest('.modal-content');
if (modalContent) {
modalContent.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}
function parsePresets(usageTips) {
if (!usageTips || usageTips === 'Save usage tips here..') return {};
try {
return JSON.parse(usageTips);
} catch {
return {};
}
}
function renderPresetTags(presets) {
return Object.entries(presets).map(([key, value]) => `
<div class="preset-tag" data-key="${key}">
<span>${formatPresetKey(key)}: ${value}</span>
<i class="fas fa-times" onclick="removePreset('${key}')"></i>
</div>
`).join('');
}
function formatPresetKey(key) {
return key.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
window.removePreset = async function(key) {
const filePath = document.querySelector('.modal-content')
.querySelector('.file-path').textContent +
document.querySelector('.modal-content')
.querySelector('#file-name').textContent + '.safetensors';
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard.dataset.usage_tips);
delete currentPresets[key];
const newPresetsJson = JSON.stringify(currentPresets);
await saveModelMetadata(filePath, {
usage_tips: newPresetsJson
});
loraCard.dataset.usage_tips = newPresetsJson;
document.querySelector('.preset-tags').innerHTML = renderPresetTags(currentPresets);
};
// 添加文件大小格式化函数
function formatFileSize(bytes) {
console.log('formatFileSize: ', bytes);
if (!bytes) return 'N/A';
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,10 @@
import { debounce } from './utils/debounce.js';
import { LoadingManager } from './managers/LoadingManager.js';
import { modalManager } from './managers/ModalManager.js';
import { state } from './state/index.js';
import { showLoraModal, toggleShowcase, scrollToTop } from './components/LoraCard.js';
import { updateService } from './managers/UpdateService.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';
import {
showToast,
@@ -24,8 +26,15 @@ import { DownloadManager } from './managers/DownloadManager.js';
import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js';
import { LoraContextMenu } from './components/ContextMenu.js';
import { moveManager } from './managers/MoveManager.js';
import { FilterManager } from './managers/FilterManager.js';
import { createLoraCard, updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
// Export all functions that need global access
// Add bulk mode to state
state.bulkMode = false;
state.selectedLoras = new Set();
// Export functions to global window object
window.loadMoreLoras = loadMoreLoras;
window.fetchCivitai = fetchCivitai;
window.deleteModel = deleteModel;
@@ -48,11 +57,30 @@ window.moveManager = moveManager;
window.toggleShowcase = toggleShowcase;
window.scrollToTop = scrollToTop;
// Export bulk manager methods to window
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
window.clearSelection = () => bulkManager.clearSelection();
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager;
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
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
window.downloadManager = new DownloadManager(); // Move this after modalManager initialization
window.filterManager = new FilterManager(); // Initialize filter manager
// Initialize state filters from filterManager if available
if (window.filterManager && window.filterManager.filters) {
state.filters = { ...window.filterManager.filters };
}
initializeInfiniteScroll();
initializeEventListeners();
lazyLoadImages();
@@ -62,6 +90,12 @@ document.addEventListener('DOMContentLoaded', () => {
initBackToTop();
window.searchManager = new SearchManager();
new LoraContextMenu();
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize the bulk manager
bulkManager.initialize();
});
// Initialize event listeners

View File

@@ -0,0 +1,343 @@
import { state } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/LoraCard.js';
export class BulkManager {
constructor() {
this.bulkBtn = document.getElementById('bulkOperationsBtn');
this.bulkPanel = document.getElementById('bulkOperationsPanel');
this.isStripVisible = false; // Track strip visibility state
// Initialize selected loras set in state if not already there
if (!state.selectedLoras) {
state.selectedLoras = new Set();
}
// Cache for lora metadata to handle non-visible selected loras
if (!state.loraMetadataCache) {
state.loraMetadataCache = new Map();
}
}
initialize() {
// Add event listeners if needed
// (Already handled via onclick attributes in HTML, but could be moved here)
// Add event listeners for the selected count to toggle thumbnail strip
const selectedCount = document.getElementById('selectedCount');
if (selectedCount) {
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
}
}
toggleBulkMode() {
// Toggle the state
state.bulkMode = !state.bulkMode;
// Update UI
this.bulkBtn.classList.toggle('active', state.bulkMode);
// Important: Remove the hidden class when entering bulk mode
if (state.bulkMode) {
this.bulkPanel.classList.remove('hidden');
// Use setTimeout to ensure the DOM updates before adding visible class
// This helps with the transition animation
setTimeout(() => {
this.bulkPanel.classList.add('visible');
}, 10);
} else {
this.bulkPanel.classList.remove('visible');
// Add hidden class back after transition completes
setTimeout(() => {
this.bulkPanel.classList.add('hidden');
}, 400); // Match this with the transition duration in CSS
// Hide thumbnail strip if it's visible
this.hideThumbnailStrip();
}
// First update all cards' visual state before clearing selection
updateCardsForBulkMode(state.bulkMode);
// 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');
});
}
}
clearSelection() {
document.querySelectorAll('.lora-card.selected').forEach(card => {
card.classList.remove('selected');
});
state.selectedLoras.clear();
this.updateSelectedCount();
// Hide thumbnail strip if it's visible
this.hideThumbnailStrip();
}
updateSelectedCount() {
const countElement = document.getElementById('selectedCount');
if (countElement) {
// Set text content without the icon
countElement.textContent = `${state.selectedLoras.size} selected `;
// Re-add the caret icon with proper direction
const caretIcon = document.createElement('i');
// Use down arrow if strip is visible, up arrow if not
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
countElement.appendChild(caretIcon);
// If there are no selections, hide the thumbnail strip
if (state.selectedLoras.size === 0) {
this.hideThumbnailStrip();
}
}
}
toggleCardSelection(card) {
const filepath = card.dataset.filepath;
if (card.classList.contains('selected')) {
card.classList.remove('selected');
state.selectedLoras.delete(filepath);
} else {
card.classList.add('selected');
state.selectedLoras.add(filepath);
// Cache the metadata for this lora
state.loraMetadataCache.set(filepath, {
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
previewUrl: this.getCardPreviewUrl(card),
isVideo: this.isCardPreviewVideo(card),
modelName: card.dataset.name
});
}
this.updateSelectedCount();
// Update thumbnail strip if it's visible
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
}
// Helper method to get preview URL from a card
getCardPreviewUrl(card) {
const img = card.querySelector('img');
const video = card.querySelector('video source');
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
}
// Helper method to check if preview is a video
isCardPreviewVideo(card) {
return card.querySelector('video') !== null;
}
// Apply selection state to cards after they are refreshed
applySelectionState() {
if (!state.bulkMode) return;
document.querySelectorAll('.lora-card').forEach(card => {
const filepath = card.dataset.filepath;
if (state.selectedLoras.has(filepath)) {
card.classList.add('selected');
// Update the cache with latest data
state.loraMetadataCache.set(filepath, {
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
previewUrl: this.getCardPreviewUrl(card),
isVideo: this.isCardPreviewVideo(card),
modelName: card.dataset.name
});
} else {
card.classList.remove('selected');
}
});
this.updateSelectedCount();
}
async copyAllLorasSyntax() {
if (state.selectedLoras.size === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
const loraSyntaxes = [];
const missingLoras = [];
// Process all selected loras using our metadata cache
for (const filepath of state.selectedLoras) {
const metadata = state.loraMetadataCache.get(filepath);
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
const strength = usageTips.strength || 1;
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
} else {
// If we don't have metadata, this is an error case
missingLoras.push(filepath);
}
}
// Handle any loras with missing metadata
if (missingLoras.length > 0) {
console.warn('Missing metadata for some selected loras:', missingLoras);
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
}
if (loraSyntaxes.length === 0) {
showToast('No valid LoRAs to copy', 'error');
return;
}
try {
await navigator.clipboard.writeText(loraSyntaxes.join(', '));
showToast(`Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`, 'success');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
}
// Create and show the thumbnail strip of selected LoRAs
toggleThumbnailStrip() {
// If no items are selected, do nothing
if (state.selectedLoras.size === 0) return;
const existing = document.querySelector('.selected-thumbnails-strip');
if (existing) {
this.hideThumbnailStrip();
} else {
this.showThumbnailStrip();
}
}
showThumbnailStrip() {
// Create the thumbnail strip container
const strip = document.createElement('div');
strip.className = 'selected-thumbnails-strip';
// Create a container for the thumbnails (for scrolling)
const thumbnailContainer = document.createElement('div');
thumbnailContainer.className = 'thumbnails-container';
strip.appendChild(thumbnailContainer);
// Position the strip above the bulk operations panel
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
// Populate the thumbnails
this.updateThumbnailStrip();
// Update strip visibility state and caret direction
this.isStripVisible = true;
this.updateSelectedCount(); // Update caret
// Add animation class after a short delay to trigger transition
setTimeout(() => strip.classList.add('visible'), 10);
}
hideThumbnailStrip() {
const strip = document.querySelector('.selected-thumbnails-strip');
if (strip) {
strip.classList.remove('visible');
// Update strip visibility state and caret direction
this.isStripVisible = false;
this.updateSelectedCount(); // Update caret
// Wait for animation to complete before removing
setTimeout(() => {
if (strip.parentNode) {
strip.parentNode.removeChild(strip);
}
}, 300);
}
}
updateThumbnailStrip() {
const container = document.querySelector('.thumbnails-container');
if (!container) return;
// Clear existing thumbnails
container.innerHTML = '';
// Add a thumbnail for each selected LoRA
for (const filepath of state.selectedLoras) {
const metadata = state.loraMetadataCache.get(filepath);
if (!metadata) continue;
const thumbnail = document.createElement('div');
thumbnail.className = 'selected-thumbnail';
thumbnail.dataset.filepath = filepath;
// Create the visual element (image or video)
if (metadata.isVideo) {
thumbnail.innerHTML = `
<video autoplay loop muted playsinline>
<source src="${metadata.previewUrl}" type="video/mp4">
</video>
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
`;
} else {
thumbnail.innerHTML = `
<img src="${metadata.previewUrl}" alt="${metadata.modelName}">
<span class="thumbnail-name" title="${metadata.modelName}">${metadata.modelName}</span>
<button class="thumbnail-remove"><i class="fas fa-times"></i></button>
`;
}
// Add click handler for deselection
thumbnail.addEventListener('click', (e) => {
if (!e.target.closest('.thumbnail-remove')) {
this.deselectItem(filepath);
}
});
// Add click handler for the remove button
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
e.stopPropagation();
this.deselectItem(filepath);
});
container.appendChild(thumbnail);
}
}
deselectItem(filepath) {
// Find and deselect the corresponding card if it's in the DOM
const card = document.querySelector(`.lora-card[data-filepath="${filepath}"]`);
if (card) {
card.classList.remove('selected');
}
// Remove from the selection set
state.selectedLoras.delete(filepath);
// Update UI
this.updateSelectedCount();
this.updateThumbnailStrip();
// Hide the strip if no more selections
if (state.selectedLoras.size === 0) {
this.hideThumbnailStrip();
}
}
}
// Create a singleton instance
export const bulkManager = new BulkManager();

View File

@@ -71,7 +71,6 @@ export class DownloadManager {
const errorElement = document.getElementById('urlError');
try {
// Show loading while fetching versions
this.loadingManager.showSimpleLoading('Fetching model versions...');
const modelId = this.extractModelId(url);
@@ -98,7 +97,6 @@ export class DownloadManager {
} catch (error) {
errorElement.textContent = error.message;
} finally {
// Hide loading when done
this.loadingManager.hide();
}
}
@@ -120,19 +118,36 @@ export class DownloadManager {
const versionList = document.getElementById('versionList');
versionList.innerHTML = this.versions.map(version => {
// Find first image (skip videos)
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
const fileSize = (version.files[0]?.sizeKB / 1024).toFixed(2); // Convert to MB
// Use version-level size or fallback to first file
const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) :
(version.files[0]?.sizeKB / 1024).toFixed(2);
// Use version-level existsLocally flag
const existsLocally = version.existsLocally;
const localPath = version.localPath;
// 更新本地状态指示器为badge样式
const localStatus = existsLocally ?
`<div class="local-badge">
<i class="fas fa-check"></i> In Library
<div class="local-path">${localPath || ''}</div>
</div>` : '';
return `
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''}"
<div class="version-item ${this.currentVersion?.id === version.id ? 'selected' : ''} ${existsLocally ? 'exists-locally' : ''}"
onclick="downloadManager.selectVersion('${version.id}')">
<div class="version-thumbnail">
<img src="${thumbnailUrl}" alt="Version preview">
</div>
<div class="version-content">
<h3>${version.name}</h3>
<div class="version-header">
<h3>${version.name}</h3>
${localStatus}
</div>
<div class="version-info">
${version.baseModel ? `<div class="base-model">${version.baseModel}</div>` : ''}
</div>
@@ -144,15 +159,45 @@ export class DownloadManager {
</div>
`;
}).join('');
// Update Next button state based on initial selection
this.updateNextButtonState();
}
selectVersion(versionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === versionId.toString());
if (!this.currentVersion) return;
// Remove the toast notification - it's redundant with the visual indicator
// const existsLocally = this.currentVersion.files[0]?.existsLocally;
// if (existsLocally) {
// showToast('This version already exists in your library', 'info');
// }
document.querySelectorAll('.version-item').forEach(item => {
item.classList.toggle('selected', item.querySelector('h3').textContent === this.currentVersion.name);
});
// Update Next button state after selection
this.updateNextButtonState();
}
// Update this method to use version-level existsLocally
updateNextButtonState() {
const nextButton = document.querySelector('#versionStep .primary-btn');
if (!nextButton) return;
const existsLocally = this.currentVersion?.existsLocally;
if (existsLocally) {
nextButton.disabled = true;
nextButton.classList.add('disabled');
nextButton.textContent = 'Already in Library';
} else {
nextButton.disabled = false;
nextButton.classList.remove('disabled');
nextButton.textContent = 'Next';
}
}
async proceedToLocation() {
@@ -160,6 +205,13 @@ export class DownloadManager {
showToast('Please select a version', 'error');
return;
}
// Double-check if the version exists locally
const existsLocally = this.currentVersion.existsLocally;
if (existsLocally) {
showToast('This version already exists in your library', 'info');
return;
}
document.getElementById('versionStep').style.display = 'none';
document.getElementById('locationStep').style.display = 'block';
@@ -222,7 +274,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') {

View File

@@ -0,0 +1,279 @@
import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js';
import { state } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { resetAndReload } from '../api/loraApi.js';
export class FilterManager {
constructor() {
this.filters = {
baseModel: [],
tags: []
};
this.filterPanel = document.getElementById('filterPanel');
this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.initialize();
}
initialize() {
// Create base model filter tags
this.createBaseModelTags();
// Close filter panel when clicking outside
document.addEventListener('click', (e) => {
if (!this.filterPanel.contains(e.target) &&
e.target !== this.filterButton &&
!this.filterButton.contains(e.target) &&
!this.filterPanel.classList.contains('hidden')) {
this.closeFilterPanel();
}
});
// Initialize active filters from localStorage if available
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;
baseModelTagsContainer.innerHTML = '';
Object.entries(BASE_MODELS).forEach(([key, value]) => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag ${BASE_MODEL_CLASSES[value]}`;
tag.dataset.baseModel = value;
tag.innerHTML = value;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(value)) {
this.filters.baseModel.push(value);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(model => model !== value);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
}
toggleFilterPanel() {
const wasHidden = this.filterPanel.classList.contains('hidden');
this.filterPanel.classList.toggle('hidden');
// If the panel is being opened, load the top tags and update selections
if (wasHidden) {
this.loadTopTags();
this.updateTagSelections();
}
}
closeFilterPanel() {
this.filterPanel.classList.add('hidden');
}
updateTagSelections() {
// Update base model tags
const baseModelTags = document.querySelectorAll('.base-model-tag');
baseModelTags.forEach(tag => {
const baseModel = tag.dataset.baseModel;
if (this.filters.baseModel.includes(baseModel)) {
tag.classList.add('active');
} else {
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 + this.filters.tags.length;
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
this.activeFiltersCount.style.display = 'inline-flex';
} else {
this.activeFiltersCount.style.display = 'none';
}
}
async applyFilters(showToastNotification = true) {
// Save filters to localStorage
localStorage.setItem('loraFilters', JSON.stringify(this.filters));
// Update state with current filters
state.filters = { ...this.filters };
// Reload loras with filters applied
await resetAndReload();
// Update filter button to show active state
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
if (showToastNotification) {
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');
if (showToastNotification) {
showToast('Filters cleared', 'info');
}
}
}
async clearFilters() {
// Clear all filters
this.filters = {
baseModel: [],
tags: []
};
// Update state
state.filters = { ...this.filters };
// Update UI
this.updateTagSelections();
this.updateActiveFiltersCount();
// Remove from localStorage
localStorage.removeItem('loraFilters');
// Update UI and reload data
this.filterButton.classList.remove('active');
await resetAndReload();
}
loadFiltersFromStorage() {
const savedFilters = localStorage.getItem('loraFilters');
if (savedFilters) {
try {
const parsedFilters = JSON.parse(savedFilters);
// Ensure backward compatibility with older filter format
this.filters = {
baseModel: parsedFilters.baseModel || [],
tags: parsedFilters.tags || []
};
this.updateTagSelections();
this.updateActiveFiltersCount();
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
}
} catch (error) {
console.error('Error loading filters from storage:', error);
}
}
}
hasActiveFilters() {
return this.filters.baseModel.length > 0 || this.filters.tags.length > 0;
}
}

View File

@@ -2,6 +2,7 @@ export class ModalManager {
constructor() {
this.modals = new Map();
this.scrollPosition = 0;
this.currentOpenModal = null; // Track currently open modal
}
initialize() {
@@ -52,6 +53,30 @@ export class ModalManager {
document.body.classList.remove('modal-open');
}
});
// Add supportModal registration
this.registerModal('supportModal', {
element: document.getElementById('supportModal'),
onClose: () => {
this.getModal('supportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
// Add updateModal registration
this.registerModal('updateModal', {
element: document.getElementById('updateModal'),
onClose: () => {
this.getModal('updateModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
}
});
// Set up event listeners for modal toggles
const supportToggle = document.getElementById('supportToggleBtn');
if (supportToggle) {
supportToggle.addEventListener('click', () => this.toggleModal('supportModal'));
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
@@ -78,10 +103,26 @@ export class ModalManager {
return this.modals.get(id);
}
// Check if any modal is currently open
isAnyModalOpen() {
for (const [id, modal] of this.modals) {
if (modal.isOpen) {
return id;
}
}
return null;
}
showModal(id, content = null, onCloseCallback = null) {
const modal = this.getModal(id);
if (!modal) return;
// Close any open modal before showing the new one
const openModalId = this.isAnyModalOpen();
if (openModalId && openModalId !== id) {
this.closeModal(openModalId);
}
if (content) {
modal.element.innerHTML = content;
}
@@ -101,6 +142,7 @@ export class ModalManager {
}
modal.isOpen = true;
this.currentOpenModal = id; // Update currently open modal
document.body.style.top = `-${this.scrollPosition}px`;
document.body.classList.add('modal-open');
}
@@ -112,6 +154,11 @@ export class ModalManager {
modal.onClose();
modal.isOpen = false;
// Clear current open modal if this is the one being closed
if (this.currentOpenModal === id) {
this.currentOpenModal = null;
}
// Remove fixed positioning and restore scroll position
document.body.classList.remove('modal-open');
document.body.style.top = '';
@@ -126,15 +173,26 @@ export class ModalManager {
handleEscape(e) {
if (e.key === 'Escape') {
// Close the last opened modal
for (const [id, modal] of this.modals) {
if (modal.isOpen) {
this.closeModal(id);
break;
}
// Close the current open modal if it exists
if (this.currentOpenModal) {
this.closeModal(this.currentOpenModal);
}
}
}
toggleModal(id, content = null, onCloseCallback = null) {
const modal = this.getModal(id);
if (!modal) return;
// If this modal is already open, close it
if (modal.isOpen) {
this.closeModal(id);
return;
}
// Otherwise, show the modal
this.showModal(id, content, onCloseCallback);
}
}
// Create and export a singleton instance

View File

@@ -5,11 +5,13 @@ import { modalManager } from './ModalManager.js';
class MoveManager {
constructor() {
this.currentFilePath = null;
this.bulkFilePaths = null;
this.modal = document.getElementById('moveModal');
this.loraRootSelect = document.getElementById('moveLoraRoot');
this.folderBrowser = document.getElementById('moveFolderBrowser');
this.newFolderInput = document.getElementById('moveNewFolder');
this.pathDisplay = document.getElementById('moveTargetPathDisplay');
this.modalTitle = document.getElementById('moveModalTitle');
this.initializeEventListeners();
}
@@ -43,7 +45,24 @@ class MoveManager {
}
async showMoveModal(filePath) {
this.currentFilePath = filePath;
// Reset state
this.currentFilePath = null;
this.bulkFilePaths = null;
// Handle bulk mode
if (filePath === 'bulk') {
const selectedPaths = Array.from(state.selectedLoras);
if (selectedPaths.length === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
this.bulkFilePaths = selectedPaths;
this.modalTitle.textContent = `Move ${selectedPaths.length} LoRAs`;
} else {
// Single file mode
this.currentFilePath = filePath;
this.modalTitle.textContent = "Move Model";
}
// 清除之前的选择
this.folderBrowser.querySelectorAll('.folder-item').forEach(item => {
@@ -105,36 +124,81 @@ class MoveManager {
targetPath = `${targetPath}/${newFolder}`;
}
try {
if (this.bulkFilePaths) {
// Bulk move mode
await this.moveBulkModels(this.bulkFilePaths, targetPath);
} else {
// Single move mode
await this.moveSingleModel(this.currentFilePath, targetPath);
}
modalManager.closeModal('moveModal');
await resetAndReload(true);
// If we were in bulk mode, exit it after successful move
if (this.bulkFilePaths && state.bulkMode) {
toggleBulkMode();
}
} catch (error) {
console.error('Error moving model(s):', error);
showToast('Failed to move model(s): ' + error.message, 'error');
}
}
async moveSingleModel(filePath, targetPath) {
// show toast if current path is same as target path
if (this.currentFilePath.substring(0, this.currentFilePath.lastIndexOf('/')) === targetPath) {
if (filePath.substring(0, filePath.lastIndexOf('/')) === targetPath) {
showToast('Model is already in the selected folder', 'info');
return;
}
try {
const response = await fetch('/api/move_model', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: this.currentFilePath,
target_path: targetPath
})
});
const response = await fetch('/api/move_model', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
target_path: targetPath
})
});
if (!response.ok) {
throw new Error('Failed to move model');
}
showToast('Model moved successfully', 'success');
modalManager.closeModal('moveModal');
await resetAndReload(true);
} catch (error) {
console.error('Error moving model:', error);
showToast('Failed to move model: ' + error.message, 'error');
if (!response.ok) {
throw new Error('Failed to move model');
}
showToast('Model moved successfully', 'success');
}
async moveBulkModels(filePaths, targetPath) {
// Filter out models already in the target path
const movedPaths = filePaths.filter(path => {
return path.substring(0, path.lastIndexOf('/')) !== targetPath;
});
if (movedPaths.length === 0) {
showToast('All selected models are already in the target folder', 'info');
return;
}
const response = await fetch('/api/move_models_bulk', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_paths: movedPaths,
target_path: targetPath
})
});
if (!response.ok) {
throw new Error('Failed to move models');
}
showToast(`Successfully moved ${movedPaths.length} models`, 'success');
}
}

View File

@@ -1,10 +1,56 @@
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() {
this.initialized = false;
this.isOpen = false;
// Add initialization to sync with modal state
this.initialize();
}
initialize() {
if (this.initialized) return;
// Add event listener to sync state when modal is closed via other means (like Escape key)
const settingsModal = document.getElementById('settingsModal');
if (settingsModal) {
const observer = new MutationObserver((mutations) => {
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();
}
}
});
});
observer.observe(settingsModal, { attributes: true });
}
this.initialized = true;
}
loadSettingsToUI() {
// Set frontend settings from state
const blurMatureContentCheckbox = document.getElementById('blurMatureContent');
if (blurMatureContentCheckbox) {
blurMatureContentCheckbox.checked = state.settings.blurMatureContent;
}
const showOnlySFWCheckbox = document.getElementById('showOnlySFW');
if (showOnlySFWCheckbox) {
// Sync with state (backend will set this via template)
state.settings.show_only_sfw = showOnlySFWCheckbox.checked;
}
// Backend settings are loaded from the template directly
}
toggleSettings() {
@@ -16,24 +62,29 @@ export class SettingsManager {
this.isOpen = !this.isOpen;
}
/*
showSettings() {
console.log('Opening settings modal...'); // Debug log
modalManager.showModal('settingsModal');
}
*/
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
})
});
@@ -43,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

View File

@@ -0,0 +1,242 @@
import { modalManager } from './ModalManager.js';
export class UpdateService {
constructor() {
this.updateCheckInterval = 24 * 60 * 60 * 1000; // 24 hours
this.currentVersion = "v0.0.0"; // Initialize with default values
this.latestVersion = "v0.0.0"; // Initialize with default values
this.updateInfo = null;
this.updateAvailable = false;
this.updateNotificationsEnabled = localStorage.getItem('show_update_notifications') !== 'false';
this.lastCheckTime = parseInt(localStorage.getItem('last_update_check') || '0');
}
initialize() {
// Initialize update preferences from localStorage
const showUpdates = localStorage.getItem('show_update_notifications');
this.updateNotificationsEnabled = showUpdates === null || showUpdates === 'true';
// Register event listener for update notification toggle
const updateCheckbox = document.getElementById('updateNotifications');
if (updateCheckbox) {
updateCheckbox.checked = this.updateNotificationsEnabled;
updateCheckbox.addEventListener('change', (e) => {
this.updateNotificationsEnabled = e.target.checked;
localStorage.setItem('show_update_notifications', e.target.checked);
this.updateBadgeVisibility();
});
}
// Perform update check if needed
this.checkForUpdates().then(() => {
// Ensure badges are updated after checking
this.updateBadgeVisibility();
});
// Set up event listener for update button
const updateToggle = document.getElementById('updateToggleBtn');
if (updateToggle) {
updateToggle.addEventListener('click', () => this.toggleUpdateModal());
}
// Immediately update modal content with current values (even if from default)
this.updateModalContent();
}
async checkForUpdates() {
// Check if we should perform an update check
const now = Date.now();
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();
}
return;
}
try {
// Call backend API to check for updates
const response = await fetch('/loras/api/check-updates');
const data = await response.json();
if (data.success) {
this.currentVersion = data.current_version || "v0.0.0";
this.latestVersion = data.latest_version || "v0.0.0";
this.updateInfo = data;
// Explicitly set update availability based on version comparison
this.updateAvailable = this.isNewerVersion(this.latestVersion, this.currentVersion);
// Update last check time
this.lastCheckTime = now;
localStorage.setItem('last_update_check', now.toString());
// Update UI
this.updateBadgeVisibility();
this.updateModalContent();
console.log("Update check complete:", {
currentVersion: this.currentVersion,
latestVersion: this.latestVersion,
updateAvailable: this.updateAvailable
});
}
} catch (error) {
console.error('Failed to check for updates:', error);
}
}
// 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');
const cornerBadge = document.querySelector('.corner-badge');
if (updateToggle) {
updateToggle.title = this.updateNotificationsEnabled && this.updateAvailable
? "Update Available"
: "Check Updates";
}
// Force updating badges visibility based on current state
const shouldShow = this.updateNotificationsEnabled && this.updateAvailable;
if (updateBadge) {
updateBadge.classList.toggle('hidden', !shouldShow);
console.log("Update badge visibility:", !shouldShow ? "hidden" : "visible");
}
if (cornerBadge) {
cornerBadge.classList.toggle('hidden', !shouldShow);
console.log("Corner badge visibility:", !shouldShow ? "hidden" : "visible");
}
}
updateModalContent() {
const modal = document.getElementById('updateModal');
if (!modal) return;
// Update title based on update availability
const headerTitle = modal.querySelector('.update-header h2');
if (headerTitle) {
headerTitle.textContent = this.updateAvailable ? "Update Available" : "Check for Updates";
}
// Always update version information, even if updateInfo is null
const currentVersionEl = modal.querySelector('.current-version .version-number');
const newVersionEl = modal.querySelector('.new-version .version-number');
if (currentVersionEl) currentVersionEl.textContent = this.currentVersion;
if (newVersionEl) newVersionEl.textContent = this.latestVersion;
// Update changelog content if available
if (this.updateInfo && this.updateInfo.changelog) {
const changelogContent = modal.querySelector('.changelog-content');
if (changelogContent) {
changelogContent.innerHTML = ''; // Clear existing content
// Create changelog item
const changelogItem = document.createElement('div');
changelogItem.className = 'changelog-item';
const versionHeader = document.createElement('h4');
versionHeader.textContent = `Version ${this.latestVersion}`;
changelogItem.appendChild(versionHeader);
// Create changelog list
const changelogList = document.createElement('ul');
if (this.updateInfo.changelog && this.updateInfo.changelog.length > 0) {
this.updateInfo.changelog.forEach(item => {
const listItem = document.createElement('li');
listItem.textContent = item;
changelogList.appendChild(listItem);
});
} else {
// If no changelog items available
const listItem = document.createElement('li');
listItem.textContent = "No detailed changelog available. Check GitHub for more information.";
changelogList.appendChild(listItem);
}
changelogItem.appendChild(changelogList);
changelogContent.appendChild(changelogItem);
}
}
// Update GitHub link to point to the specific release if available
const githubLink = modal.querySelector('.update-link');
if (githubLink && this.latestVersion) {
const versionTag = this.latestVersion.replace(/^v/, '');
githubLink.href = `https://github.com/willmiao/ComfyUI-Lora-Manager/releases/tag/v${versionTag}`;
}
}
toggleUpdateModal() {
const updateModal = modalManager.getModal('updateModal');
// If modal is already open, just close it
if (updateModal && updateModal.isOpen) {
modalManager.closeModal('updateModal');
return;
}
// Update the modal content immediately with current data
this.updateModalContent();
// Show the modal with current data
modalManager.showModal('updateModal');
// Then check for updates in the background
this.manualCheckForUpdates().then(() => {
// Update the modal content again after the check completes
this.updateModalContent();
});
}
showUpdateModal() {
this.toggleUpdateModal();
}
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();
}
}
// Create and export singleton instance
export const updateService = new UpdateService();

View File

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

View File

@@ -0,0 +1,100 @@
export const BASE_MODELS = {
// Stable Diffusion 1.x models
SD_1_4: "SD 1.4",
SD_1_5: "SD 1.5",
SD_1_5_LCM: "SD 1.5 LCM",
SD_1_5_HYPER: "SD 1.5 Hyper",
// Stable Diffusion 2.x models
SD_2_0: "SD 2.0",
SD_2_1: "SD 2.1",
// Stable Diffusion 3.x models
SD_3: "SD 3",
SD_3_5: "SD 3.5",
SD_3_5_MEDIUM: "SD 3.5 Medium",
SD_3_5_LARGE: "SD 3.5 Large",
SD_3_5_LARGE_TURBO: "SD 3.5 Large Turbo",
// SDXL models
SDXL: "SDXL 1.0",
SDXL_LIGHTNING: "SDXL Lightning",
SDXL_HYPER: "SDXL Hyper",
// Other models
FLUX_1_D: "Flux.1 D",
FLUX_1_S: "Flux.1 S",
AURAFLOW: "AuraFlow",
PIXART_A: "PixArt a",
PIXART_E: "PixArt E",
HUNYUAN_1: "Hunyuan 1",
LUMINA: "Lumina",
KOLORS: "Kolors",
NOOBAI: "NoobAI",
ILLUSTRIOUS: "Illustrious",
PONY: "Pony",
// Video models
SVD: "SVD",
WAN_VIDEO: "Wan Video",
HUNYUAN_VIDEO: "Hunyuan Video",
// Default
UNKNOWN: "Unknown"
};
// Base model display names and their corresponding class names (for styling)
export const BASE_MODEL_CLASSES = {
// Stable Diffusion 1.x models
[BASE_MODELS.SD_1_4]: "sd-1-4",
[BASE_MODELS.SD_1_5]: "sd-1-5",
[BASE_MODELS.SD_1_5_LCM]: "sd-1-5-lcm",
[BASE_MODELS.SD_1_5_HYPER]: "sd-1-5-hyper",
// Stable Diffusion 2.x models
[BASE_MODELS.SD_2_0]: "sd-2-0",
[BASE_MODELS.SD_2_1]: "sd-2-1",
// Stable Diffusion 3.x models
[BASE_MODELS.SD_3]: "sd-3",
[BASE_MODELS.SD_3_5]: "sd-3-5",
[BASE_MODELS.SD_3_5_MEDIUM]: "sd-3-5-medium",
[BASE_MODELS.SD_3_5_LARGE]: "sd-3-5-large",
[BASE_MODELS.SD_3_5_LARGE_TURBO]: "sd-3-5-large-turbo",
// SDXL models
[BASE_MODELS.SDXL]: "sdxl",
[BASE_MODELS.SDXL_LIGHTNING]: "sdxl-lightning",
[BASE_MODELS.SDXL_HYPER]: "sdxl-hyper",
// Video models
[BASE_MODELS.SVD]: "svd",
[BASE_MODELS.WAN_VIDEO]: "wan-video",
[BASE_MODELS.HUNYUAN_VIDEO]: "hunyuan-video",
// Other models
[BASE_MODELS.FLUX_1_D]: "flux-d",
[BASE_MODELS.FLUX_1_S]: "flux-s",
[BASE_MODELS.AURAFLOW]: "auraflow",
[BASE_MODELS.PIXART_A]: "pixart-a",
[BASE_MODELS.PIXART_E]: "pixart-e",
[BASE_MODELS.HUNYUAN_1]: "hunyuan-1",
[BASE_MODELS.LUMINA]: "lumina",
[BASE_MODELS.KOLORS]: "kolors",
[BASE_MODELS.NOOBAI]: "noobai",
[BASE_MODELS.ILLUSTRIOUS]: "il",
[BASE_MODELS.PONY]: "pony",
// Default
[BASE_MODELS.UNKNOWN]: "unknown"
};
export const NSFW_LEVELS = {
UNKNOWN: 0,
PG: 1,
PG13: 2,
R: 4,
X: 8,
XXX: 16,
BLOCKED: 32
};

View File

@@ -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);

View File

@@ -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';
}

0
supportModal.html Normal file
View File

View File

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

View File

@@ -28,12 +28,96 @@
<i class="fas fa-cloud-download-alt"></i> Download
</button>
</div>
<div class="control-group">
<button id="bulkOperationsBtn" onclick="bulkManager.toggleBulkMode()" title="Bulk Operations">
<i class="fas fa-th-large"></i> Bulk
</button>
</div>
<div class="search-container">
<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>
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
</button>
</div>
</div>
</div>
<!-- Add search options panel -->
<div id="searchOptionsPanel" class="search-options-panel hidden">
<div class="options-header">
<h3>Search Options</h3>
<button class="close-options-btn" id="closeSearchOptions">
<i class="fas fa-times"></i>
</button>
</div>
<div class="options-section">
<h4>Search In:</h4>
<div class="search-option-tags">
<div class="search-option-tag active" data-option="filename">Filename</div>
<div class="search-option-tag active" data-option="tags">Tags</div>
<div class="search-option-tag active" data-option="modelname">Model Name</div>
</div>
</div>
<div class="options-section">
<div class="search-option-switch">
<span>Include Subfolders</span>
<label class="switch">
<input type="checkbox" id="recursiveSearchToggle">
<span class="slider round"></span>
</label>
</div>
</div>
</div>
<!-- Add filter panel -->
<div id="filterPanel" class="filter-panel hidden">
<div class="filter-header">
<h3>Filter Models</h3>
<button class="close-filter-btn" onclick="filterManager.closeFilterPanel()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="filter-section">
<h4>Base Model</h4>
<div class="filter-tags" id="baseModelTags">
<!-- 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
</button>
</div>
</div>
<!-- Add bulk operations panel (initially hidden) -->
<div id="bulkOperationsPanel" class="bulk-operations-panel hidden">
<div class="bulk-operations-header">
<span id="selectedCount" class="selectable-count" title="Click to view selected items">
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
</span>
<div class="bulk-operations-actions">
<button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax">
<i class="fas fa-copy"></i> Copy All
</button>
<button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
<i class="fas fa-folder-open"></i> Move All
</button>
<button onclick="bulkManager.clearSelection()" title="Clear selection">
<i class="fas fa-times"></i> Clear
</button>
</div>
</div>

View File

@@ -86,8 +86,10 @@
<!-- Move Model Modal -->
<div id="moveModal" class="modal">
<div class="modal-content">
<button class="close" onclick="modalManager.closeModal('moveModal')">&times;</button>
<h2>Move Model</h2>
<div class="modal-header">
<h2 id="moveModalTitle">Move Model</h2>
<span class="close" onclick="modalManager.closeModal('moveModal')">&times;</span>
</div>
<div class="location-selection">
<div class="path-preview">
<label>Target Location Preview:</label>
@@ -145,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>
@@ -159,4 +195,119 @@
</a>
</div>
</div>
</div>
<!-- Support Modal -->
<div id="supportModal" class="modal">
<div class="modal-content support-modal">
<button class="close" onclick="modalManager.closeModal('supportModal')">&times;</button>
<div class="support-header">
<i class="fas fa-heart support-icon"></i>
<h2>Support the Project</h2>
</div>
<div class="support-content">
<p>If you find LoRA Manager useful, I'd really appreciate your support! 🙌</p>
<div class="support-section">
<h3><i class="fas fa-comment"></i> Provide Feedback</h3>
<p>Your feedback helps shape future updates! Share your thoughts:</p>
<div class="support-links">
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/issues/new" class="social-link" target="_blank">
<i class="fab fa-github"></i>
<span>Submit GitHub Issue</span>
</a>
<a href="https://discord.gg/KK3dn4Jx" class="social-link" target="_blank">
<i class="fab fa-discord"></i>
<span>Join Discord</span>
</a>
</div>
</div>
<div class="support-section">
<h3><i class="fas fa-rss"></i> Follow for Updates</h3>
<div class="support-links">
<a href="https://www.youtube.com/@pixelpaws-ai" class="social-link" target="_blank">
<i class="fab fa-youtube"></i>
<span>YouTube Channel</span>
</a>
<a href="https://civitai.com/user/PixelPawsAI" class="social-link civitai-link" target="_blank">
<svg class="civitai-icon" viewBox="0 0 225 225" width="20" height="20">
<g transform="translate(0,225) scale(0.1,-0.1)" fill="currentColor">
<path d="M950 1899 c-96 -55 -262 -150 -367 -210 -106 -61 -200 -117 -208
-125 -13 -13 -15 -76 -15 -443 0 -395 1 -429 18 -443 9 -9 116 -73 237 -143
121 -70 283 -163 359 -208 76 -45 146 -80 155 -80 9 1 183 98 386 215 l370
215 2 444 3 444 -376 215 c-206 118 -378 216 -382 217 -4 1 -86 -43 -182 -98z
m346 -481 l163 -93 1 -57 0 -58 -89 0 c-87 0 -91 1 -166 44 l-78 45 -51 -30
c-28 -17 -61 -35 -73 -41 -21 -10 -23 -18 -23 -99 l0 -87 71 -41 c39 -23 73
-41 76 -41 3 0 37 18 75 40 68 39 72 40 164 40 l94 0 0 -53 c0 -60 23 -41
-198 -168 l-133 -77 -92 52 c-51 29 -126 73 -167 97 l-75 45 0 193 0 192 164
95 c91 52 167 94 169 94 2 0 78 -42 168 -92z"/>
</g>
</svg>
<span>Civitai Profile</span>
</a>
</div>
</div>
<div class="support-section">
<h3><i class="fas fa-coffee"></i> Buy me a coffee</h3>
<p>If you'd like to support my work directly:</p>
<a href="https://ko-fi.com/pixelpawsai" class="kofi-button" target="_blank">
<i class="fas fa-mug-hot"></i>
<span>Support on Ko-fi</span>
</a>
</div>
<div class="support-footer">
<p>Thank you for using LoRA Manager! ❤️</p>
</div>
</div>
</div>
</div>
<!-- Update Modal -->
<div id="updateModal" class="modal">
<div class="modal-content update-modal">
<button class="close" onclick="modalManager.closeModal('updateModal')">&times;</button>
<div class="update-header">
<i class="fas fa-bell update-icon"></i>
<h2>Check for Updates</h2>
</div>
<div class="update-content">
<div class="update-info">
<div class="version-info">
<div class="current-version">
<span class="label">Current Version:</span>
<span class="version-number">v0.0.0</span>
</div>
<div class="new-version">
<span class="label">New Version:</span>
<span class="version-number">v0.0.0</span>
</div>
</div>
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager" target="_blank" class="update-link">
<i class="fas fa-external-link-alt"></i> View on GitHub
</a>
</div>
<div class="changelog-section">
<h3>Changelog</h3>
<div class="changelog-content">
<!-- Dynamic changelog content will be inserted here -->
<div class="changelog-item">
<h4>Checking for updates...</h4>
<p>Please wait while we check for the latest version.</p>
</div>
</div>
</div>
<div class="update-preferences">
<label class="toggle-switch">
<input type="checkbox" id="updateNotifications" checked>
<span class="toggle-slider"></span>
<span class="toggle-label">Show update notifications</span>
</label>
</div>
</div>
</div>
</div>

View File

@@ -47,12 +47,25 @@
</head>
<body>
<div class="corner-controls">
<div class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
<img src="/loras_static/images/theme-toggle-light.svg" alt="Theme" class="theme-icon light-icon">
<img src="/loras_static/images/theme-toggle-dark.svg" alt="Theme" class="theme-icon dark-icon">
<div class="corner-controls-toggle">
<i class="fas fa-ellipsis-v"></i>
<span class="update-badge corner-badge hidden"></span>
</div>
<div class="settings-toggle" onclick="settingsManager.toggleSettings()" title="Settings">
<i class="fas fa-cog"></i>
<div class="corner-controls-items">
<div class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
<img src="/loras_static/images/theme-toggle-light.svg" alt="Theme" class="theme-icon light-icon">
<img src="/loras_static/images/theme-toggle-dark.svg" alt="Theme" class="theme-icon dark-icon">
</div>
<div class="update-toggle" id="updateToggleBtn" title="Check Updates">
<i class="fas fa-bell"></i>
<span class="update-badge hidden"></span>
</div>
<div class="support-toggle" id="supportToggleBtn" title="Support">
<i class="fas fa-heart"></i>
</div>
<div class="settings-toggle" onclick="settingsManager.toggleSettings()" title="Settings">
<i class="fas fa-cog"></i>
</div>
</div>
</div>
@@ -75,9 +88,13 @@
<div class="card-grid" id="loraGrid">
<!-- Cards will be dynamically inserted here -->
</div>
<!-- Bulk operations panel will be inserted here by JavaScript -->
{% endif %}
</div>
<!-- Add after the container div -->
<div class="bulk-mode-overlay"></div>
<script type="module" src="/loras_static/js/main.js"></script>
{% if is_initializing %}
<script>

View File

@@ -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
View File

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

View File

@@ -142,13 +142,38 @@ export function addTagsWidget(node, name, opts, callback) {
});
},
getMinHeight: function() {
// 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);
},
});

View File

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

File diff suppressed because one or more lines are too long