Compare commits

..

33 Commits

Author SHA1 Message Date
Will Miao
82a2a6e669 chore: update version to 0.8.19 and add release notes for new features and enhancements 2025-06-28 08:04:16 +08:00
Will Miao
6376d60af5 Add temp debug console logging 2025-06-27 17:47:19 +08:00
Will Miao
b1e2e3831f fix: enhance model processing logic to skip already processed models only if their directories contain files. See #259 2025-06-27 13:09:19 +08:00
Will Miao
5de1c8aa82 feat: add node selector header with action mode indicator and instructions for improved user guidance 2025-06-27 12:39:20 +08:00
Will Miao
63dc5c2bdb fix: change overflow-y property to scroll for consistent vertical scrolling behavior 2025-06-27 11:44:43 +08:00
Will Miao
7f2d1670a0 feat: add startExpanded option to renderShowcaseContent for improved showcase interaction 2025-06-27 10:12:17 +08:00
Will Miao
53c8c337fc fix: remove unnecessary variable assignment for trigger words section in edit mode 2025-06-27 09:58:24 +08:00
Will Miao
5b4ec1b2a2 feat: implement disabled state for header search on statistics page with appropriate styling and functionality adjustments 2025-06-27 09:45:48 +08:00
Will Miao
64dd2ed141 feat: enhance node registration and management with support for multiple nodes and improved UI elements. Fixes #220 2025-06-26 23:00:55 +08:00
Will Miao
eb57e04e95 feat: implement thread-safe node registry and registration endpoints for Lora nodes 2025-06-26 18:31:14 +08:00
Will Miao
ae905c8630 fix: correct extension name format and update initialization method in usage stats 2025-06-26 16:57:26 +08:00
Will Miao
c157e794f0 feat: implement event delegation for checkpoint cards and enhance Civitai link handling 2025-06-26 11:42:43 +08:00
Will Miao
ed9bae6f6a feat: enhance recipe metadata handling with NSFW level updates and context menu actions. FIxes #247 2025-06-26 11:04:51 +08:00
Will Miao
9fe1ce19ad feat: add Patreon support section to the support modal with styling 2025-06-26 09:54:07 +08:00
Will Miao
6148236cbd fix: add missing patreon entry in FUNDING.yml 2025-06-26 08:23:12 +08:00
Will Miao
2471eb518a fix: correct key reference in process_trigger_words and update comment for widget values. Fixes #254 2025-06-25 20:57:12 +08:00
Will Miao
8931b41c76 feat: refactor API routes for renaming models and update related functions 2025-06-25 19:38:38 +08:00
Will Miao
7f523f167d fix: correct indentation for appending lora_entry in CivitaiApiMetadataParser. Fixes #253 2025-06-25 15:57:14 +08:00
Will Miao
446b6d6158 feat: sync saved example images path with backend on path update. Fixes #250 2025-06-25 15:34:25 +08:00
Will Miao
2ee057e19b feat: update metadata saving to ensure backup creation and support nested civitai structure 2025-06-25 11:50:10 +08:00
Will Miao
afc810f21f feat: prevent Ctrl+A behavior when search input is focused. See #251 2025-06-24 22:12:53 +08:00
pixelpaws
357052a903 Merge pull request #252 from willmiao/stats-page
Add statistics page with metrics, charts, and insights functionality
2025-06-24 21:37:06 +08:00
Will Miao
39d6d8d04a Add statistics page with metrics, charts, and insights functionality
- Implemented CSS styles for the statistics page layout and components.
- Developed JavaScript functionality for managing statistics, including data fetching, chart rendering, and tab navigation.
- Created HTML template for the statistics page, integrating dynamic content for metrics, charts, and insights.
- Added responsive design adjustments and loading states for better user experience.
2025-06-24 21:36:20 +08:00
Will Miao
888896c0c0 feat: add card info display setting with options for always visible or reveal on hover 2025-06-24 17:41:52 +08:00
Will Miao
ceee482ecc feat: refactor Lora handling by introducing chainCallback for improved node initialization and widget management. Fixes #176 2025-06-24 16:36:15 +08:00
Will Miao
d0ed1213d8 feat: enhance LoRA metadata handling by adding model IDs and updating recipe data structure. Fixes #246 2025-06-24 11:12:21 +08:00
Will Miao
f6ef428008 feat: update preview URL handling in RecipeRoutes and optimize recipe refresh logic in RecipeModal. Fixes #244 2025-06-23 15:29:22 +08:00
Will Miao
e726c4f442 feat: enhance metadata extraction for TSC samplers with vae_decode handling 2025-06-23 10:55:27 +08:00
Will Miao
402318e586 feat: enhance metadata processing and extraction for Efficient nodes with improved prompt handling and conditioning outputs. 2025-06-22 13:21:31 +08:00
Will Miao
b198cc2a6e feat: enhance metadata enrichment process to update file paths and preview URLs dynamically. See #113 2025-06-21 21:24:22 +08:00
Will Miao
c3dd4da11b feat: enhance theme toggle functionality with auto theme support and icon updates. Fix #243 2025-06-21 20:43:44 +08:00
Will Miao
ba2e42b06e feat: enhance LoraModal with notes hint and cleanup functionality on close 2025-06-21 20:04:57 +08:00
Will Miao
fa0902dc74 feat: add AdvancedCLIPTextEncode to NODE_EXTRACTORS for enhanced metadata extraction. See #234 2025-06-21 06:22:33 +08:00
75 changed files with 3935 additions and 1112 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,5 @@
# These are supported funding model platforms
patreon: PixelPawsAI
ko_fi: pixelpawsai
custom: ['paypal.me/pixelpawsai']

View File

@@ -22,6 +22,13 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes
### v0.8.19
* **Analytics Dashboard** - Added new Statistics page providing comprehensive visual analysis of model collection and usage patterns for better library insights
* **Target Node Selection** - Enhanced workflow integration with intelligent target choosing when sending LoRAs/recipes to workflows with multiple loader/stacker nodes; a visual selector now appears showing node color, type, ID, and title for precise targeting
* **Enhanced NSFW Controls** - Added support for setting NSFW levels on recipes with automatic content blurring based on user preferences
* **Customizable Card Display** - New display settings allowing users to choose whether card information and action buttons are always visible or only revealed on hover
* **Expanded Compatibility** - Added support for efficiency-nodes-comfyui in Save Recipe and Save Image nodes, plus fixed compatibility with ComfyUI_Custom_Nodes_AlekPet
### v0.8.18
* **Custom Example Images** - Added ability to import your own example images for LoRAs and checkpoints with automatic metadata extraction from embedded information
* **Enhanced Example Management** - New action buttons to set specific examples as previews or delete custom examples
@@ -101,13 +108,6 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
- 🚀 **High Performance**
- Fast model loading and browsing
- Smooth scrolling through large collections
- Real-time updates when files change
- 📂 **Advanced Organization**
- Quick search with fuzzy matching
- Folder-based categorization
- Move LoRAs between folders
- Sort by name or date
- 🌐 **Rich Model Integration**
- Direct download from CivitAI
@@ -271,6 +271,8 @@ If you find this project helpful, consider supporting its development:
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/pixelpawsai)
[![Patreon](https://img.shields.io/badge/Become%20a%20Patron-F96854.svg?style=for-the-badge&logo=patreon&logoColor=white)](https://patreon.com/PixelPawsAI)
WeChat: [Click to view QR code](https://raw.githubusercontent.com/willmiao/ComfyUI-Lora-Manager/main/static/images/wechat-qr.webp)
## 💬 Community

View File

@@ -10,6 +10,7 @@ from .routes.lora_routes import LoraRoutes
from .routes.api_routes import ApiRoutes
from .routes.recipe_routes import RecipeRoutes
from .routes.checkpoints_routes import CheckpointsRoutes
from .routes.stats_routes import StatsRoutes
from .routes.update_routes import UpdateRoutes
from .routes.misc_routes import MiscRoutes
from .routes.example_images_routes import ExampleImagesRoutes
@@ -112,10 +113,12 @@ class LoraManager:
# Setup feature routes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
stats_routes = StatsRoutes()
# Initialize routes
lora_routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
stats_routes.setup_routes(app) # Add statistics routes
ApiRoutes.setup_routes(app)
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)

View File

@@ -1,5 +1,6 @@
import json
import sys
from .constants import IMAGES
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
@@ -18,6 +19,10 @@ class MetadataProcessor:
- metadata: The workflow metadata
- downstream_id: Optional ID of a downstream node to help identify the specific primary sampler
"""
if downstream_id is None:
if IMAGES in metadata and "first_decode" in metadata[IMAGES]:
downstream_id = metadata[IMAGES]["first_decode"]["node_id"]
# If we have a downstream_id and execution_order, use it to narrow down potential samplers
if downstream_id and "execution_order" in metadata:
execution_order = metadata["execution_order"]
@@ -233,17 +238,25 @@ class MetadataProcessor:
pos_conditioning = metadata[PROMPTS][sampler_id].get("pos_conditioning")
neg_conditioning = metadata[PROMPTS][sampler_id].get("neg_conditioning")
# Try to match conditioning objects with those stored by CLIPTextEncodeExtractor
for prompt_node_id, prompt_data in metadata[PROMPTS].items():
if "conditioning" not in prompt_data:
continue
# For nodes with single conditioning output
if "conditioning" in prompt_data:
if pos_conditioning is not None and id(prompt_data["conditioning"]) == id(pos_conditioning):
result["prompt"] = prompt_data.get("text", "")
if pos_conditioning is not None and id(prompt_data["conditioning"]) == id(pos_conditioning):
result["prompt"] = prompt_data.get("text", "")
if neg_conditioning is not None and id(prompt_data["conditioning"]) == id(neg_conditioning):
result["negative_prompt"] = prompt_data.get("text", "")
if neg_conditioning is not None and id(prompt_data["conditioning"]) == id(neg_conditioning):
result["negative_prompt"] = prompt_data.get("text", "")
# For nodes with separate pos_conditioning and neg_conditioning outputs (like TSC_EfficientLoader)
if "positive_encoded" in prompt_data:
if pos_conditioning is not None and id(prompt_data["positive_encoded"]) == id(pos_conditioning):
result["prompt"] = prompt_data.get("positive_text", "")
if "negative_encoded" in prompt_data:
if neg_conditioning is not None and id(prompt_data["negative_encoded"]) == id(neg_conditioning):
result["negative_prompt"] = prompt_data.get("negative_text", "")
return result

View File

@@ -35,7 +35,70 @@ class CheckpointLoaderExtractor(NodeMetadataExtractor):
"type": "checkpoint",
"node_id": node_id
}
class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "ckpt_name" not in inputs:
return
model_name = inputs.get("ckpt_name")
if model_name:
metadata[MODELS][node_id] = {
"name": model_name,
"type": "checkpoint",
"node_id": node_id
}
# For loader node has lora_stack input, like Efficient Loader from Efficient Nodes
active_loras = []
# Process lora_stack if available
if "lora_stack" in inputs:
lora_stack = inputs.get("lora_stack", [])
for lora_path, model_strength, clip_strength in lora_stack:
# Extract lora name from path (following the format in lora_loader.py)
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": model_strength
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
# Extract positive and negative prompt text if available
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
# Store both positive and negative text
metadata[PROMPTS][node_id]["positive_text"] = positive_text
metadata[PROMPTS][node_id]["negative_text"] = negative_text
@staticmethod
def update(node_id, outputs, metadata):
# Handle conditioning outputs from TSC_EfficientLoader
# outputs is a list with [(model, positive_encoded, negative_encoded, {"samples":latent}, vae, clip, dependencies,)]
if outputs and isinstance(outputs, list) and len(outputs) > 0:
first_output = outputs[0]
if isinstance(first_output, tuple) and len(first_output) >= 3:
positive_conditioning = first_output[1]
negative_conditioning = first_output[2]
# Save both conditioning objects in metadata
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class CLIPTextEncodeExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -155,6 +218,81 @@ class KSamplerAdvancedExtractor(NodeMetadataExtractor):
"node_id": node_id
}
class TSCSamplerBaseExtractor(NodeMetadataExtractor):
"""Base extractor for handling TSC sampler node outputs"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Store vae_decode setting for later use in update
if inputs and "vae_decode" in inputs:
if SAMPLING not in metadata:
metadata[SAMPLING] = {}
if node_id not in metadata[SAMPLING]:
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
# Store the vae_decode setting
metadata[SAMPLING][node_id]["vae_decode"] = inputs["vae_decode"]
@staticmethod
def update(node_id, outputs, metadata):
# Check if vae_decode was set to "true"
should_save_image = True
if SAMPLING in metadata and node_id in metadata[SAMPLING]:
vae_decode = metadata[SAMPLING][node_id].get("vae_decode")
if vae_decode is not None:
should_save_image = (vae_decode == "true")
# Skip image saving if vae_decode isn't "true"
if not should_save_image:
return
# Ensure IMAGES category exists
if IMAGES not in metadata:
metadata[IMAGES] = {}
# Extract output_images from the TSC sampler format
# outputs = [{"ui": {"images": preview_images}, "result": result}]
# where result = (original_model, original_positive, original_negative, latent_list, optional_vae, output_images,)
if outputs and isinstance(outputs, list) and len(outputs) > 0:
# Get the first item in the list
output_item = outputs[0]
if isinstance(output_item, dict) and "result" in output_item:
result = output_item["result"]
if isinstance(result, tuple) and len(result) >= 6:
# The output_images is the last element in the result tuple
output_images = (result[5],)
# Save image data under node ID index to be captured by caching mechanism
metadata[IMAGES][node_id] = {
"node_id": node_id,
"image": output_images
}
# Only set first_decode if it hasn't been recorded yet
if "first_decode" not in metadata[IMAGES]:
metadata[IMAGES]["first_decode"] = metadata[IMAGES][node_id]
class TSCKSamplerExtractor(SamplerExtractor, TSCSamplerBaseExtractor):
"""Extractor for TSC_KSampler nodes"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Call parent extract methods
SamplerExtractor.extract(node_id, inputs, outputs, metadata)
TSCSamplerBaseExtractor.extract(node_id, inputs, outputs, metadata)
# Update method is inherited from TSCSamplerBaseExtractor
class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtractor):
"""Extractor for TSC_KSamplerAdvanced nodes"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Call parent extract methods
SamplerExtractor.extract(node_id, inputs, outputs, metadata)
TSCSamplerBaseExtractor.extract(node_id, inputs, outputs, metadata)
# Update method is inherited from TSCSamplerBaseExtractor
class LoraLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -432,17 +570,21 @@ class CFGGuiderExtractor(NodeMetadataExtractor):
metadata[SAMPLING][node_id]["parameters"]["cfg"] = cfg_value
# Registry of node-specific extractors
# Keys are node class names
NODE_EXTRACTORS = {
# Sampling
"KSampler": SamplerExtractor,
"KSamplerAdvanced": KSamplerAdvancedExtractor,
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor, # Updated to use dedicated extractor
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
# Sampling Selectors
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
# Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # eeasy comfyLoader
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
"LoraLoader": LoraLoaderExtractor,
@@ -451,6 +593,7 @@ NODE_EXTRACTORS = {
"CLIPTextEncode": CLIPTextEncodeExtractor,
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
# Latent
"EmptyLatentImage": ImageSizeExtractor,
# Flux

View File

@@ -50,15 +50,9 @@ class TriggerWordToggle:
def process_trigger_words(self, id, group_mode, default_active, **kwargs):
# Handle both old and new formats for trigger_words
trigger_words_data = self._get_toggle_data(kwargs, 'trigger_words')
trigger_words_data = self._get_toggle_data(kwargs, 'orinalMessage')
trigger_words = trigger_words_data if isinstance(trigger_words_data, str) else ""
# Send trigger words to frontend
# PromptServer.instance.send_sync("trigger_word_update", {
# "id": id,
# "message": trigger_words
# })
filtered_triggers = trigger_words
# Get toggle data with support for both formats

View File

@@ -79,6 +79,9 @@ class RecipeMetadataParser(ABC):
if 'model' in civitai_info and 'name' in civitai_info['model']:
lora_entry['name'] = civitai_info['model']['name']
lora_entry['id'] = civitai_info.get('id')
lora_entry['modelId'] = civitai_info.get('modelId')
# Update version if available
if 'name' in civitai_info:
lora_entry['version'] = civitai_info.get('name', '')

View File

@@ -184,8 +184,8 @@ class AutomaticMetadataParser(RecipeMetadataParser):
if resource.get("type") in ["lora", "lycoris", "hypernet"] and resource.get("modelVersionId"):
# Initialize lora entry
lora_entry = {
'id': str(resource.get("modelVersionId")),
'modelId': str(resource.get("modelId")) if resource.get("modelId") else None,
'id': resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""),
'type': resource.get("type", "lora"),

View File

@@ -141,8 +141,8 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
if resource.get("type") in ["lora", "lycoris"] or "type" not in resource:
# Initialize lora entry with the same structure as in automatic.py
lora_entry = {
'id': str(resource.get("modelVersionId")),
'modelId': str(resource.get("modelId")) if resource.get("modelId") else None,
'id': resource.get("modelVersionId", 0),
'modelId': resource.get("modelId", 0),
'name': resource.get("modelName", "Unknown LoRA"),
'version': resource.get("modelVersionName", ""),
'type': resource.get("type", "lora"),
@@ -235,7 +235,7 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
except Exception as e:
logger.error(f"Error fetching Civitai info for model ID {model_id}: {e}")
result["loras"].append(lora_entry)
result["loras"].append(lora_entry)
# If base model wasn't found earlier, use the most common one from LoRAs
if not result["base_model"] and base_model_counts:

View File

@@ -43,7 +43,7 @@ class RecipeFormatParser(RecipeMetadataParser):
for lora in recipe_metadata.get('loras', []):
# Convert recipe lora format to frontend format
lora_entry = {
'id': lora.get('modelVersionId', ''),
'id': int(lora.get('modelVersionId', 0)),
'name': lora.get('modelName', ''),
'version': lora.get('modelVersionName', ''),
'type': 'lora',

View File

@@ -65,7 +65,7 @@ class ApiRoutes:
app.router.add_get('/api/loras/top-tags', routes.get_top_tags) # Add new route for top tags
app.router.add_get('/api/loras/base-models', routes.get_base_models) # Add new route for base models
app.router.add_get('/api/lora-civitai-url', routes.get_lora_civitai_url) # Add new route for Civitai URL
app.router.add_post('/api/rename_lora', routes.rename_lora) # Add new route for renaming LoRA files
app.router.add_post('/api/loras/rename', routes.rename_lora) # Add new route for renaming LoRA files
app.router.add_get('/api/loras/scan', routes.scan_loras) # Add new route for scanning LoRA files
# Add the new trigger words route
@@ -792,9 +792,13 @@ class ApiRoutes:
metadata['modelDescription'] = description
metadata['tags'] = tags
metadata['creator'] = creator
# Ensure the civitai dict exists
if 'civitai' not in metadata:
metadata['civitai'] = {}
# Store creator in the civitai nested structure
metadata['civitai']['creator'] = creator
await MetadataManager.save_metadata(file_path, metadata)
await MetadataManager.save_metadata(file_path, metadata, True)
except Exception as e:
logger.error(f"Error saving model metadata: {e}")
@@ -869,128 +873,10 @@ class ApiRoutes:
async def rename_lora(self, request: web.Request) -> web.Response:
"""Handle renaming a LoRA file and its associated files"""
try:
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
if self.download_manager is None:
self.download_manager = await ServiceRegistry.get_download_manager()
data = await request.json()
file_path = data.get('file_path')
new_file_name = data.get('new_file_name')
if self.scanner is None:
self.scanner = await ServiceRegistry.get_lora_scanner()
if not file_path or not new_file_name:
return web.json_response({
'success': False,
'error': 'File path and new file name are required'
}, status=400)
# Validate the new file name (no path separators or invalid characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
if any(char in new_file_name for char in invalid_chars):
return web.json_response({
'success': False,
'error': 'Invalid characters in file name'
}, status=400)
# Get the directory and current file name
target_dir = os.path.dirname(file_path)
old_file_name = os.path.splitext(os.path.basename(file_path))[0]
# Check if the target file already exists
new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/')
if os.path.exists(new_file_path):
return web.json_response({
'success': False,
'error': 'A file with this name already exists'
}, status=400)
# Define the patterns for associated files
patterns = [
f"{old_file_name}.safetensors", # Required
f"{old_file_name}.metadata.json",
f"{old_file_name}.metadata.json.bak",
]
# Add all preview file extensions
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{old_file_name}{ext}")
# Find all matching files
existing_files = []
for pattern in patterns:
path = os.path.join(target_dir, pattern)
if os.path.exists(path):
existing_files.append((path, pattern))
# Get the hash from the main file to update hash index
hash_value = None
metadata = None
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
if os.path.exists(metadata_path):
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
hash_value = metadata.get('sha256')
# Rename all files
renamed_files = []
new_metadata_path = None
for old_path, pattern in existing_files:
# Get the file extension like .safetensors or .metadata.json
ext = ModelRouteUtils.get_multipart_ext(pattern)
# Create the new path
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
# Rename the file
os.rename(old_path, new_path)
renamed_files.append(new_path)
# Keep track of metadata path for later update
if ext == '.metadata.json':
new_metadata_path = new_path
# Update the metadata file with new file name and paths
if new_metadata_path and metadata:
# Update file_name, file_path and preview_url in metadata
metadata['file_name'] = new_file_name
metadata['file_path'] = new_file_path
# Update preview_url if it exists
if 'preview_url' in metadata and metadata['preview_url']:
old_preview = metadata['preview_url']
ext = ModelRouteUtils.get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
metadata['preview_url'] = new_preview
# Save updated metadata
await MetadataManager.save_metadata(new_file_path, metadata)
# Update the scanner cache
if metadata:
await self.scanner.update_single_model_cache(file_path, new_file_path, metadata)
# Update recipe files and cache if hash is available
if hash_value:
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name)
logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed LoRA")
return web.json_response({
'success': True,
'new_file_path': new_file_path,
'renamed_files': renamed_files,
'reload_required': False
})
except Exception as e:
logger.error(f"Error renaming LoRA: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
return await ModelRouteUtils.handle_rename_model(request, self.scanner)
async def get_trigger_words(self, request: web.Request) -> web.Response:
"""Get trigger words for specified LoRA models"""

View File

@@ -56,6 +56,7 @@ class CheckpointsRoutes:
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route
app.router.add_post('/api/checkpoints/rename', self.rename_checkpoint) # Add new rename endpoint
# Add new WebSocket endpoint for checkpoint progress
app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection)
@@ -836,3 +837,7 @@ class CheckpointsRoutes:
async def verify_duplicates(self, request: web.Request) -> web.Response:
"""Handle verification of duplicate checkpoint hashes"""
return await ModelRouteUtils.handle_verify_duplicates(request, self.scanner)
async def rename_checkpoint(self, request: web.Request) -> web.Response:
"""Handle renaming a checkpoint file and its associated files"""
return await ModelRouteUtils.handle_rename_model(request, self.scanner)

View File

@@ -1,31 +1,83 @@
import logging
import os
import sys
import threading
import asyncio
from server import PromptServer # type: ignore
from aiohttp import web
from ..services.settings_manager import settings
from ..utils.usage_stats import UsageStats
from ..utils.lora_metadata import extract_trained_words
from ..config import config
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS, NODE_TYPES, DEFAULT_NODE_COLOR
import re
logger = logging.getLogger(__name__)
# Download status tracking
download_task = None
is_downloading = False
download_progress = {
'total': 0,
'completed': 0,
'current_model': '',
'status': 'idle', # idle, running, paused, completed, error
'errors': [],
'last_error': None,
'start_time': None,
'end_time': None,
'processed_models': set(), # Track models that have been processed
'refreshed_models': set() # Track models that had metadata refreshed
}
standalone_mode = 'nodes' not in sys.modules
# Node registry for tracking active workflow nodes
class NodeRegistry:
"""Thread-safe registry for tracking Lora nodes in active workflows"""
def __init__(self):
self._lock = threading.RLock()
self._nodes = {} # node_id -> node_info
self._registry_updated = threading.Event()
def register_nodes(self, nodes):
"""Register multiple nodes at once, replacing existing registry"""
with self._lock:
# Clear existing registry
self._nodes.clear()
# Register all new nodes
for node in nodes:
node_id = node['node_id']
node_type = node.get('type', '')
# Convert node type name to integer
type_id = NODE_TYPES.get(node_type, 0) # 0 for unknown types
# Handle null bgcolor with default color
bgcolor = node.get('bgcolor')
if bgcolor is None:
bgcolor = DEFAULT_NODE_COLOR
self._nodes[node_id] = {
'id': node_id,
'bgcolor': bgcolor,
'title': node.get('title'),
'type': type_id,
'type_name': node_type
}
logger.debug(f"Registered {len(nodes)} nodes in registry")
# Signal that registry has been updated
self._registry_updated.set()
def get_registry(self):
"""Get current registry information"""
with self._lock:
return {
'nodes': dict(self._nodes), # Return a copy
'node_count': len(self._nodes)
}
def clear_registry(self):
"""Clear the entire registry"""
with self._lock:
self._nodes.clear()
logger.info("Node registry cleared")
def wait_for_update(self, timeout=1.0):
"""Wait for registry update with timeout"""
self._registry_updated.clear()
return self._registry_updated.wait(timeout)
# Global registry instance
node_registry = NodeRegistry()
class MiscRoutes:
"""Miscellaneous routes for various utility functions"""
@@ -50,6 +102,10 @@ class MiscRoutes:
# Add new route for getting model example files
app.router.add_get('/api/model-example-files', MiscRoutes.get_model_example_files)
# Node registry endpoints
app.router.add_post('/api/register-nodes', MiscRoutes.register_nodes)
app.router.add_get('/api/get-registry', MiscRoutes.get_registry)
@staticmethod
async def clear_cache(request):
@@ -83,10 +139,6 @@ class MiscRoutes:
'error': f"Failed to delete {filename}: {str(e)}"
}, status=500)
# If we want to completely remove the cache folder too (optional,
# but we'll keep the folder structure in place here)
# shutil.rmtree(cache_folder)
return web.json_response({
'success': True,
'message': f"Successfully cleared {len(deleted_files)} cache files",
@@ -403,3 +455,128 @@ class MiscRoutes:
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def register_nodes(request):
"""
Register multiple Lora nodes at once
Expects a JSON body with:
{
"nodes": [
{
"node_id": 123,
"bgcolor": "#535",
"title": "Lora Loader (LoraManager)"
},
...
]
}
"""
try:
data = await request.json()
# Validate required fields
nodes = data.get('nodes', [])
if not isinstance(nodes, list):
return web.json_response({
'success': False,
'error': 'nodes must be a list'
}, status=400)
# Validate each node
for i, node in enumerate(nodes):
if not isinstance(node, dict):
return web.json_response({
'success': False,
'error': f'Node {i} must be an object'
}, status=400)
node_id = node.get('node_id')
if node_id is None:
return web.json_response({
'success': False,
'error': f'Node {i} missing node_id parameter'
}, status=400)
# Validate node_id is an integer
try:
node['node_id'] = int(node_id)
except (ValueError, TypeError):
return web.json_response({
'success': False,
'error': f'Node {i} node_id must be an integer'
}, status=400)
# Register all nodes
node_registry.register_nodes(nodes)
return web.json_response({
'success': True,
'message': f'{len(nodes)} nodes registered successfully'
})
except Exception as e:
logger.error(f"Failed to register nodes: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def get_registry(request):
"""Get current node registry information by refreshing from frontend"""
try:
# Check if running in standalone mode
if standalone_mode:
logger.warning("Registry refresh not available in standalone mode")
return web.json_response({
'success': False,
'error': 'Standalone Mode Active',
'message': 'Cannot interact with ComfyUI in standalone mode.'
}, status=503)
# Send message to frontend to refresh registry
try:
PromptServer.instance.send_sync("lora_registry_refresh", {})
logger.debug("Sent registry refresh request to frontend")
except Exception as e:
logger.error(f"Failed to send registry refresh message: {e}")
return web.json_response({
'success': False,
'error': 'Communication Error',
'message': f'Failed to communicate with ComfyUI frontend: {str(e)}'
}, status=500)
# Wait for registry update with timeout
def wait_for_registry():
return node_registry.wait_for_update(timeout=1.0)
# Run the wait in a thread to avoid blocking the event loop
loop = asyncio.get_event_loop()
registry_updated = await loop.run_in_executor(None, wait_for_registry)
if not registry_updated:
logger.warning("Registry refresh timeout after 1 second")
return web.json_response({
'success': False,
'error': 'Timeout Error',
'message': 'Registry refresh timeout - ComfyUI frontend may not be responsive'
}, status=408)
# Get updated registry
registry_info = node_registry.get_registry()
return web.json_response({
'success': True,
'data': registry_info
})
except Exception as e:
logger.error(f"Failed to get registry: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': 'Internal Error',
'message': str(e)
}, status=500)

View File

@@ -648,7 +648,7 @@ class RecipeRoutes:
"file_name": lora.get("file_name", "") or os.path.splitext(os.path.basename(lora.get("localPath", "")))[0] if lora.get("localPath") else "",
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
"strength": float(lora.get("weight", 1.0)),
"modelVersionId": lora.get("id", ""),
"modelVersionId": lora.get("id", 0),
"modelName": lora.get("name", ""),
"modelVersionName": lora.get("version", ""),
"isDeleted": lora.get("isDeleted", False), # Preserve deletion status in saved recipe
@@ -996,7 +996,7 @@ class RecipeRoutes:
else:
latest_image = None
if not latest_image:
if latest_image is None:
return web.json_response({"error": "No recent images found to use for recipe. Try generating an image first."}, status=400)
# Convert the image data to bytes - handle tuple and tensor cases
@@ -1107,7 +1107,7 @@ class RecipeRoutes:
"file_name": lora_name,
"hash": lora_info.get("sha256", "").lower() if lora_info else "",
"strength": float(lora_strength),
"modelVersionId": lora_info.get("civitai", {}).get("id", "") if lora_info else "",
"modelVersionId": lora_info.get("civitai", {}).get("id", 0) if lora_info else 0,
"modelName": lora_info.get("civitai", {}).get("model", {}).get("name", "") if lora_info else lora_name,
"modelVersionName": lora_info.get("civitai", {}).get("name", "") if lora_info else "",
"isDeleted": False
@@ -1266,9 +1266,9 @@ class RecipeRoutes:
data = await request.json()
# Validate required fields
if 'title' not in data and 'tags' not in data and 'source_path' not in data:
if 'title' not in data and 'tags' not in data and 'source_path' not in data and 'preview_nsfw_level' not in data:
return web.json_response({
"error": "At least one field to update must be provided (title or tags or source_path)"
"error": "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)"
}, status=400)
# Use the recipe scanner's update method
@@ -1296,7 +1296,7 @@ class RecipeRoutes:
data = await request.json()
# Validate required fields
required_fields = ['recipe_id', 'lora_data', 'target_name']
required_fields = ['recipe_id', 'lora_index', 'target_name']
for field in required_fields:
if field not in data:
return web.json_response({
@@ -1304,7 +1304,7 @@ class RecipeRoutes:
}, status=400)
recipe_id = data['recipe_id']
lora_data = data['lora_data']
lora_index = int(data['lora_index'])
target_name = data['target_name']
# Get recipe scanner
@@ -1324,46 +1324,27 @@ class RecipeRoutes:
# Load recipe data
with open(recipe_path, 'r', encoding='utf-8') as f:
recipe_data = json.load(f)
# Find the deleted LoRA in the recipe
found = False
updated_lora = None
lora = recipe_data.get("loras", [])[lora_index] if lora_index < len(recipe_data.get('loras', [])) else None
if lora is None:
return web.json_response({"error": "LoRA index out of range in recipe"}, status=404)
# Update LoRA data
lora['isDeleted'] = False
lora['exclude'] = False
lora['file_name'] = target_name
# Identification can be by hash, modelVersionId, or modelName
for i, lora in enumerate(recipe_data.get('loras', [])):
match_found = False
# Try to match by available identifiers
if 'hash' in lora and 'hash' in lora_data and lora['hash'] == lora_data['hash']:
match_found = True
elif 'modelVersionId' in lora and 'modelVersionId' in lora_data and lora['modelVersionId'] == lora_data['modelVersionId']:
match_found = True
elif 'modelName' in lora and 'modelName' in lora_data and lora['modelName'] == lora_data['modelName']:
match_found = True
if match_found:
# Update LoRA data
lora['isDeleted'] = False
lora['file_name'] = target_name
# Update with information from the target LoRA
if 'sha256' in target_lora:
lora['hash'] = target_lora['sha256'].lower()
if target_lora.get("civitai"):
lora['modelName'] = target_lora['civitai']['model']['name']
lora['modelVersionName'] = target_lora['civitai']['name']
lora['modelVersionId'] = target_lora['civitai']['id']
# Keep original fields for identification
# Mark as found and store updated lora
found = True
updated_lora = dict(lora) # Make a copy for response
break
if not found:
return web.json_response({"error": "Could not find matching deleted LoRA in recipe"}, status=404)
# Update with information from the target LoRA
if 'sha256' in target_lora:
lora['hash'] = target_lora['sha256'].lower()
if target_lora.get("civitai"):
lora['modelName'] = target_lora['civitai']['model']['name']
lora['modelVersionName'] = target_lora['civitai']['name']
lora['modelVersionId'] = target_lora['civitai']['id']
updated_lora = dict(lora) # Make a copy for response
# Recalculate recipe fingerprint after updating LoRA
from ..utils.utils import calculate_recipe_fingerprint
recipe_data['fingerprint'] = calculate_recipe_fingerprint(recipe_data.get('loras', []))
@@ -1373,7 +1354,7 @@ class RecipeRoutes:
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
updated_lora['inLibrary'] = True
updated_lora['preview_url'] = target_lora['preview_url']
updated_lora['preview_url'] = config.get_preview_static_url(target_lora['preview_url'])
updated_lora['localPath'] = target_lora['file_path']
# Update in cache if it exists

438
py/routes/stats_routes.py Normal file
View File

@@ -0,0 +1,438 @@
import os
import json
import jinja2
from aiohttp import web
import logging
from datetime import datetime, timedelta
from collections import defaultdict, Counter
from typing import Dict, List, Any
from ..config import config
from ..services.settings_manager import settings
from ..services.service_registry import ServiceRegistry
from ..utils.usage_stats import UsageStats
logger = logging.getLogger(__name__)
class StatsRoutes:
"""Route handlers for Statistics page and API endpoints"""
def __init__(self):
self.lora_scanner = None
self.checkpoint_scanner = None
self.usage_stats = None
self.template_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(config.templates_path),
autoescape=True
)
async def init_services(self):
"""Initialize services from ServiceRegistry"""
self.lora_scanner = await ServiceRegistry.get_lora_scanner()
self.checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
self.usage_stats = UsageStats()
async def handle_stats_page(self, request: web.Request) -> web.Response:
"""Handle GET /statistics request"""
try:
# Ensure services are initialized
await self.init_services()
# Check if scanners are initializing
lora_initializing = (
self.lora_scanner._cache is None or
(hasattr(self.lora_scanner, 'is_initializing') and self.lora_scanner.is_initializing())
)
checkpoint_initializing = (
self.checkpoint_scanner._cache is None or
(hasattr(self.checkpoint_scanner, '_is_initializing') and self.checkpoint_scanner._is_initializing)
)
is_initializing = lora_initializing or checkpoint_initializing
template = self.template_env.get_template('statistics.html')
rendered = template.render(
is_initializing=is_initializing,
settings=settings,
request=request
)
return web.Response(
text=rendered,
content_type='text/html'
)
except Exception as e:
logger.error(f"Error handling statistics request: {e}", exc_info=True)
return web.Response(
text="Error loading statistics page",
status=500
)
async def get_collection_overview(self, request: web.Request) -> web.Response:
"""Get collection overview statistics"""
try:
await self.init_services()
# Get LoRA statistics
lora_cache = await self.lora_scanner.get_cached_data()
lora_count = len(lora_cache.raw_data)
lora_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data)
# Get Checkpoint statistics
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
checkpoint_count = len(checkpoint_cache.raw_data)
checkpoint_size = sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
return web.json_response({
'success': True,
'data': {
'total_models': lora_count + checkpoint_count,
'lora_count': lora_count,
'checkpoint_count': checkpoint_count,
'total_size': lora_size + checkpoint_size,
'lora_size': lora_size,
'checkpoint_size': checkpoint_size,
'total_generations': usage_data.get('total_executions', 0),
'unused_loras': self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {})),
'unused_checkpoints': self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
}
})
except Exception as e:
logger.error(f"Error getting collection overview: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_usage_analytics(self, request: web.Request) -> web.Response:
"""Get usage analytics data"""
try:
await self.init_services()
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Get model data for enrichment
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Create hash to model mapping
lora_map = {lora['sha256']: lora for lora in lora_cache.raw_data}
checkpoint_map = {cp['sha256']: cp for cp in checkpoint_cache.raw_data}
# Prepare top used models
top_loras = self._get_top_used_models(usage_data.get('loras', {}), lora_map, 10)
top_checkpoints = self._get_top_used_models(usage_data.get('checkpoints', {}), checkpoint_map, 10)
# Prepare usage timeline (last 30 days)
timeline = self._get_usage_timeline(usage_data, 30)
return web.json_response({
'success': True,
'data': {
'top_loras': top_loras,
'top_checkpoints': top_checkpoints,
'usage_timeline': timeline,
'total_executions': usage_data.get('total_executions', 0)
}
})
except Exception as e:
logger.error(f"Error getting usage analytics: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_base_model_distribution(self, request: web.Request) -> web.Response:
"""Get base model distribution statistics"""
try:
await self.init_services()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Count by base model
lora_base_models = Counter(lora.get('base_model', 'Unknown') for lora in lora_cache.raw_data)
checkpoint_base_models = Counter(cp.get('base_model', 'Unknown') for cp in checkpoint_cache.raw_data)
return web.json_response({
'success': True,
'data': {
'loras': dict(lora_base_models),
'checkpoints': dict(checkpoint_base_models)
}
})
except Exception as e:
logger.error(f"Error getting base model distribution: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_tag_analytics(self, request: web.Request) -> web.Response:
"""Get tag usage analytics"""
try:
await self.init_services()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Count tag frequencies
all_tags = []
for lora in lora_cache.raw_data:
all_tags.extend(lora.get('tags', []))
for cp in checkpoint_cache.raw_data:
all_tags.extend(cp.get('tags', []))
tag_counts = Counter(all_tags)
# Get top 50 tags
top_tags = [{'tag': tag, 'count': count} for tag, count in tag_counts.most_common(50)]
return web.json_response({
'success': True,
'data': {
'top_tags': top_tags,
'total_unique_tags': len(tag_counts)
}
})
except Exception as e:
logger.error(f"Error getting tag analytics: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_storage_analytics(self, request: web.Request) -> web.Response:
"""Get storage usage analytics"""
try:
await self.init_services()
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
# Create models with usage data
lora_storage = []
for lora in lora_cache.raw_data:
usage_count = 0
if lora['sha256'] in usage_data.get('loras', {}):
usage_count = usage_data['loras'][lora['sha256']].get('total', 0)
lora_storage.append({
'name': lora['model_name'],
'size': lora.get('size', 0),
'usage_count': usage_count,
'folder': lora.get('folder', ''),
'base_model': lora.get('base_model', 'Unknown')
})
checkpoint_storage = []
for cp in checkpoint_cache.raw_data:
usage_count = 0
if cp['sha256'] in usage_data.get('checkpoints', {}):
usage_count = usage_data['checkpoints'][cp['sha256']].get('total', 0)
checkpoint_storage.append({
'name': cp['model_name'],
'size': cp.get('size', 0),
'usage_count': usage_count,
'folder': cp.get('folder', ''),
'base_model': cp.get('base_model', 'Unknown')
})
# Sort by size
lora_storage.sort(key=lambda x: x['size'], reverse=True)
checkpoint_storage.sort(key=lambda x: x['size'], reverse=True)
return web.json_response({
'success': True,
'data': {
'loras': lora_storage[:20], # Top 20 by size
'checkpoints': checkpoint_storage[:20]
}
})
except Exception as e:
logger.error(f"Error getting storage analytics: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
async def get_insights(self, request: web.Request) -> web.Response:
"""Get smart insights about the collection"""
try:
await self.init_services()
# Get usage statistics
usage_data = await self.usage_stats.get_stats()
# Get model data
lora_cache = await self.lora_scanner.get_cached_data()
checkpoint_cache = await self.checkpoint_scanner.get_cached_data()
insights = []
# Calculate unused models
unused_loras = self._count_unused_models(lora_cache.raw_data, usage_data.get('loras', {}))
unused_checkpoints = self._count_unused_models(checkpoint_cache.raw_data, usage_data.get('checkpoints', {}))
total_loras = len(lora_cache.raw_data)
total_checkpoints = len(checkpoint_cache.raw_data)
if total_loras > 0:
unused_lora_percent = (unused_loras / total_loras) * 100
if unused_lora_percent > 50:
insights.append({
'type': 'warning',
'title': 'High Number of Unused LoRAs',
'description': f'{unused_lora_percent:.1f}% of your LoRAs ({unused_loras}/{total_loras}) have never been used.',
'suggestion': 'Consider organizing or archiving unused models to free up storage space.'
})
if total_checkpoints > 0:
unused_checkpoint_percent = (unused_checkpoints / total_checkpoints) * 100
if unused_checkpoint_percent > 30:
insights.append({
'type': 'warning',
'title': 'Unused Checkpoints Detected',
'description': f'{unused_checkpoint_percent:.1f}% of your checkpoints ({unused_checkpoints}/{total_checkpoints}) have never been used.',
'suggestion': 'Review and consider removing checkpoints you no longer need.'
})
# Storage insights
total_size = sum(lora.get('size', 0) for lora in lora_cache.raw_data) + \
sum(cp.get('size', 0) for cp in checkpoint_cache.raw_data)
if total_size > 100 * 1024 * 1024 * 1024: # 100GB
insights.append({
'type': 'info',
'title': 'Large Collection Detected',
'description': f'Your model collection is using {self._format_size(total_size)} of storage.',
'suggestion': 'Consider using external storage or cloud solutions for better organization.'
})
# Recent activity insight
if usage_data.get('total_executions', 0) > 100:
insights.append({
'type': 'success',
'title': 'Active User',
'description': f'You\'ve completed {usage_data["total_executions"]} generations so far!',
'suggestion': 'Keep exploring and creating amazing content with your models.'
})
return web.json_response({
'success': True,
'data': {
'insights': insights
}
})
except Exception as e:
logger.error(f"Error getting insights: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
def _count_unused_models(self, models: List[Dict], usage_data: Dict) -> int:
"""Count models that have never been used"""
used_hashes = set(usage_data.keys())
unused_count = 0
for model in models:
if model.get('sha256') not in used_hashes:
unused_count += 1
return unused_count
def _get_top_used_models(self, usage_data: Dict, model_map: Dict, limit: int) -> List[Dict]:
"""Get top used models with their metadata"""
sorted_usage = sorted(usage_data.items(), key=lambda x: x[1].get('total', 0), reverse=True)
top_models = []
for sha256, usage_info in sorted_usage[:limit]:
if sha256 in model_map:
model = model_map[sha256]
top_models.append({
'name': model['model_name'],
'usage_count': usage_info.get('total', 0),
'base_model': model.get('base_model', 'Unknown'),
'preview_url': config.get_preview_static_url(model.get('preview_url', '')),
'folder': model.get('folder', '')
})
return top_models
def _get_usage_timeline(self, usage_data: Dict, days: int) -> List[Dict]:
"""Get usage timeline for the past N days"""
timeline = []
today = datetime.now()
for i in range(days):
date = today - timedelta(days=i)
date_str = date.strftime('%Y-%m-%d')
lora_usage = 0
checkpoint_usage = 0
# Count usage for this date
for model_usage in usage_data.get('loras', {}).values():
if isinstance(model_usage, dict) and 'history' in model_usage:
lora_usage += model_usage['history'].get(date_str, 0)
for model_usage in usage_data.get('checkpoints', {}).values():
if isinstance(model_usage, dict) and 'history' in model_usage:
checkpoint_usage += model_usage['history'].get(date_str, 0)
timeline.append({
'date': date_str,
'lora_usage': lora_usage,
'checkpoint_usage': checkpoint_usage,
'total_usage': lora_usage + checkpoint_usage
})
return list(reversed(timeline)) # Oldest to newest
def _format_size(self, size_bytes: int) -> str:
"""Format file size in human readable format"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.1f} PB"
def setup_routes(self, app: web.Application):
"""Register routes with the application"""
# Add an app startup handler to initialize services
app.on_startup.append(self._on_startup)
# Register page route
app.router.add_get('/statistics', self.handle_stats_page)
# Register API routes
app.router.add_get('/api/stats/collection-overview', self.get_collection_overview)
app.router.add_get('/api/stats/usage-analytics', self.get_usage_analytics)
app.router.add_get('/api/stats/base-model-distribution', self.get_base_model_distribution)
app.router.add_get('/api/stats/tag-analytics', self.get_tag_analytics)
app.router.add_get('/api/stats/storage-analytics', self.get_storage_analytics)
app.router.add_get('/api/stats/insights', self.get_insights)
async def _on_startup(self, app):
"""Initialize services when the app starts"""
await self.init_services()

View File

@@ -255,7 +255,7 @@ class DownloadManager:
metadata.update_file_info(save_path)
# 5. Final metadata update
await MetadataManager.save_metadata(save_path, metadata)
await MetadataManager.save_metadata(save_path, metadata, True)
# 6. Update cache based on model type
if model_type == "checkpoint":

View File

@@ -788,7 +788,7 @@ class ModelScanner:
metadata = self.model_class.from_civitai_info(version_info, file_info, file_path)
metadata.preview_url = find_preview_file(file_name, os.path.dirname(file_path))
await MetadataManager.save_metadata(file_path, metadata)
await MetadataManager.save_metadata(file_path, metadata, True)
logger.debug(f"Created metadata from .civitai.info for {file_path}")
except Exception as e:
logger.error(f"Error creating metadata from .civitai.info for {file_path}: {e}")
@@ -815,7 +815,7 @@ class ModelScanner:
metadata.modelDescription = version_info['model']['description']
# Save the updated metadata
await MetadataManager.save_metadata(file_path, metadata)
await MetadataManager.save_metadata(file_path, metadata, True)
logger.debug(f"Updated metadata with civitai info for {file_path}")
except Exception as e:
logger.error(f"Error restoring civitai data from .civitai.info for {file_path}: {e}")
@@ -884,7 +884,7 @@ class ModelScanner:
model_data['civitai']['creator'] = model_metadata['creator']
await MetadataManager.save_metadata(file_path, model_data)
await MetadataManager.save_metadata(file_path, model_data, True)
except Exception as e:
logger.error(f"Failed to update metadata from Civitai for {file_path}: {e}")

View File

@@ -7,6 +7,15 @@ NSFW_LEVELS = {
"Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account?
}
# Node type constants
NODE_TYPES = {
"Lora Loader (LoraManager)": 1,
"Lora Stacker (LoraManager)": 2
}
# Default ComfyUI node color when bgcolor is null
DEFAULT_NODE_COLOR = "#353535"
# preview extensions
PREVIEW_EXTENSIONS = [
'.webp',

View File

@@ -295,10 +295,15 @@ class DownloadManager:
# Update current model info
download_progress['current_model'] = f"{model_name} ({model_hash[:8]})"
# Skip if already processed
# Skip if already processed AND directory exists with files
if model_hash in download_progress['processed_models']:
logger.debug(f"Skipping already processed model: {model_name}")
return False
model_dir = os.path.join(output_dir, model_hash)
has_files = os.path.exists(model_dir) and any(os.listdir(model_dir))
if has_files:
logger.debug(f"Skipping already processed model: {model_name}")
return False
else:
logger.info(f"Model {model_name} marked as processed but folder empty or missing, reprocessing")
# Create model directory
model_dir = os.path.join(output_dir, model_hash)

View File

@@ -147,7 +147,7 @@ class ExifUtils:
"file_name": lora.get("file_name", ""),
"hash": lora.get("hash", "").lower() if lora.get("hash") else "",
"strength": float(lora.get("strength", 1.0)),
"modelVersionId": lora.get("modelVersionId", ""),
"modelVersionId": lora.get("modelVersionId", 0),
"modelName": lora.get("modelName", ""),
"modelVersionName": lora.get("modelVersionName", ""),
}

View File

@@ -91,14 +91,14 @@ class MetadataManager:
return None
@staticmethod
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict], create_backup: bool = True) -> bool:
async def save_metadata(path: str, metadata: Union[BaseModelMetadata, Dict], create_backup: bool = False) -> bool:
"""
Save metadata with atomic write operations and backup creation.
Args:
path: Path to the model file or directly to the metadata file
metadata: Metadata to save (either BaseModelMetadata object or dict)
create_backup: Whether to create a backup of existing file
create_backup: Whether to create a new backup of existing file if a backup doesn't already exist
Returns:
bool: Success or failure
@@ -114,10 +114,13 @@ class MetadataManager:
backup_path = f"{metadata_path}.bak"
try:
# Create backup if requested and file exists
if create_backup and os.path.exists(metadata_path):
# Create backup if file exists and either:
# 1. create_backup is True, OR
# 2. backup file doesn't already exist
if os.path.exists(metadata_path) and (create_backup or not os.path.exists(backup_path)):
try:
shutil.copy2(metadata_path, backup_path)
logger.debug(f"Created metadata backup at: {backup_path}")
except Exception as e:
logger.warning(f"Failed to create metadata backup: {str(e)}")
@@ -261,15 +264,29 @@ class MetadataManager:
metadata: Metadata object to update
file_path: Current file path for the model
"""
need_update = False
# Check if file path is different from what's in metadata
if normalize_path(file_path) != metadata.file_path:
metadata.file_path = normalize_path(file_path)
need_update = True
# Check if preview exists at the current location
preview_url = metadata.preview_url
if preview_url and not os.path.exists(preview_url):
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = find_preview_file(base_name, dir_path)
if new_preview_url:
metadata.preview_url = normalize_path(new_preview_url)
if preview_url:
# Get directory parts of both paths
file_dir = os.path.dirname(file_path)
preview_dir = os.path.dirname(preview_url)
# Update preview if it doesn't exist OR if model and preview are in different directories
if not os.path.exists(preview_url) or file_dir != preview_dir:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = find_preview_file(base_name, dir_path)
if new_preview_url:
metadata.preview_url = normalize_path(new_preview_url)
need_update = True
# If path attributes were changed, save the metadata back to disk
if need_update:
await MetadataManager.save_metadata(file_path, metadata, create_backup=False)

View File

@@ -154,7 +154,7 @@ class ModelRouteUtils:
local_metadata['preview_nsfw_level'] = first_preview.get('nsfwLevel', 0)
# Save updated metadata
await MetadataManager.save_metadata(metadata_path, local_metadata)
await MetadataManager.save_metadata(metadata_path, local_metadata, True)
@staticmethod
async def fetch_and_update_model(
@@ -836,3 +836,132 @@ class ModelRouteUtils:
'success': False,
'error': str(e)
}, status=500)
@staticmethod
async def handle_rename_model(request: web.Request, scanner) -> web.Response:
"""Handle renaming a model file and its associated files
Args:
request: The aiohttp request
scanner: The model scanner instance
Returns:
web.Response: The HTTP response
"""
try:
data = await request.json()
file_path = data.get('file_path')
new_file_name = data.get('new_file_name')
if not file_path or not new_file_name:
return web.json_response({
'success': False,
'error': 'File path and new file name are required'
}, status=400)
# Validate the new file name (no path separators or invalid characters)
invalid_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
if any(char in new_file_name for char in invalid_chars):
return web.json_response({
'success': False,
'error': 'Invalid characters in file name'
}, status=400)
# Get the directory and current file name
target_dir = os.path.dirname(file_path)
old_file_name = os.path.splitext(os.path.basename(file_path))[0]
# Check if the target file already exists
new_file_path = os.path.join(target_dir, f"{new_file_name}.safetensors").replace(os.sep, '/')
if os.path.exists(new_file_path):
return web.json_response({
'success': False,
'error': 'A file with this name already exists'
}, status=400)
# Define the patterns for associated files
patterns = [
f"{old_file_name}.safetensors", # Required
f"{old_file_name}.metadata.json",
f"{old_file_name}.metadata.json.bak",
]
# Add all preview file extensions
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{old_file_name}{ext}")
# Find all matching files
existing_files = []
for pattern in patterns:
path = os.path.join(target_dir, pattern)
if os.path.exists(path):
existing_files.append((path, pattern))
# Get the hash from the main file to update hash index
hash_value = None
metadata = None
metadata_path = os.path.join(target_dir, f"{old_file_name}.metadata.json")
if os.path.exists(metadata_path):
metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
hash_value = metadata.get('sha256')
# Rename all files
renamed_files = []
new_metadata_path = None
for old_path, pattern in existing_files:
# Get the file extension like .safetensors or .metadata.json
ext = ModelRouteUtils.get_multipart_ext(pattern)
# Create the new path
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
# Rename the file
os.rename(old_path, new_path)
renamed_files.append(new_path)
# Keep track of metadata path for later update
if ext == '.metadata.json':
new_metadata_path = new_path
# Update the metadata file with new file name and paths
if new_metadata_path and metadata:
# Update file_name, file_path and preview_url in metadata
metadata['file_name'] = new_file_name
metadata['file_path'] = new_file_path
# Update preview_url if it exists
if 'preview_url' in metadata and metadata['preview_url']:
old_preview = metadata['preview_url']
ext = ModelRouteUtils.get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(os.sep, '/')
metadata['preview_url'] = new_preview
# Save updated metadata
await MetadataManager.save_metadata(new_file_path, metadata)
# Update the scanner cache
if metadata:
await scanner.update_single_model_cache(file_path, new_file_path, metadata)
# Update recipe files and cache if hash is available and recipe_scanner exists
if hash_value and hasattr(scanner, 'update_lora_filename_by_hash'):
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
if recipe_scanner:
recipes_updated, cache_updated = await recipe_scanner.update_lora_filename_by_hash(hash_value, new_file_name)
logger.info(f"Updated {recipes_updated} recipe files and {cache_updated} cache entries for renamed model")
return web.json_response({
'success': True,
'new_file_path': new_file_path,
'renamed_files': renamed_files,
'reload_required': False
})
except Exception as e:
logger.error(f"Error renaming model: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -142,7 +142,7 @@ def calculate_recipe_fingerprint(loras):
# Get the hash - use modelVersionId as fallback if hash is empty
hash_value = lora.get("hash", "").lower()
if not hash_value and lora.get("isDeleted", False) and lora.get("modelVersionId"):
hash_value = lora.get("modelVersionId")
hash_value = str(lora.get("modelVersionId"))
# Skip entries without a valid hash
if not hash_value:

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

View File

@@ -301,13 +301,16 @@ class StandaloneLoraManager(LoraManager):
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
from py.routes.example_images_routes import ExampleImagesRoutes
from py.routes.stats_routes import StatsRoutes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
stats_routes = StatsRoutes()
# Initialize routes
lora_routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
stats_routes.setup_routes(app)
ApiRoutes.setup_routes(app)
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)

View File

@@ -29,6 +29,7 @@ html, body {
:root {
--bg-color: #ffffff;
--text-color: #333333;
--text-muted: #6c757d;
--card-bg: #ffffff;
--border-color: #e0e0e0;
@@ -84,6 +85,7 @@ html[data-theme="light"] {
[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--text-muted: #a0a0a0;
--card-bg: #2d2d2d;
--border-color: #404040;

View File

@@ -252,6 +252,18 @@
z-index: 3;
}
/* New styles for hover reveal mode */
.hover-reveal .card-header,
.hover-reveal .card-footer {
opacity: 0;
transition: opacity 0.2s ease;
}
.hover-reveal .lora-card:hover .card-header,
.hover-reveal .lora-card:hover .card-footer {
opacity: 1;
}
.card-footer {
position: absolute;
bottom: 0;
@@ -431,30 +443,6 @@
user-select: none;
}
/* Recipe specific elements - migrated from recipe-card.css */
.recipe-indicator {
position: absolute;
top: 6px;
left: 8px;
width: 24px;
height: 24px;
background: var(--lora-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
z-index: 2;
}
.base-model-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin-left: 32px; /* For accommodating the recipe indicator */
}
.lora-count {
display: flex;
align-items: center;

View File

@@ -79,6 +79,50 @@
flex: 1;
max-width: 400px;
margin: 0 1rem;
transition: opacity 0.2s ease;
}
/* Disabled state for header search */
.header-search.disabled {
opacity: 0.5;
pointer-events: none;
}
.header-search.disabled input {
background-color: var(--input-disabled-bg, #f5f5f5);
color: var(--text-muted);
cursor: not-allowed;
}
.header-search.disabled button {
background-color: var(--button-disabled-bg, #e0e0e0);
color: var(--text-muted);
cursor: not-allowed;
}
.header-search.disabled .search-icon {
color: var(--text-muted);
}
/* Dark theme specific styles for disabled header search */
[data-theme="dark"] .header-search.disabled input {
background-color: #3a3a3a;
color: #888888;
border-color: #555555;
}
[data-theme="dark"] .header-search.disabled button {
background-color: #3a3a3a;
color: #888888;
border-color: #555555;
}
[data-theme="dark"] .header-search.disabled .search-icon {
color: #888888;
}
[data-theme="dark"] .header-search.disabled .fas {
color: #888888;
}
/* Header controls (formerly corner controls) */
@@ -115,7 +159,8 @@
}
.theme-toggle .light-icon,
.theme-toggle .dark-icon {
.theme-toggle .dark-icon,
.theme-toggle .auto-icon {
position: absolute;
top: 50%;
left: 50%;
@@ -124,15 +169,38 @@
transition: opacity 0.3s ease;
}
/* Default state shows dark icon */
.theme-toggle .dark-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .light-icon {
/* Light theme shows light icon */
.theme-toggle.theme-light .light-icon {
opacity: 1;
}
[data-theme="light"] .theme-toggle .dark-icon {
.theme-toggle.theme-light .dark-icon,
.theme-toggle.theme-light .auto-icon {
opacity: 0;
}
/* Dark theme shows dark icon */
.theme-toggle.theme-dark .dark-icon {
opacity: 1;
}
.theme-toggle.theme-dark .light-icon,
.theme-toggle.theme-dark .auto-icon {
opacity: 0;
}
/* Auto theme shows auto icon */
.theme-toggle.theme-auto .auto-icon {
opacity: 1;
}
.theme-toggle.theme-auto .light-icon,
.theme-toggle.theme-auto .dark-icon {
opacity: 0;
}

View File

@@ -107,25 +107,13 @@
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;
.notes-hint {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-left: 5px;
cursor: help;
position: relative; /* Add positioning context */
}
@media (max-width: 640px) {

View File

@@ -116,4 +116,105 @@
background: var(--lora-accent);
color: white;
border-color: var(--lora-accent);
}
/* Node Selector */
.node-selector {
position: fixed;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 4px 0;
min-width: 200px;
max-width: 350px;
max-height: 400px;
overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1000;
display: none;
backdrop-filter: blur(10px);
}
.node-item {
padding: 10px 15px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
color: var(--text-color);
background: var(--lora-surface);
transition: background-color 0.2s;
border-bottom: 1px solid var(--border-color);
}
.node-item:last-child {
border-bottom: none;
}
.node-item:hover {
background-color: var(--lora-accent);
color: var(--lora-text);
}
.node-icon-indicator {
width: 24px;
height: 24px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.node-icon-indicator i {
color: white;
font-size: 12px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.node-icon-indicator.all-nodes {
background: linear-gradient(45deg, #4a90e2, #357abd);
}
/* Remove old node-color-indicator styles */
.node-color-indicator {
display: none;
}
.send-all-item {
border-top: 1px solid var(--border-color);
font-weight: 500;
background: var(--card-bg);
}
.send-all-item:hover {
background-color: var(--lora-accent);
color: var(--lora-text);
}
.send-all-item i {
width: 16px;
text-align: center;
}
/* Node Selector Header */
.node-selector-header {
padding: 10px 15px;
border-bottom: 1px solid var(--border-color);
background: var(--card-bg);
display: flex;
flex-direction: column;
gap: 4px;
}
.selector-action-type {
font-weight: 600;
font-size: 14px;
color: var(--lora-accent);
}
.selector-instruction {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}

View File

@@ -0,0 +1,520 @@
/* Statistics Page Styles */
.metrics-panel {
margin-bottom: var(--space-3);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.metric-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
padding: var(--space-2);
text-align: center;
transition: all 0.3s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.metric-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.metric-card .metric-icon {
font-size: 2rem;
color: var(--lora-accent);
margin-bottom: var(--space-1);
}
.metric-card .metric-value {
font-size: 1.8rem;
font-weight: bold;
color: var(--text-color);
margin-bottom: 4px;
}
.metric-card .metric-label {
font-size: 0.9rem;
color: oklch(var(--text-color) / 0.7);
}
.metric-card .metric-change {
font-size: 0.8rem;
margin-top: 4px;
}
.metric-change.positive {
color: var(--lora-success);
}
.metric-change.negative {
color: var(--lora-error);
}
/* Dashboard Content */
.dashboard-content {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
overflow: hidden;
}
.dashboard-tabs {
display: flex;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
overflow-x: auto;
}
.tab-button {
background: none;
border: none;
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: all 0.3s ease;
color: var(--text-color);
border-bottom: 3px solid transparent;
white-space: nowrap;
font-size: 0.9rem;
display: flex;
align-items: center;
gap: 8px;
}
.tab-button:hover {
background: oklch(var(--lora-accent) / 0.1);
}
.tab-button.active {
color: var(--lora-accent);
border-bottom-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.tab-content {
padding: var(--space-3);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
/* Panel Grid Layout */
.panel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: var(--space-3);
align-items: start;
}
.panel-grid .full-width {
grid-column: 1 / -1;
}
/* Chart Containers */
.chart-container {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
min-height: 300px;
}
.chart-container h3 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.chart-container h3 i {
color: var(--lora-accent);
}
.chart-wrapper {
position: relative;
height: 250px;
}
.chart-wrapper canvas {
max-height: 100%;
}
/* List Containers */
.list-container {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
min-height: 300px;
}
.list-container h3 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.list-container h3 i {
color: var(--lora-accent);
}
.model-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.model-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
}
.model-item:hover {
border-color: var(--lora-accent);
transform: translateX(2px);
}
.model-item .model-preview {
width: 40px;
height: 40px;
border-radius: var(--border-radius-xs);
margin-right: 12px;
object-fit: cover;
background: var(--border-color);
}
.model-item .model-info {
flex: 1;
min-width: 0;
}
.model-item .model-name {
font-weight: 600;
text-shadow: none;
color: var(--text-color);
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-item .model-meta {
font-size: 0.8rem;
color: oklch(var(--text-color) / 0.7);
margin-top: 2px;
}
.model-item .model-usage {
text-align: right;
color: var(--lora-accent);
font-weight: 600;
font-size: 0.9rem;
}
/* Tag Cloud */
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: var(--space-2) 0;
max-height: 250px;
overflow-y: auto;
}
.tag-cloud-item {
padding: 4px 8px;
background: oklch(var(--lora-accent) / 0.1);
color: var(--lora-accent);
border-radius: var(--border-radius-xs);
font-size: 0.8rem;
border: 1px solid oklch(var(--lora-accent) / 0.2);
transition: all 0.2s ease;
cursor: pointer;
}
.tag-cloud-item:hover {
background: oklch(var(--lora-accent) / 0.2);
transform: scale(1.05);
}
.tag-cloud-item.size-1 { font-size: 0.7rem; }
.tag-cloud-item.size-2 { font-size: 0.8rem; }
.tag-cloud-item.size-3 { font-size: 0.9rem; }
.tag-cloud-item.size-4 { font-size: 1.0rem; }
.tag-cloud-item.size-5 { font-size: 1.1rem; font-weight: 600; }
/* Analysis Cards */
.analysis-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-2);
}
.analysis-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-2);
text-align: center;
}
.analysis-card .card-icon {
font-size: 1.5rem;
color: var(--lora-accent);
margin-bottom: 8px;
}
.analysis-card .card-value {
font-size: 1.4rem;
font-weight: bold;
color: var(--text-color);
margin-bottom: 4px;
}
.analysis-card .card-label {
font-size: 0.85rem;
color: oklch(var(--text-color) / 0.7);
}
/* Insights */
.insights-container {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-3);
}
.insights-container h3 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.2rem;
display: flex;
align-items: center;
gap: 8px;
}
.insights-container h3 i {
color: var(--lora-accent);
}
.insights-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.insight-card {
padding: var(--space-2);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.insight-card:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.insight-card.type-success {
border-left: 4px solid var(--lora-success);
background: oklch(var(--lora-success) / 0.05);
}
.insight-card.type-warning {
border-left: 4px solid var(--lora-warning);
background: oklch(var(--lora-warning) / 0.05);
}
.insight-card.type-info {
border-left: 4px solid var(--lora-accent);
background: oklch(var(--lora-accent) / 0.05);
}
.insight-card.type-error {
border-left: 4px solid var(--lora-error);
background: oklch(var(--lora-error) / 0.05);
}
.insight-title {
font-weight: 600;
color: var(--text-color);
margin-bottom: 8px;
font-size: 1rem;
}
.insight-description {
color: oklch(var(--text-color) / 0.8);
margin-bottom: 8px;
font-size: 0.9rem;
line-height: 1.4;
}
.insight-suggestion {
color: oklch(var(--text-color) / 0.7);
font-size: 0.85rem;
font-style: italic;
}
/* Recommendations Section */
.recommendations-section {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
.recommendations-section h4 {
margin: 0 0 var(--space-2) 0;
color: var(--text-color);
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.recommendations-section h4 i {
color: var(--lora-accent);
}
.recommendations-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.recommendation-item {
padding: 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
transition: all 0.2s ease;
}
.recommendation-item:hover {
border-color: var(--lora-accent);
}
.recommendation-title {
font-weight: 600;
color: var(--text-color);
margin-bottom: 6px;
font-size: 0.9rem;
}
.recommendation-description {
color: oklch(var(--text-color) / 0.8);
font-size: 0.85rem;
line-height: 1.4;
}
/* Loading States */
.loading-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: oklch(var(--text-color) / 0.6);
font-size: 0.9rem;
}
.loading-placeholder i {
margin-right: 8px;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Responsive Design */
@media (max-width: 1200px) {
.panel-grid {
grid-template-columns: 1fr;
}
.metrics-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
}
@media (max-width: 768px) {
.dashboard-tabs {
flex-wrap: wrap;
}
.tab-button {
flex: 1;
min-width: 0;
font-size: 0.8rem;
padding: 12px 8px;
}
.tab-button i {
display: none;
}
.tab-content {
padding: var(--space-2);
}
.metrics-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--space-1);
}
.metric-card {
padding: var(--space-1);
}
.metric-card .metric-icon {
font-size: 1.5rem;
}
.metric-card .metric-value {
font-size: 1.4rem;
}
.chart-wrapper {
height: 200px;
}
.model-item .model-preview {
width: 32px;
height: 32px;
}
}
/* Dark mode adjustments */
[data-theme="dark"] .chart-container,
[data-theme="dark"] .list-container,
[data-theme="dark"] .insights-container {
border-color: oklch(var(--border-color) / 0.3);
}
[data-theme="dark"] .metric-card {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
[data-theme="dark"] .metric-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}

View File

@@ -7,9 +7,6 @@
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 {
@@ -33,13 +30,11 @@
.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 {
@@ -117,6 +112,28 @@
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* Patreon button style */
.patreon-button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 20px;
background: #F96854;
color: white;
border-radius: var(--border-radius-sm);
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
margin-top: var(--space-1);
}
.patreon-button:hover {
background: #E04946;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
/* QR Code section styles */
.qrcode-toggle {
width: 100%;

View File

@@ -4,7 +4,7 @@
width: 100%;
position: relative;
overflow-x: hidden; /* Prevent horizontal scrolling */
overflow-y: auto; /* Enable vertical scrolling */
overflow-y: scroll; /* Enable vertical scrolling */
}
.container {

View File

@@ -30,6 +30,7 @@
@import 'components/alphabet-bar.css'; /* Add alphabet bar component */
@import 'components/duplicates.css'; /* Add duplicates component */
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
.initialization-notice {
display: flex;

View File

@@ -140,7 +140,7 @@ export async function renameCheckpointFile(filePath, newFileName) {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming checkpoint file...');
const response = await fetch('/api/rename_checkpoint', {
const response = await fetch('/api/checkpoints/rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -160,7 +160,6 @@ export async function renameCheckpointFile(filePath, newFileName) {
console.error('Error renaming checkpoint file:', error);
throw error;
} finally {
// Hide loading indicator
state.loadingManager.hide();
}
}

View File

@@ -149,7 +149,7 @@ export async function renameLoraFile(filePath, newFileName) {
// Show loading indicator
state.loadingManager.showSimpleLoading('Renaming LoRA file...');
const response = await fetch('/api/rename_lora', {
const response = await fetch('/api/loras/rename', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -171,3 +171,44 @@ export function createRecipeCard(recipe) {
});
return recipeCard.element;
}
/**
* Update recipe metadata on the server
* @param {string} filePath - The file path of the recipe (e.g. D:/Workspace/ComfyUI/models/loras/recipes/86b4c335-ecfc-4791-89d2-3746e55a7614.webp)
* @param {Object} updates - The metadata updates to apply
* @returns {Promise<Object>} The updated recipe data
*/
export async function updateRecipeMetadata(filePath, updates) {
try {
state.loadingManager.showSimpleLoading('Saving metadata...');
// Extract recipeId from filePath (basename without extension)
const basename = filePath.split('/').pop().split('\\').pop();
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
const response = await fetch(`/api/recipe/${recipeId}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates)
});
const data = await response.json();
if (!data.success) {
showToast(`Failed to update recipe: ${data.error}`, 'error');
throw new Error(data.error || 'Failed to update recipe');
}
state.virtualScroller.updateSingleItem(filePath, updates);
return data;
} catch (error) {
console.error('Error updating recipe:', error);
showToast(`Error updating recipe: ${error.message}`, 'error');
throw error;
} finally {
state.loadingManager.hide();
}
}

View File

@@ -1,10 +1,203 @@
import { showToast, copyToClipboard, openExampleImagesFolder } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, openExampleImagesFolder, openCivitai } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { showCheckpointModal } from './checkpointModal/index.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js';
import { showDeleteModal } from '../utils/modalUtils.js';
// Add a global event delegation handler
export function setupCheckpointCardEventDelegation() {
const gridElement = document.getElementById('checkpointGrid');
if (!gridElement) return;
// Remove any existing event listener to prevent duplication
gridElement.removeEventListener('click', handleCheckpointCardEvent);
// Add the event delegation handler
gridElement.addEventListener('click', handleCheckpointCardEvent);
}
// Event delegation handler for all checkpoint card events
function handleCheckpointCardEvent(event) {
// Find the closest card element
const card = event.target.closest('.lora-card');
if (!card) return;
// Handle specific elements within the card
if (event.target.closest('.toggle-blur-btn')) {
event.stopPropagation();
toggleBlurContent(card);
return;
}
if (event.target.closest('.show-content-btn')) {
event.stopPropagation();
showBlurredContent(card);
return;
}
if (event.target.closest('.fa-star')) {
event.stopPropagation();
toggleFavorite(card);
return;
}
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.filepath);
}
return;
}
if (event.target.closest('.fa-copy')) {
event.stopPropagation();
copyCheckpointName(card);
return;
}
if (event.target.closest('.fa-trash')) {
event.stopPropagation();
showDeleteModal(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-image')) {
event.stopPropagation();
replaceCheckpointPreview(card.dataset.filepath);
return;
}
if (event.target.closest('.fa-folder-open')) {
event.stopPropagation();
openExampleImagesFolder(card.dataset.sha256);
return;
}
// If no specific element was clicked, handle the card click (show modal)
showCheckpointModalFromCard(card);
}
// Helper functions for event handling
function toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn 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';
}
}
function showBlurredContent(card) {
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';
}
}
async function toggleFavorite(card) {
const starIcon = card.querySelector('.fa-star');
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
showToast('Added to favorites', 'success');
} else {
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
}
async function copyCheckpointName(card) {
const checkpointName = card.dataset.file_name;
try {
await copyToClipboard(checkpointName, 'Checkpoint name copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
}
function showCheckpointModalFromCard(card) {
// Get the page-specific previewVersions map
const previewVersions = state.pages.checkpoints.previewVersions || new Map();
const version = previewVersions.get(card.dataset.filepath);
const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png';
const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl;
// Show checkpoint details modal
const checkpointMeta = {
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: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
preview_url: versionedPreviewUrl,
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: (() => {
try {
return JSON.parse(card.dataset.tags || '[]');
} catch (e) {
console.error('Failed to parse tags:', e);
return []; // Return empty array on error
}
})(),
modelDescription: card.dataset.modelDescription || ''
};
showCheckpointModal(checkpointMeta);
}
function replaceCheckpointPreview(filePath) {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);
} else {
apiReplaceCheckpointPreview(filePath);
}
}
export function createCheckpointCard(checkpoint) {
const card = document.createElement('div');
card.className = 'lora-card'; // Reuse the same class for styling
@@ -123,153 +316,7 @@ export function createCheckpointCard(checkpoint) {
</div>
`;
// Main card click event
card.addEventListener('click', () => {
// Show checkpoint details modal
const checkpointMeta = {
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: parseInt(card.dataset.file_size || '0'),
from_civitai: card.dataset.from_civitai === 'true',
base_model: card.dataset.base_model,
notes: card.dataset.notes || '',
preview_url: versionedPreviewUrl,
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
return JSON.parse(card.dataset.meta || '{}');
} catch (e) {
console.error('Failed to parse civitai metadata:', e);
return {}; // Return empty object on error
}
})(),
tags: (() => {
try {
return JSON.parse(card.dataset.tags || '[]');
} catch (e) {
console.error('Failed to parse tags:', e);
return []; // Return empty array on error
}
})(),
modelDescription: card.dataset.modelDescription || ''
};
showCheckpointModal(checkpointMeta);
});
// 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';
}
});
}
// Favorite button click event
card.querySelector('.fa-star')?.addEventListener('click', async e => {
e.stopPropagation();
const starIcon = e.currentTarget;
const isFavorite = starIcon.classList.contains('fas');
const newFavoriteState = !isFavorite;
try {
// Save the new favorite state to the server
await saveModelMetadata(card.dataset.filepath, {
favorite: newFavoriteState
});
if (newFavoriteState) {
showToast('Added to favorites', 'success');
} else {
showToast('Removed from favorites', 'success');
}
} catch (error) {
console.error('Failed to update favorite status:', error);
showToast('Failed to update favorite status', 'error');
}
});
// Copy button click event
card.querySelector('.fa-copy')?.addEventListener('click', async e => {
e.stopPropagation();
const checkpointName = card.dataset.file_name;
try {
await copyToClipboard(checkpointName, 'Checkpoint name copied');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
// Civitai button click event
if (checkpoint.from_civitai) {
card.querySelector('.fa-globe')?.addEventListener('click', e => {
e.stopPropagation();
openCivitai(checkpoint.model_name);
});
}
// Delete button click event
card.querySelector('.fa-trash')?.addEventListener('click', e => {
e.stopPropagation();
showDeleteModal(checkpoint.file_path);
});
// Replace preview button click event
card.querySelector('.fa-image')?.addEventListener('click', e => {
e.stopPropagation();
replaceCheckpointPreview(checkpoint.file_path);
});
// Open example images folder button click event
card.querySelector('.fa-folder-open')?.addEventListener('click', e => {
e.stopPropagation();
openExampleImagesFolder(checkpoint.sha256);
});
// Add autoplayOnHover handlers for video elements if needed
// Add video auto-play on hover functionality if needed
const videoElement = card.querySelector('video');
if (videoElement && autoplayOnHover) {
const cardPreview = card.querySelector('.card-preview');
@@ -278,52 +325,10 @@ export function createCheckpointCard(checkpoint) {
videoElement.removeAttribute('autoplay');
videoElement.pause();
// Add mouse events to trigger play/pause
cardPreview.addEventListener('mouseenter', () => {
videoElement.play();
});
cardPreview.addEventListener('mouseleave', () => {
videoElement.pause();
videoElement.currentTime = 0;
});
// Add mouse events to trigger play/pause using event attributes
cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()');
cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}');
}
return card;
}
// These functions will be implemented in checkpointApi.js
function openCivitai(modelName) {
// Check if the global function exists (registered by PageControls)
if (window.openCivitai) {
window.openCivitai(modelName);
} else {
// Fallback implementation
const card = document.querySelector(`.lora-card[data-name="${modelName}"]`);
if (!card) return;
const metaData = JSON.parse(card.dataset.meta || '{}');
const civitaiId = metaData.modelId;
const versionId = metaData.id;
// Build URL
if (civitaiId) {
let url = `https://civitai.com/models/${civitaiId}`;
if (versionId) {
url += `?modelVersionId=${versionId}`;
}
window.open(url, '_blank');
} else {
// If no ID, try searching by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
}
}
}
function replaceCheckpointPreview(filePath) {
if (window.replaceCheckpointPreview) {
window.replaceCheckpointPreview(filePath);
} else {
apiReplaceCheckpointPreview(filePath);
}
}

View File

@@ -1,6 +1,4 @@
import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import { state } from '../../state/index.js';
@@ -44,149 +42,6 @@ export const ModelContextMenuMixin = {
});
},
updateCardBlurEffect(card, level) {
// Get user settings for blur threshold
const blurThreshold = parseInt(getStorageItem('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';
// Remove toggle button when content is set to PG or PG13
const cardHeader = previewContainer.querySelector('.card-header');
if (cardHeader) {
const toggleBtn = cardHeader.querySelector('.toggle-blur-btn');
if (toggleBtn) {
// Remove the toggle button completely
toggleBtn.remove();
// Update base model label class if it exists
const baseModelLabel = cardHeader.querySelector('.base-model-label');
if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) {
baseModelLabel.classList.remove('with-toggle');
}
}
}
}
},
showNSFWLevelSelector(x, y, card) {
const selector = document.getElementById('nsfwLevelSelector');
const currentLevelEl = document.getElementById('currentNSFWLevel');

View File

@@ -1,11 +1,31 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
export class RecipeContextMenu extends BaseContextMenu {
constructor() {
super('recipeContextMenu', '.lora-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'recipe';
// Initialize NSFW Level Selector events
if (this.nsfwSelector) {
this.initNSFWSelector();
}
}
// Use the updateRecipeMetadata implementation from recipeApi
async saveModelMetadata(filePath, data) {
return updateRecipeMetadata(filePath, data);
}
// Override resetAndReload for recipe context
async resetAndReload() {
const { resetAndReload } = await import('../../api/recipeApi.js');
return resetAndReload();
}
showMenu(x, y, card) {
@@ -31,6 +51,12 @@ export class RecipeContextMenu extends BaseContextMenu {
}
handleMenuAction(action) {
// First try to handle with common actions from ModelContextMenuMixin
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
// Handle recipe-specific actions
const recipeId = this.currentCard.dataset.id;
switch(action) {
@@ -256,4 +282,7 @@ export class RecipeContextMenu extends BaseContextMenu {
}
}
}
}
}
// Mix in shared methods from ModelContextMenuMixin
Object.assign(RecipeContextMenu.prototype, ModelContextMenuMixin);

View File

@@ -26,6 +26,7 @@ export class HeaderManager {
const path = window.location.pathname;
if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}
@@ -46,9 +47,21 @@ export class HeaderManager {
// Handle theme toggle
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
// Set initial state based on current theme
const currentTheme = localStorage.getItem('lm_theme') || 'auto';
themeToggle.classList.add(`theme-${currentTheme}`);
themeToggle.addEventListener('click', () => {
if (typeof toggleTheme === 'function') {
toggleTheme();
const newTheme = toggleTheme();
// Update tooltip based on next toggle action
if (newTheme === 'light') {
themeToggle.title = "Switch to dark theme";
} else if (newTheme === 'dark') {
themeToggle.title = "Switch to auto theme";
} else {
themeToggle.title = "Switch to light theme";
}
}
});
}
@@ -109,14 +122,32 @@ export class HeaderManager {
});
}
// Handle help toggle
// const helpToggle = document.querySelector('.help-toggle');
// if (helpToggle) {
// helpToggle.addEventListener('click', () => {
// if (window.modalManager) {
// window.modalManager.toggleModal('helpModal');
// }
// });
// }
// Hide search functionality on Statistics page
this.updateHeaderForPage();
}
updateHeaderForPage() {
const headerSearch = document.getElementById('headerSearch');
if (this.currentPage === 'statistics' && headerSearch) {
headerSearch.classList.add('disabled');
// Disable search functionality
const searchInput = headerSearch.querySelector('#searchInput');
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = true;
searchInput.placeholder = 'Search not available on statistics page';
}
searchButtons.forEach(btn => btn.disabled = true);
} else if (headerSearch) {
headerSearch.classList.remove('disabled');
// Re-enable search functionality
const searchInput = headerSearch.querySelector('#searchInput');
const searchButtons = headerSearch.querySelectorAll('button');
if (searchInput) {
searchInput.disabled = false;
}
searchButtons.forEach(btn => btn.disabled = false);
}
}
}

View File

@@ -45,7 +45,7 @@ function handleLoraCardEvent(event) {
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
openCivitai(card.dataset.name);
openCivitai(card.dataset.filepath);
}
return;
}

View File

@@ -2,6 +2,8 @@
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js';
import { NSFW_LEVELS } from '../utils/constants.js';
class RecipeCard {
constructor(recipe, clickHandler) {
@@ -16,8 +18,9 @@ class RecipeCard {
createCardElement() {
const card = document.createElement('div');
card.className = 'lora-card';
card.dataset.filePath = this.recipe.file_path;
card.dataset.filepath = this.recipe.file_path;
card.dataset.title = this.recipe.title;
card.dataset.nsfwLevel = this.recipe.preview_nsfw_level || 0;
card.dataset.created = this.recipe.created_date;
card.dataset.id = this.recipe.id || '';
@@ -41,15 +44,34 @@ class RecipeCard {
const pageState = getCurrentPageState();
const isDuplicatesMode = pageState.duplicatesMode;
// NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
if (shouldBlur) {
card.classList.add('nsfw-content');
}
// 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 = `
${!isDuplicatesMode ? `<div class="recipe-indicator" title="Recipe">R</div>` : ''}
<div class="card-preview">
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
<img src="${imageUrl}" alt="${this.recipe.title}">
${!isDuplicatesMode ? `
<div class="card-header">
<div class="base-model-wrapper">
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
</div>
${shouldBlur ?
`<button class="toggle-blur-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>` : ''}
${baseModel ? `<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModel}">${baseModel}</span>` : ''}
<div class="card-actions">
<i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
@@ -57,6 +79,14 @@ class RecipeCard {
</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">${this.recipe.title}</span>
@@ -71,7 +101,7 @@ class RecipeCard {
</div>
`;
this.attachEventListeners(card, isDuplicatesMode);
this.attachEventListeners(card, isDuplicatesMode, shouldBlur);
return card;
}
@@ -81,7 +111,27 @@ class RecipeCard {
return `${missingCount} of ${totalCount} LoRAs missing`;
}
attachEventListeners(card, isDuplicatesMode) {
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
// Add blur toggle functionality if content should be blurred
if (shouldBlur) {
const toggleBtn = card.querySelector('.toggle-blur-btn');
const showBtn = card.querySelector('.show-content-btn');
if (toggleBtn) {
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleBlurContent(card);
});
}
if (showBtn) {
showBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.showBlurredContent(card);
});
}
}
// Recipe card click event - only attach if not in duplicates mode
if (!isDuplicatesMode) {
card.addEventListener('click', () => {
@@ -108,7 +158,42 @@ class RecipeCard {
}
}
// Replace copyRecipeSyntax with sendRecipeToWorkflow
toggleBlurContent(card) {
const preview = card.querySelector('.card-preview');
const isBlurred = preview.classList.toggle('blurred');
const icon = card.querySelector('.toggle-blur-btn 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';
}
}
showBlurredContent(card) {
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';
}
}
sendRecipeToWorkflow(replaceMode = false) {
try {
// Get recipe ID
@@ -141,6 +226,7 @@ class RecipeCard {
try {
// Get recipe ID
const recipeId = this.recipe.id;
const filePath = this.recipe.file_path;
if (!recipeId) {
showToast('Cannot delete recipe: Missing recipe ID', 'error');
return;
@@ -184,6 +270,7 @@ class RecipeCard {
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath;
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
@@ -227,7 +314,7 @@ class RecipeCard {
.then(data => {
showToast('Recipe deleted successfully', 'success');
window.recipeManager.loadRecipes();
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal');
})

View File

@@ -3,6 +3,7 @@ import { showToast, copyToClipboard } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { updateRecipeCard } from '../utils/cardUpdater.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
class RecipeModal {
constructor() {
@@ -117,6 +118,7 @@ class RecipeModal {
// Store the recipe ID for copy syntax API call
this.recipeId = recipe.id;
this.filePath = recipe.file_path;
// Set recipe tags if they exist
const tagsCompactElement = document.getElementById('recipeTagsCompact');
@@ -522,7 +524,19 @@ class RecipeModal {
titleContainer.querySelector('.content-text').textContent = newTitle;
// Update the recipe on the server
this.updateRecipeMetadata({ title: newTitle });
updateRecipeMetadata(this.filePath, { title: newTitle })
.then(data => {
// Show success toast
showToast('Recipe name updated successfully', 'success');
// Update the current recipe object
this.currentRecipe.title = newTitle;
})
.catch(error => {
// Error is handled in the API function
// Reset the UI if needed
titleContainer.querySelector('.content-text').textContent = this.currentRecipe.title || '';
});
}
// Hide editor
@@ -580,64 +594,20 @@ class RecipeModal {
if (tagsChanged) {
// Update the recipe on the server
this.updateRecipeMetadata({ tags: newTags });
// Update tags in the UI
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (newTags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = newTags.slice(0, maxVisibleTags);
const remainingTags = newTags.length > maxVisibleTags ? newTags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
updateRecipeMetadata(this.filePath, { tags: newTags })
.then(data => {
// Show success toast
showToast('Recipe tags updated successfully', 'success');
// Update the current recipe object
this.currentRecipe.tags = newTags;
// Update tags in the UI
this.updateTagsDisplay(tagsContainer, newTags);
})
.catch(error => {
// Error is handled in the API function
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
newTags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
// Update the current recipe object
this.currentRecipe.tags = newTags;
}
// Hide editor
@@ -646,6 +616,62 @@ class RecipeModal {
}
}
// Helper method to update tags display
updateTagsDisplay(tagsContainer, tags) {
const tagsDisplay = tagsContainer.querySelector('.tags-display');
tagsDisplay.innerHTML = '';
if (tags.length > 0) {
// Limit displayed tags to 5, show a "+X more" button if needed
const maxVisibleTags = 5;
const visibleTags = tags.slice(0, maxVisibleTags);
const remainingTags = tags.length > maxVisibleTags ? tags.slice(maxVisibleTags) : [];
// Add visible tags
visibleTags.forEach(tag => {
const tagElement = document.createElement('div');
tagElement.className = 'recipe-tag-compact';
tagElement.textContent = tag;
tagsDisplay.appendChild(tagElement);
});
// Add "more" button if needed
if (remainingTags.length > 0) {
const moreButton = document.createElement('div');
moreButton.className = 'recipe-tag-more';
moreButton.textContent = `+${remainingTags.length} more`;
tagsDisplay.appendChild(moreButton);
// Update tooltip content
const tooltipContent = document.getElementById('recipeTagsTooltipContent');
if (tooltipContent) {
tooltipContent.innerHTML = '';
tags.forEach(tag => {
const tooltipTag = document.createElement('div');
tooltipTag.className = 'tooltip-tag';
tooltipTag.textContent = tag;
tooltipContent.appendChild(tooltipTag);
});
}
// Re-add tooltip functionality
moreButton.addEventListener('mouseenter', () => {
document.getElementById('recipeTagsTooltip').classList.add('visible');
});
moreButton.addEventListener('mouseleave', () => {
setTimeout(() => {
if (!document.getElementById('recipeTagsTooltip').matches(':hover')) {
document.getElementById('recipeTagsTooltip').classList.remove('visible');
}
}, 300);
});
}
} else {
tagsDisplay.innerHTML = '<div class="no-tags">No tags</div>';
}
}
cancelTagsEdit() {
const tagsContainer = document.getElementById('recipeTagsCompact');
if (tagsContainer) {
@@ -660,41 +686,66 @@ class RecipeModal {
}
}
// Update recipe metadata on the server
async updateRecipeMetadata(updates) {
try {
const response = await fetch(`/api/recipe/${this.recipeId}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(updates)
});
const data = await response.json();
if (data.success) {
// 显示保存成功的提示
if (updates.title) {
showToast('Recipe name updated successfully', 'success');
} else if (updates.tags) {
showToast('Recipe tags updated successfully', 'success');
} else {
showToast('Recipe updated successfully', 'success');
}
// 更新当前recipe对象的属性
Object.assign(this.currentRecipe, updates);
// Update the recipe card in the UI
updateRecipeCard(this.recipeId, updates);
} else {
showToast(`Failed to update recipe: ${data.error}`, 'error');
// Setup source URL handlers
setupSourceUrlHandlers() {
const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor');
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
// Show editor on edit button click
sourceUrlEditBtn.addEventListener('click', () => {
sourceUrlContainer.classList.add('hide');
sourceUrlEditor.classList.add('active');
sourceUrlInput.focus();
});
// Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
sourceUrlInput.value = this.currentRecipe.source_path || '';
});
// Save new source URL
sourceUrlSaveBtn.addEventListener('click', () => {
const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl !== this.currentRecipe.source_path) {
// Update the recipe on the server
updateRecipeMetadata(this.filePath, { source_path: newSourceUrl })
.then(data => {
// Show success toast
showToast('Source URL updated successfully', 'success');
// Update source URL in the UI
sourceUrlText.textContent = newSourceUrl || 'No source URL';
sourceUrlText.title = newSourceUrl && (newSourceUrl.startsWith('http://') ||
newSourceUrl.startsWith('https://')) ?
'Click to open source URL' : 'No valid URL';
// Update the current recipe object
this.currentRecipe.source_path = newSourceUrl;
})
.catch(error => {
// Error is handled in the API function
});
}
} catch (error) {
console.error('Error updating recipe:', error);
showToast(`Error updating recipe: ${error.message}`, 'error');
}
// Hide editor
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
});
// Open source URL in a new tab if it's valid
sourceUrlText.addEventListener('click', () => {
const url = sourceUrlText.textContent.trim();
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
}
});
}
// Setup copy buttons for prompts and recipe syntax
@@ -950,13 +1001,6 @@ class RecipeModal {
// Remove .safetensors extension if present
fileName = fileName.replace(/\.safetensors$/, '');
// Get the deleted lora data
const deletedLora = this.currentRecipe.loras[loraIndex];
if (!deletedLora) {
showToast('Error: Could not find the LoRA in the recipe', 'error');
return;
}
state.loadingManager.showSimpleLoading('Reconnecting LoRA...');
// Call API to reconnect the LoRA
@@ -967,7 +1011,7 @@ class RecipeModal {
},
body: JSON.stringify({
recipe_id: this.recipeId,
lora_data: deletedLora,
lora_index: loraIndex,
target_name: fileName
})
});
@@ -989,13 +1033,10 @@ class RecipeModal {
setTimeout(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
// Refresh recipes list
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
setTimeout(() => {
window.recipeManager.loadRecipes(true);
}, 1000);
}
state.virtualScroller.updateSingleItem(this.currentRecipe.file_path, {
loras: this.currentRecipe.loras
});
} else {
showToast(`Error: ${result.error}`, 'error');
}
@@ -1065,56 +1106,6 @@ class RecipeModal {
});
});
}
// New method to set up source URL handlers
setupSourceUrlHandlers() {
const sourceUrlContainer = document.querySelector('.source-url-container');
const sourceUrlEditor = document.querySelector('.source-url-editor');
const sourceUrlText = sourceUrlContainer.querySelector('.source-url-text');
const sourceUrlEditBtn = sourceUrlContainer.querySelector('.source-url-edit-btn');
const sourceUrlCancelBtn = sourceUrlEditor.querySelector('.source-url-cancel-btn');
const sourceUrlSaveBtn = sourceUrlEditor.querySelector('.source-url-save-btn');
const sourceUrlInput = sourceUrlEditor.querySelector('.source-url-input');
// Show editor on edit button click
sourceUrlEditBtn.addEventListener('click', () => {
sourceUrlContainer.classList.add('hide');
sourceUrlEditor.classList.add('active');
sourceUrlInput.focus();
});
// Cancel editing
sourceUrlCancelBtn.addEventListener('click', () => {
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
sourceUrlInput.value = this.currentRecipe.source_path || '';
});
// Save new source URL
sourceUrlSaveBtn.addEventListener('click', () => {
const newSourceUrl = sourceUrlInput.value.trim();
if (newSourceUrl && newSourceUrl !== this.currentRecipe.source_path) {
// Update source URL in the UI
sourceUrlText.textContent = newSourceUrl;
sourceUrlText.title = newSourceUrl.startsWith('http://') || newSourceUrl.startsWith('https://') ? 'Click to open source URL' : 'No valid URL';
// Update the recipe on the server
this.updateRecipeMetadata({ source_path: newSourceUrl });
}
// Hide editor
sourceUrlEditor.classList.remove('active');
sourceUrlContainer.classList.remove('hide');
});
// Open source URL in a new tab if it's valid
sourceUrlText.addEventListener('click', () => {
const url = sourceUrlText.textContent.trim();
if (url.startsWith('http://') || url.startsWith('https://')) {
window.open(url, '_blank');
}
});
}
}
export { RecipeModal };

View File

@@ -4,7 +4,7 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { updateModelCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
import { saveModelMetadata, renameCheckpointFile } from '../../api/checkpointApi.js';
/**
@@ -412,30 +412,10 @@ export function setupFileNameEditing(filePath) {
if (result.success) {
showToast('File name updated successfully', 'success');
// Get the new file path from the result
const pathParts = filePath.split(/[\\/]/);
pathParts.pop(); // Remove old filename
const newFilePath = [...pathParts, newFileName].join('/');
const newFilePath = filePath.replace(originalValue, newFileName);
// Update the checkpoint card with new file path
updateModelCard(filePath, {
filepath: newFilePath,
file_name: newFileName
});
// Update the file name display in the modal
document.querySelector('#file-name').textContent = newFileName;
// Update the modal's data-filepath attribute
const modalContent = document.querySelector('#checkpointModal .modal-content');
if (modalContent) {
modalContent.dataset.filepath = newFilePath;
}
// Reload the page after a short delay to reflect changes
setTimeout(() => {
window.location.reload();
}, 1500);
state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath });
this.textContent = newFileName;
} else {
throw new Error(result.error || 'Unknown error');
}

View File

@@ -4,7 +4,7 @@
*/
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { updateModelCard } from '../../utils/cardUpdater.js';
import { state } from '../../state/index.js';
import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js';
/**
@@ -420,8 +420,8 @@ export function setupFileNameEditing(filePath) {
// Get the new file path and update the card
const newFilePath = filePath.replace(originalValue, newFileName);
// Pass the new file_name in the updates object for proper card update
updateModelCard(filePath, { file_name: newFileName, filepath: newFilePath });
;
state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath });
} else {
throw new Error(result.error || 'Unknown error');
}

View File

@@ -102,13 +102,10 @@ function renderRecipes(tabElement, recipes, loraName, loraHash) {
card.dataset.id = recipe.id || '';
card.innerHTML = `
<div class="recipe-indicator" title="Recipe">R</div>
<div class="card-preview">
<img src="${imageUrl}" alt="${recipe.title}" loading="lazy">
<div class="card-header">
<div class="base-model-wrapper">
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
</div>
${baseModel ? `<span class="base-model-label" title="${baseModel}">${baseModel}</span>` : ''}
<div class="card-actions">
<i class="fas fa-copy" title="Copy Recipe Syntax"></i>
</div>

View File

@@ -354,7 +354,6 @@ export function setupTriggerWordsEditMode() {
}
// Remove dropdown if present
const triggerWordsSection = editBtn.closest('.trigger-words');
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
}

View File

@@ -116,12 +116,9 @@ export function showLoraModal(lora) {
</div>
${renderTriggerWords(escapedWords, lora.file_path)}
<div class="info-item notes">
<label>Additional Notes</label>
<label>Additional Notes <i class="fas fa-info-circle notes-hint" title="Press Enter to save, Shift+Enter for new line"></i></label>
<div class="editable-field">
<div class="notes-content" contenteditable="true" spellcheck="false">${lora.notes || 'Add your notes here...'}</div>
<button class="save-btn" data-action="save-notes">
<i class="fas fa-save"></i>
</button>
</div>
</div>
<div class="info-item full-width">
@@ -171,7 +168,14 @@ export function showLoraModal(lora) {
</div>
`;
modalManager.showModal('loraModal', content);
modalManager.showModal('loraModal', content, null, function() {
// Clean up all handlers when modal closes
const modalElement = document.getElementById('loraModal');
if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
});
setupEditableFields(lora.file_path);
setupShowcaseScroll('loraModal');
setupTabSwitching();
@@ -206,8 +210,11 @@ export function showLoraModal(lora) {
function setupEventHandlers(filePath) {
const modalElement = document.getElementById('loraModal');
// Use event delegation to handle clicks
modalElement.addEventListener('click', async (event) => {
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
@@ -217,16 +224,17 @@ function setupEventHandlers(filePath) {
case 'close-modal':
modalManager.closeModal('loraModal');
break;
case 'save-notes':
await saveNotes(filePath);
break;
case 'scroll-to-top':
scrollToTop(target);
break;
}
});
}
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick;
}
async function saveNotes(filePath) {

View File

@@ -71,10 +71,10 @@ export async function loadExampleImages(images, modelHash) {
* Render showcase content
* @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files
* @param {Object} options - Options for rendering
* @param {boolean} startExpanded - Whether to start in expanded state
* @returns {string} HTML content
*/
export function renderShowcaseContent(images, exampleFiles = []) {
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
if (!images?.length) {
// Show empty state with import interface
return renderImportInterface(true);
@@ -113,10 +113,10 @@ export function renderShowcaseContent(images, exampleFiles = []) {
return `
<div class="scroll-indicator" onclick="toggleShowcase(this)">
<i class="fas fa-chevron-down"></i>
<span>Scroll or click to show ${filteredImages.length} examples</span>
<i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i>
<span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span>
</div>
<div class="carousel collapsed">
<div class="carousel ${startExpanded ? '' : 'collapsed'}">
${hiddenNotification}
<div class="carousel-container">
${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')}
@@ -406,7 +406,10 @@ async function handleImportFiles(files, modelHash, importContainer) {
const customImages = result.custom_images || [];
// Combine both arrays for rendering
const allImages = [...regularImages, ...customImages];
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files);
console.log("Regular images:", regularImages);
console.log("Custom images:", customImages);
console.log("Combined images:", allImages);
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
// Re-initialize showcase functionality
const carousel = showcaseTab.querySelector('.carousel');

View File

@@ -42,6 +42,9 @@ export class AppCore {
exampleImagesManager.initialize();
// Initialize the help manager
helpManager.initialize();
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
// Mark as initialized
this.initialized = true;

View File

@@ -41,6 +41,12 @@ export class BulkManager {
return; // Exit early - let the browser handle Ctrl+A within the modal
}
// Check if search input is currently focused - if so, don't handle Ctrl+A
const searchInput = document.getElementById('searchInput');
if (searchInput && document.activeElement === searchInput) {
return; // Exit early - let the browser handle Ctrl+A within the search input
}
// Prevent default browser "Select All" behavior
e.preventDefault();

View File

@@ -69,6 +69,30 @@ class ExampleImagesManager {
pathInput.value = savedPath;
// Enable download button if path is set
this.updateDownloadButtonState(true);
// Sync the saved path with the backend
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
example_images_path: savedPath
})
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (!data.success) {
console.error('Failed to sync example images path with backend:', data.error);
}
} catch (error) {
console.error('Failed to sync saved path with backend:', error);
}
} else {
// Disable download button if no path is set
this.updateDownloadButtonState(false);

View File

@@ -148,6 +148,8 @@ export class FilterManager {
apiEndpoint = '/api/recipes/base-models';
} else if (this.currentPage === 'checkpoints') {
apiEndpoint = '/api/checkpoints/base-models';
} else {
return;
}
// Fetch base models

View File

@@ -299,7 +299,7 @@ export class ModalManager {
return null;
}
showModal(id, content = null, onCloseCallback = null) {
showModal(id, content = null, onCloseCallback = null, cleanupCallback = null) {
const modal = this.getModal(id);
if (!modal) return;
@@ -314,9 +314,8 @@ export class ModalManager {
}
// Store callback
if (onCloseCallback) {
modal.onCloseCallback = onCloseCallback;
}
modal.onCloseCallback = onCloseCallback;
modal.cleanupCallback = cleanupCallback;
// Store current scroll position before showing modal
this.scrollPosition = window.scrollY;
@@ -362,6 +361,11 @@ export class ModalManager {
modal.onCloseCallback();
modal.onCloseCallback = null;
}
if (modal.cleanupCallback) {
modal.cleanupCallback();
modal.cleanupCallback = null;
}
}
handleEscape(e) {

View File

@@ -2,7 +2,6 @@ import { showToast } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { modalManager } from './ModalManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
import { updateModelCard } from '../utils/cardUpdater.js';
class MoveManager {
constructor() {
@@ -151,8 +150,8 @@ class MoveManager {
const filename = filePath.substring(filePath.lastIndexOf('/') + 1);
// Construct new filepath
const newFilePath = `${targetPath}/${filename}`;
// Update the card with new filepath
updateModelCard(filePath, {filepath: newFilePath});
state.virtualScroller.updateSingleItem(filePath, {file_path: newFilePath});
});
}
} else {
@@ -169,8 +168,8 @@ class MoveManager {
const filename = this.currentFilePath.substring(this.currentFilePath.lastIndexOf('/') + 1);
// Construct new filepath
const newFilePath = `${targetPath}/${filename}`;
// Update the card with new filepath
updateModelCard(this.currentFilePath, {filepath: newFilePath});
state.virtualScroller.updateSingleItem(this.currentFilePath, {file_path: newFilePath});
}
}

View File

@@ -36,6 +36,11 @@ export class SettingsManager {
if (state.global.settings.optimizeExampleImages === undefined) {
state.global.settings.optimizeExampleImages = true;
}
// Set default for cardInfoDisplay if undefined
if (state.global.settings.cardInfoDisplay === undefined) {
state.global.settings.cardInfoDisplay = 'always';
}
// Convert old boolean compactMode to new displayDensity string
if (typeof state.global.settings.displayDensity === 'undefined') {
@@ -102,6 +107,12 @@ export class SettingsManager {
if (displayDensitySelect) {
displayDensitySelect.value = state.global.settings.displayDensity || 'default';
}
// Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
if (cardInfoDisplaySelect) {
cardInfoDisplaySelect.value = state.global.settings.cardInfoDisplay || 'always';
}
// Set optimize example images setting
const optimizeExampleImagesCheckbox = document.getElementById('optimizeExampleImages');
@@ -245,6 +256,8 @@ export class SettingsManager {
// Also update compactMode for backwards compatibility
state.global.settings.compactMode = (value !== 'default');
} else if (settingKey === 'card_info_display') {
state.global.settings.cardInfoDisplay = value;
} else {
// For any other settings that might be added in the future
state.global.settings[settingKey] = value;
@@ -506,6 +519,10 @@ export class SettingsManager {
// Add the appropriate density class
grid.classList.add(`${density}-density`);
}
// Apply card info display setting
const cardInfoDisplay = state.global.settings.cardInfoDisplay || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
}
}

View File

@@ -4,7 +4,8 @@ import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
// Load settings from localStorage or use defaults
const savedSettings = getStorageItem('settings', {
blurMatureContent: true,
show_only_sfw: false
show_only_sfw: false,
cardInfoDisplay: 'always' // Add default value for card info display
});
// Load preview versions from localStorage

730
static/js/statistics.js Normal file
View File

@@ -0,0 +1,730 @@
// Statistics page functionality
import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js';
// Chart.js import (assuming it's available globally or via CDN)
// If Chart.js isn't available, we'll need to add it to the project
class StatisticsManager {
constructor() {
this.charts = {};
this.data = {};
this.initialized = false;
}
async initialize() {
if (this.initialized) return;
console.log('StatisticsManager: Initializing...');
// Initialize tab functionality
this.initializeTabs();
// Load initial data
await this.loadAllData();
// Initialize charts and visualizations
this.initializeVisualizations();
this.initialized = true;
}
initializeTabs() {
const tabButtons = document.querySelectorAll('.tab-button');
const tabPanels = document.querySelectorAll('.tab-panel');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.dataset.tab;
// Update active tab button
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
// Update active tab panel
tabPanels.forEach(panel => panel.classList.remove('active'));
const targetPanel = document.getElementById(`${tabId}-panel`);
if (targetPanel) {
targetPanel.classList.add('active');
// Refresh charts when tab becomes visible
this.refreshChartsInPanel(tabId);
}
});
});
}
async loadAllData() {
try {
// Load all statistics data in parallel
const [
collectionOverview,
usageAnalytics,
baseModelDistribution,
tagAnalytics,
storageAnalytics,
insights
] = await Promise.all([
this.fetchData('/api/stats/collection-overview'),
this.fetchData('/api/stats/usage-analytics'),
this.fetchData('/api/stats/base-model-distribution'),
this.fetchData('/api/stats/tag-analytics'),
this.fetchData('/api/stats/storage-analytics'),
this.fetchData('/api/stats/insights')
]);
this.data = {
collection: collectionOverview.data,
usage: usageAnalytics.data,
baseModels: baseModelDistribution.data,
tags: tagAnalytics.data,
storage: storageAnalytics.data,
insights: insights.data
};
console.log('Statistics data loaded:', this.data);
} catch (error) {
console.error('Error loading statistics data:', error);
showToast('Failed to load statistics data', 'error');
}
}
async fetchData(endpoint) {
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error(`Failed to fetch ${endpoint}: ${response.statusText}`);
}
return response.json();
}
initializeVisualizations() {
// Initialize metrics cards
this.renderMetricsCards();
// Initialize charts
this.initializeCharts();
// Initialize lists and other components
this.renderTopModelsLists();
this.renderTagCloud();
this.renderInsights();
}
renderMetricsCards() {
const metricsGrid = document.getElementById('metricsGrid');
if (!metricsGrid || !this.data.collection) return;
const metrics = [
{
icon: 'fas fa-magic',
value: this.data.collection.total_models,
label: 'Total Models',
format: 'number'
},
{
icon: 'fas fa-database',
value: this.data.collection.total_size,
label: 'Total Storage',
format: 'size'
},
{
icon: 'fas fa-play-circle',
value: this.data.collection.total_generations,
label: 'Total Generations',
format: 'number'
},
{
icon: 'fas fa-chart-line',
value: this.calculateUsageRate(),
label: 'Usage Rate',
format: 'percentage'
},
{
icon: 'fas fa-layer-group',
value: this.data.collection.lora_count,
label: 'LoRAs',
format: 'number'
},
{
icon: 'fas fa-check-circle',
value: this.data.collection.checkpoint_count,
label: 'Checkpoints',
format: 'number'
}
];
metricsGrid.innerHTML = metrics.map(metric => this.createMetricCard(metric)).join('');
}
createMetricCard(metric) {
const formattedValue = this.formatValue(metric.value, metric.format);
return `
<div class="metric-card">
<div class="metric-icon">
<i class="${metric.icon}"></i>
</div>
<div class="metric-value">${formattedValue}</div>
<div class="metric-label">${metric.label}</div>
</div>
`;
}
formatValue(value, format) {
switch (format) {
case 'number':
return new Intl.NumberFormat().format(value);
case 'size':
return this.formatFileSize(value);
case 'percentage':
return `${value.toFixed(1)}%`;
default:
return value;
}
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
calculateUsageRate() {
if (!this.data.collection) return 0;
const totalModels = this.data.collection.total_models;
const unusedModels = this.data.collection.unused_loras + this.data.collection.unused_checkpoints;
const usedModels = totalModels - unusedModels;
return totalModels > 0 ? (usedModels / totalModels) * 100 : 0;
}
initializeCharts() {
// Check if Chart.js is available
if (typeof Chart === 'undefined') {
console.warn('Chart.js is not available. Charts will not be rendered.');
this.showChartPlaceholders();
return;
}
// Collection pie chart
this.createCollectionPieChart();
// Base model distribution chart
this.createBaseModelChart();
// Usage timeline chart
this.createUsageTimelineChart();
// Usage distribution chart
this.createUsageDistributionChart();
// Storage chart
this.createStorageChart();
// Storage efficiency chart
this.createStorageEfficiencyChart();
}
createCollectionPieChart() {
const ctx = document.getElementById('collectionPieChart');
if (!ctx || !this.data.collection) return;
const data = {
labels: ['LoRAs', 'Checkpoints'],
datasets: [{
data: [this.data.collection.lora_count, this.data.collection.checkpoint_count],
backgroundColor: [
'oklch(68% 0.28 256)',
'oklch(68% 0.28 200)'
],
borderWidth: 2,
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--border-color')
}]
};
this.charts.collection = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
createBaseModelChart() {
const ctx = document.getElementById('baseModelChart');
if (!ctx || !this.data.baseModels) return;
const loraData = this.data.baseModels.loras;
const checkpointData = this.data.baseModels.checkpoints;
const allModels = new Set([...Object.keys(loraData), ...Object.keys(checkpointData)]);
const data = {
labels: Array.from(allModels),
datasets: [
{
label: 'LoRAs',
data: Array.from(allModels).map(model => loraData[model] || 0),
backgroundColor: 'oklch(68% 0.28 256 / 0.7)'
},
{
label: 'Checkpoints',
data: Array.from(allModels).map(model => checkpointData[model] || 0),
backgroundColor: 'oklch(68% 0.28 200 / 0.7)'
}
]
};
this.charts.baseModels = new Chart(ctx, {
type: 'bar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true
},
y: {
stacked: true
}
}
}
});
}
createUsageTimelineChart() {
const ctx = document.getElementById('usageTimelineChart');
if (!ctx || !this.data.usage) return;
const timeline = this.data.usage.usage_timeline || [];
const data = {
labels: timeline.map(item => new Date(item.date).toLocaleDateString()),
datasets: [
{
label: 'LoRA Usage',
data: timeline.map(item => item.lora_usage),
borderColor: 'oklch(68% 0.28 256)',
backgroundColor: 'oklch(68% 0.28 256 / 0.1)',
fill: true
},
{
label: 'Checkpoint Usage',
data: timeline.map(item => item.checkpoint_usage),
borderColor: 'oklch(68% 0.28 200)',
backgroundColor: 'oklch(68% 0.28 200 / 0.1)',
fill: true
}
]
};
this.charts.timeline = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Date'
}
},
y: {
display: true,
title: {
display: true,
text: 'Usage Count'
}
}
}
}
});
}
createUsageDistributionChart() {
const ctx = document.getElementById('usageDistributionChart');
if (!ctx || !this.data.usage) return;
const topLoras = this.data.usage.top_loras || [];
const topCheckpoints = this.data.usage.top_checkpoints || [];
// Combine and sort all models by usage
const allModels = [
...topLoras.map(m => ({ ...m, type: 'LoRA' })),
...topCheckpoints.map(m => ({ ...m, type: 'Checkpoint' }))
].sort((a, b) => b.usage_count - a.usage_count).slice(0, 10);
const data = {
labels: allModels.map(model => model.name),
datasets: [{
label: 'Usage Count',
data: allModels.map(model => model.usage_count),
backgroundColor: allModels.map(model =>
model.type === 'LoRA' ? 'oklch(68% 0.28 256)' : 'oklch(68% 0.28 200)'
)
}]
};
this.charts.distribution = new Chart(ctx, {
type: 'bar',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: {
display: false
}
}
}
});
}
createStorageChart() {
const ctx = document.getElementById('storageChart');
if (!ctx || !this.data.collection) return;
const data = {
labels: ['LoRAs', 'Checkpoints'],
datasets: [{
data: [this.data.collection.lora_size, this.data.collection.checkpoint_size],
backgroundColor: [
'oklch(68% 0.28 256)',
'oklch(68% 0.28 200)'
]
}]
};
this.charts.storage = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom'
},
tooltip: {
callbacks: {
label: (context) => {
const value = this.formatFileSize(context.raw);
return `${context.label}: ${value}`;
}
}
}
}
}
});
}
createStorageEfficiencyChart() {
const ctx = document.getElementById('storageEfficiencyChart');
if (!ctx || !this.data.storage) return;
const loraData = this.data.storage.loras || [];
const checkpointData = this.data.storage.checkpoints || [];
const allData = [
...loraData.map(item => ({ ...item, type: 'LoRA' })),
...checkpointData.map(item => ({ ...item, type: 'Checkpoint' }))
];
const data = {
datasets: [{
label: 'Models',
data: allData.map(item => ({
x: item.size,
y: item.usage_count,
name: item.name,
type: item.type
})),
backgroundColor: allData.map(item =>
item.type === 'LoRA' ? 'oklch(68% 0.28 256 / 0.6)' : 'oklch(68% 0.28 200 / 0.6)'
)
}]
};
this.charts.efficiency = new Chart(ctx, {
type: 'scatter',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: {
display: true,
text: 'File Size (bytes)'
},
type: 'logarithmic'
},
y: {
title: {
display: true,
text: 'Usage Count'
}
}
},
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const point = context.raw;
return `${point.name}: ${this.formatFileSize(point.x)}, ${point.y} uses`;
}
}
}
}
}
});
}
renderTopModelsLists() {
this.renderTopLorasList();
this.renderTopCheckpointsList();
this.renderLargestModelsList();
}
renderTopLorasList() {
const container = document.getElementById('topLorasList');
if (!container || !this.data.usage?.top_loras) return;
const topLoras = this.data.usage.top_loras;
if (topLoras.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
return;
}
container.innerHTML = topLoras.map(lora => `
<div class="model-item">
<img src="${lora.preview_url || '/loras_static/images/no-preview.png'}"
alt="${lora.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${lora.name}">${lora.name}</div>
<div class="model-meta">${lora.base_model}${lora.folder}</div>
</div>
<div class="model-usage">${lora.usage_count}</div>
</div>
`).join('');
}
renderTopCheckpointsList() {
const container = document.getElementById('topCheckpointsList');
if (!container || !this.data.usage?.top_checkpoints) return;
const topCheckpoints = this.data.usage.top_checkpoints;
if (topCheckpoints.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No usage data available</div>';
return;
}
container.innerHTML = topCheckpoints.map(checkpoint => `
<div class="model-item">
<img src="${checkpoint.preview_url || '/loras_static/images/no-preview.png'}"
alt="${checkpoint.name}" class="model-preview"
onerror="this.src='/loras_static/images/no-preview.png'">
<div class="model-info">
<div class="model-name" title="${checkpoint.name}">${checkpoint.name}</div>
<div class="model-meta">${checkpoint.base_model}${checkpoint.folder}</div>
</div>
<div class="model-usage">${checkpoint.usage_count}</div>
</div>
`).join('');
}
renderLargestModelsList() {
const container = document.getElementById('largestModelsList');
if (!container || !this.data.storage) return;
const loraModels = this.data.storage.loras || [];
const checkpointModels = this.data.storage.checkpoints || [];
// Combine and sort by size
const allModels = [
...loraModels.map(m => ({ ...m, type: 'LoRA' })),
...checkpointModels.map(m => ({ ...m, type: 'Checkpoint' }))
].sort((a, b) => b.size - a.size).slice(0, 10);
if (allModels.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No storage data available</div>';
return;
}
container.innerHTML = allModels.map(model => `
<div class="model-item">
<div class="model-info">
<div class="model-name" title="${model.name}">${model.name}</div>
<div class="model-meta">${model.type}${model.base_model}</div>
</div>
<div class="model-usage">${this.formatFileSize(model.size)}</div>
</div>
`).join('');
}
renderTagCloud() {
const container = document.getElementById('tagCloud');
if (!container || !this.data.tags?.top_tags) return;
const topTags = this.data.tags.top_tags.slice(0, 30); // Show top 30 tags
const maxCount = Math.max(...topTags.map(tag => tag.count));
container.innerHTML = topTags.map(tagData => {
const size = Math.ceil((tagData.count / maxCount) * 5);
return `
<span class="tag-cloud-item size-${size}"
title="${tagData.tag}: ${tagData.count} models">
${tagData.tag}
</span>
`;
}).join('');
}
renderInsights() {
const container = document.getElementById('insightsList');
if (!container || !this.data.insights?.insights) return;
const insights = this.data.insights.insights;
if (insights.length === 0) {
container.innerHTML = '<div class="loading-placeholder">No insights available</div>';
return;
}
container.innerHTML = insights.map(insight => `
<div class="insight-card type-${insight.type}">
<div class="insight-title">${insight.title}</div>
<div class="insight-description">${insight.description}</div>
<div class="insight-suggestion">${insight.suggestion}</div>
</div>
`).join('');
// Render collection analysis cards
this.renderCollectionAnalysis();
}
renderCollectionAnalysis() {
const container = document.getElementById('collectionAnalysis');
if (!container || !this.data.collection) return;
const analysis = [
{
icon: 'fas fa-percentage',
value: this.calculateUsageRate(),
label: 'Usage Rate',
format: 'percentage'
},
{
icon: 'fas fa-tags',
value: this.data.tags?.total_unique_tags || 0,
label: 'Unique Tags',
format: 'number'
},
{
icon: 'fas fa-clock',
value: this.data.collection.unused_loras + this.data.collection.unused_checkpoints,
label: 'Unused Models',
format: 'number'
},
{
icon: 'fas fa-chart-line',
value: this.calculateAverageUsage(),
label: 'Avg. Uses/Model',
format: 'decimal'
}
];
container.innerHTML = analysis.map(item => `
<div class="analysis-card">
<div class="card-icon">
<i class="${item.icon}"></i>
</div>
<div class="card-value">${this.formatValue(item.value, item.format)}</div>
<div class="card-label">${item.label}</div>
</div>
`).join('');
}
calculateAverageUsage() {
if (!this.data.usage || !this.data.collection) return 0;
const totalGenerations = this.data.collection.total_generations;
const totalModels = this.data.collection.total_models;
return totalModels > 0 ? totalGenerations / totalModels : 0;
}
showChartPlaceholders() {
const chartCanvases = document.querySelectorAll('canvas');
chartCanvases.forEach(canvas => {
const container = canvas.parentElement;
container.innerHTML = '<div class="loading-placeholder"><i class="fas fa-chart-bar"></i> Chart requires Chart.js library</div>';
});
}
refreshChartsInPanel(panelId) {
// Refresh charts when panels become visible
setTimeout(() => {
Object.values(this.charts).forEach(chart => {
if (chart && typeof chart.resize === 'function') {
chart.resize();
}
});
}, 100);
}
destroy() {
// Clean up charts
Object.values(this.charts).forEach(chart => {
if (chart && typeof chart.destroy === 'function') {
chart.destroy();
}
});
this.charts = {};
this.initialized = false;
}
}
// Initialize statistics page when DOM is ready
document.addEventListener('DOMContentLoaded', async () => {
// Wait for app core to initialize
await appCore.initialize();
// Initialize statistics functionality
const statsManager = new StatisticsManager();
await statsManager.initialize();
// Make statsManager globally available for debugging
window.statsManager = statsManager;
console.log('Statistics page initialized successfully');
});
// Handle page unload
window.addEventListener('beforeunload', () => {
if (window.statsManager) {
window.statsManager.destroy();
}
});

View File

@@ -2,47 +2,6 @@
* Utility functions to update checkpoint cards after modal edits
*/
/**
* Update the Lora card after metadata edits in the modal
* @param {string} filePath - Path to the Lora file
* @param {Object} updates - Object containing the updates (model_name, base_model, notes, usage_tips, etc)
*/
export function updateModelCard(filePath, updates) {
// Find the card with matching filepath
const modelCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (!modelCard) return;
// Update card dataset and visual elements based on the updates object
Object.entries(updates).forEach(([key, value]) => {
// Update dataset
modelCard.dataset[key] = value;
// Update visual elements based on the property
switch(key) {
case 'model_name':
// Update the model name in the card title
const titleElement = modelCard.querySelector('.card-title');
if (titleElement) titleElement.textContent = value;
// Also update the model name in the footer if it exists
const modelNameElement = modelCard.querySelector('.model-name');
if (modelNameElement) modelNameElement.textContent = value;
break;
case 'base_model':
// Update the base model label in the card header if it exists
const baseModelLabel = modelCard.querySelector('.base-model-label');
if (baseModelLabel) {
baseModelLabel.textContent = value;
baseModelLabel.title = value;
}
break;
}
});
return modelCard; // Return the updated card element for chaining
}
/**
* Update the recipe card after metadata edits in the modal
* @param {string} recipeId - ID of the recipe to update

View File

@@ -101,4 +101,25 @@ export const NSFW_LEVELS = {
X: 8,
XXX: 16,
BLOCKED: 32
};
};
// Node type constants
export const NODE_TYPES = {
LORA_LOADER: 1,
LORA_STACKER: 2
};
// Node type names to IDs mapping
export const NODE_TYPE_NAMES = {
"Lora Loader (LoraManager)": NODE_TYPES.LORA_LOADER,
"Lora Stacker (LoraManager)": NODE_TYPES.LORA_STACKER
};
// Node type icons
export const NODE_TYPE_ICONS = {
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
[NODE_TYPES.LORA_STACKER]: "fas fa-s"
};
// Default ComfyUI node color when bgcolor is null
export const DEFAULT_NODE_COLOR = "#353535";

View File

@@ -1,7 +1,7 @@
import { state, getCurrentPageState } from '../state/index.js';
import { VirtualScroller } from './VirtualScroller.js';
import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js';
import { createCheckpointCard } from '../components/CheckpointCard.js';
import { createCheckpointCard, setupCheckpointCardEventDelegation } from '../components/CheckpointCard.js';
import { fetchLorasPage } from '../api/loraApi.js';
import { fetchCheckpointsPage } from '../api/checkpointApi.js';
import { showToast } from './uiHelpers.js';
@@ -68,6 +68,8 @@ export async function initializeInfiniteScroll(pageType = 'loras') {
// Setup event delegation for lora cards if on the loras page
if (pageType === 'loras') {
setupLoraCardEventDelegation();
} else if (pageType === 'checkpoints') {
setupCheckpointCardEventDelegation();
}
}

View File

@@ -40,7 +40,8 @@ export const apiRoutes = {
export const pageRoutes = {
loras: '/loras',
recipes: '/loras/recipes',
checkpoints: '/checkpoints'
checkpoints: '/checkpoints',
statistics: '/statistics'
};
// Helper function to get current page type
@@ -48,6 +49,7 @@ export function getCurrentPageType() {
const path = window.location.pathname;
if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras';
return 'unknown';
}

View File

@@ -1,7 +1,7 @@
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NSFW_LEVELS } from './constants.js';
import { NODE_TYPES, NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
/**
* Utility function to copy text to clipboard with fallback for older browsers
@@ -118,10 +118,13 @@ export function toggleTheme() {
const currentTheme = getStorageItem('theme') || 'auto';
let newTheme;
if (currentTheme === 'dark') {
newTheme = 'light';
} else {
// Cycle through themes: light → dark → auto → light
if (currentTheme === 'light') {
newTheme = 'dark';
} else if (currentTheme === 'dark') {
newTheme = 'auto';
} else {
newTheme = 'light';
}
setStorageItem('theme', newTheme);
@@ -151,6 +154,21 @@ function applyTheme(theme) {
htmlElement.setAttribute('data-theme', 'light');
document.body.dataset.theme = 'light';
}
// Update the theme-toggle icon state
updateThemeToggleIcons(theme);
}
// New function to update theme toggle icons
function updateThemeToggleIcons(theme) {
const themeToggle = document.querySelector('.theme-toggle');
if (!themeToggle) return;
// Remove any existing active classes
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
// Add the appropriate class based on current theme
themeToggle.classList.add(`theme-${theme}`);
}
export function toggleFolder(tag) {
@@ -178,16 +196,14 @@ function filterByFolder(folderPath) {
});
}
export function openCivitai(modelName) {
// 从卡片的data-meta属性中获取civitai ID
const loraCard = document.querySelector(`.lora-card[data-name="${modelName}"]`);
export function openCivitai(filePath) {
const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);
if (!loraCard) return;
const metaData = JSON.parse(loraCard.dataset.meta);
const civitaiId = metaData.modelId; // 使用modelId作为civitai模型ID
const versionId = metaData.id; // 使用id作为版本ID
const civitaiId = metaData.modelId;
const versionId = metaData.id;
// 构建URL
if (civitaiId) {
let url = `https://civitai.com/models/${civitaiId}`;
if (versionId) {
@@ -196,6 +212,7 @@ export function openCivitai(modelName) {
window.open(url, '_blank');
} else {
// 如果没有ID尝试使用名称搜索
const modelName = loraCard.dataset.name;
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
}
}
@@ -246,56 +263,6 @@ export function updatePanelPositions() {
}
}
}
}
// Update the toggleFolderTags function
export function toggleFolderTags() {
const folderTags = document.querySelector('.folder-tags');
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (folderTags) {
folderTags.classList.toggle('collapsed');
if (folderTags.classList.contains('collapsed')) {
// Change icon to indicate folders are hidden
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
setStorageItem('folderTagsCollapsed', 'true');
} else {
// Change icon to indicate folders are visible
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
setStorageItem('folderTagsCollapsed', 'false');
}
// Update panel positions after toggling
// Use a small delay to ensure the DOM has updated
setTimeout(() => {
updatePanelPositions();
}, 50);
}
}
// Add this to your existing initialization code
export function initFolderTagsVisibility() {
const isCollapsed = getStorageItem('folderTagsCollapsed');
if (isCollapsed) {
const folderTags = document.querySelector('.folder-tags');
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (folderTags) {
folderTags.classList.add('collapsed');
}
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-plus';
toggleBtn.parentElement.title = 'Show folder tags';
}
} else {
const toggleBtn = document.querySelector('.toggle-folders-btn i');
if (toggleBtn) {
toggleBtn.className = 'fas fa-folder-minus';
toggleBtn.parentElement.title = 'Hide folder tags';
}
}
}
export function initBackToTop() {
@@ -350,33 +317,53 @@ export function getNSFWLevelName(level) {
*/
export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntaxType = 'lora') {
try {
let loraNodes = [];
let isDesktopMode = false;
// Get registry information from the new endpoint
const registryResponse = await fetch('/api/get-registry');
const registryData = await registryResponse.json();
// Get the current workflow from localStorage
const workflowData = localStorage.getItem('workflow');
if (workflowData) {
// Web browser mode - extract node IDs from workflow
const workflow = JSON.parse(workflowData);
// Find all Lora Loader (LoraManager) nodes
if (workflow.nodes && Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
if (node.type === "Lora Loader (LoraManager)") {
loraNodes.push(node.id);
}
}
}
if (loraNodes.length === 0) {
showToast('No Lora Loader nodes found in the workflow', 'warning');
if (!registryData.success) {
// Handle specific error cases
if (registryData.error === 'Standalone Mode Active') {
// Standalone mode - show warning with specific message
showToast(registryData.message || 'Cannot interact with ComfyUI in standalone mode', 'warning');
return false;
} else {
// Other errors - show error toast
showToast(registryData.message || registryData.error || 'Failed to get workflow information', 'error');
return false;
}
} else {
// ComfyUI Desktop mode - don't specify node IDs and let backend handle it
isDesktopMode = true;
}
// Success case - check node count
if (registryData.data.node_count === 0) {
// No nodes found - show warning
showToast('No Lora Loader or Lora Stacker nodes found in workflow', 'warning');
return false;
} else if (registryData.data.node_count > 1) {
// Multiple nodes - show selector
showNodeSelector(registryData.data.nodes, loraSyntax, replaceMode, syntaxType);
return true;
} else {
// Single node - send directly
const nodeId = Object.keys(registryData.data.nodes)[0];
return await sendToSpecificNode([nodeId], loraSyntax, replaceMode, syntaxType);
}
} catch (error) {
console.error('Failed to get registry:', error);
showToast('Failed to communicate with ComfyUI', 'error');
return false;
}
}
/**
* Send LoRA to specific nodes
* @param {Array|undefined} nodeIds - Array of node IDs or undefined for desktop mode
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
*/
async function sendToSpecificNode(nodeIds, loraSyntax, replaceMode, syntaxType) {
try {
// Call the backend API to update the lora code
const response = await fetch('/api/update-lora-code', {
method: 'POST',
@@ -384,7 +371,7 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
'Content-Type': 'application/json'
},
body: JSON.stringify({
node_ids: isDesktopMode ? undefined : loraNodes,
node_ids: nodeIds,
lora_code: loraSyntax,
mode: replaceMode ? 'replace' : 'append'
})
@@ -411,6 +398,196 @@ export async function sendLoraToWorkflow(loraSyntax, replaceMode = false, syntax
}
}
// Global variable to track active node selector state
let nodeSelectorState = {
isActive: false,
clickHandler: null,
selectorClickHandler: null
};
/**
* Show node selector popup near mouse position
* @param {Object} nodes - Registry nodes data
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
*/
function showNodeSelector(nodes, loraSyntax, replaceMode, syntaxType) {
const selector = document.getElementById('nodeSelector');
if (!selector) return;
// Clean up any existing state
hideNodeSelector();
// Generate node list HTML with icons and proper colors
const nodeItems = Object.values(nodes).map(node => {
const iconClass = NODE_TYPE_ICONS[node.type] || 'fas fa-question-circle';
const bgColor = node.bgcolor || DEFAULT_NODE_COLOR;
return `
<div class="node-item" data-node-id="${node.id}">
<div class="node-icon-indicator" style="background-color: ${bgColor}">
<i class="${iconClass}"></i>
</div>
<span>#${node.id} ${node.title}</span>
</div>
`;
}).join('');
// Add header with action mode indicator
const actionType = syntaxType === 'recipe' ? 'Recipe' : 'LoRA';
const actionMode = replaceMode ? 'Replace' : 'Append';
selector.innerHTML = `
<div class="node-selector-header">
<span class="selector-action-type">${actionMode} ${actionType}</span>
<span class="selector-instruction">Select target node</span>
</div>
${nodeItems}
<div class="node-item send-all-item" data-action="send-all">
<div class="node-icon-indicator all-nodes">
<i class="fas fa-broadcast-tower"></i>
</div>
<span>Send to All</span>
</div>
`;
// Position near mouse
positionNearMouse(selector);
// Show selector
selector.style.display = 'block';
nodeSelectorState.isActive = true;
// Setup event listeners with proper cleanup
setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, syntaxType);
}
/**
* Setup event listeners for node selector
* @param {HTMLElement} selector - The selector element
* @param {Object} nodes - Registry nodes data
* @param {string} loraSyntax - The LoRA syntax to send
* @param {boolean} replaceMode - Whether to replace existing LoRAs
* @param {string} syntaxType - The type of syntax ('lora' or 'recipe')
*/
function setupNodeSelectorEvents(selector, nodes, loraSyntax, replaceMode, syntaxType) {
// Clean up any existing event listeners
cleanupNodeSelectorEvents();
// Handle clicks outside to close
nodeSelectorState.clickHandler = (e) => {
if (!selector.contains(e.target)) {
hideNodeSelector();
}
};
// Handle node selection
nodeSelectorState.selectorClickHandler = async (e) => {
const nodeItem = e.target.closest('.node-item');
if (!nodeItem) return;
e.stopPropagation();
const action = nodeItem.dataset.action;
const nodeId = nodeItem.dataset.nodeId;
if (action === 'send-all') {
// Send to all nodes
const allNodeIds = Object.keys(nodes);
await sendToSpecificNode(allNodeIds, loraSyntax, replaceMode, syntaxType);
} else if (nodeId) {
// Send to specific node
await sendToSpecificNode([nodeId], loraSyntax, replaceMode, syntaxType);
}
hideNodeSelector();
};
// Add event listeners with a small delay to prevent immediate triggering
setTimeout(() => {
if (nodeSelectorState.isActive) {
document.addEventListener('click', nodeSelectorState.clickHandler);
selector.addEventListener('click', nodeSelectorState.selectorClickHandler);
}
}, 100);
}
/**
* Clean up node selector event listeners
*/
function cleanupNodeSelectorEvents() {
if (nodeSelectorState.clickHandler) {
document.removeEventListener('click', nodeSelectorState.clickHandler);
nodeSelectorState.clickHandler = null;
}
if (nodeSelectorState.selectorClickHandler) {
const selector = document.getElementById('nodeSelector');
if (selector) {
selector.removeEventListener('click', nodeSelectorState.selectorClickHandler);
}
nodeSelectorState.selectorClickHandler = null;
}
}
/**
* Hide node selector
*/
function hideNodeSelector() {
const selector = document.getElementById('nodeSelector');
if (selector) {
selector.style.display = 'none';
selector.innerHTML = ''; // Clear content to prevent memory leaks
}
// Clean up event listeners
cleanupNodeSelectorEvents();
nodeSelectorState.isActive = false;
}
/**
* Position element near mouse cursor
* @param {HTMLElement} element - Element to position
*/
function positionNearMouse(element) {
// Get current mouse position from last mouse event or use default
const mouseX = window.lastMouseX || window.innerWidth / 2;
const mouseY = window.lastMouseY || window.innerHeight / 2;
// Show element temporarily to get dimensions
element.style.visibility = 'hidden';
element.style.display = 'block';
const rect = element.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
// Calculate position with offset from mouse
let x = mouseX + 10;
let y = mouseY + 10;
// Ensure element doesn't go offscreen
if (x + rect.width > viewportWidth) {
x = mouseX - rect.width - 10;
}
if (y + rect.height > viewportHeight) {
y = mouseY - rect.height - 10;
}
// Apply position
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.visibility = 'visible';
}
// Track mouse position for node selector positioning
document.addEventListener('mousemove', (e) => {
window.lastMouseX = e.clientX;
window.lastMouseY = e.clientY;
});
/**
* Opens the example images folder for a specific model
* @param {string} modelHash - The SHA256 hash of the model

View File

@@ -56,4 +56,8 @@
<button class="nsfw-level-btn" data-level="16">XXX</button>
</div>
</div>
</div>
<div id="nodeSelector" class="node-selector">
<!-- Dynamic node list will be populated here -->
</div>

View File

@@ -16,10 +16,13 @@
<a href="/checkpoints" class="nav-item" id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> Checkpoints
</a>
<a href="/statistics" class="nav-item" id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> Statistics
</a>
</nav>
<!-- Context-aware search container -->
<div class="header-search">
<div class="header-search" id="headerSearch">
<div class="search-container">
<input type="text" id="searchInput" placeholder="Search..." />
<i class="fas fa-search search-icon"></i>
@@ -39,6 +42,7 @@
<div class="theme-toggle" title="Toggle theme">
<i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i>
<i class="fas fa-adjust auto-icon"></i>
</div>
<div class="settings-toggle" title="Settings">
<i class="fas fa-cog"></i>
@@ -152,6 +156,7 @@
const lorasNavItem = document.getElementById('lorasNavItem');
const recipesNavItem = document.getElementById('recipesNavItem');
const checkpointsNavItem = document.getElementById('checkpointsNavItem');
const statisticsNavItem = document.getElementById('statisticsNavItem');
if (currentPath === '/loras') {
lorasNavItem.classList.add('active');
@@ -159,6 +164,8 @@
recipesNavItem.classList.add('active');
} else if (currentPath === '/checkpoints') {
checkpointsNavItem.classList.add('active');
} else if (currentPath === '/statistics') {
statisticsNavItem.classList.add('active');
}
});
</script>

View File

@@ -226,6 +226,28 @@
<span class="warning-text">Warning: Higher densities may cause performance issues on systems with limited resources.</span>
</div>
</div>
<!-- Add Card Info Display setting -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardInfoDisplay">Card Info Display</label>
</div>
<div class="setting-control select-control">
<select id="cardInfoDisplay" onchange="settingsManager.saveSelectSetting('cardInfoDisplay', 'card_info_display')">
<option value="always">Always Visible</option>
<option value="hover">Reveal on Hover</option>
</select>
</div>
</div>
<div class="input-help">
Choose when to display model information and action buttons:
<ul>
<li><strong>Always Visible:</strong> Headers and footers are always visible</li>
<li><strong>Reveal on Hover:</strong> Headers and footers only appear when hovering over a card</li>
</ul>
</div>
</div>
</div>
<!-- Add Cache Management Section -->
@@ -355,6 +377,16 @@
</a>
</div>
<!-- Patreon Support Section -->
<div class="support-section">
<h3><i class="fab fa-patreon"></i> Become a Patron</h3>
<p>Support ongoing development with monthly contributions:</p>
<a href="https://patreon.com/PixelPawsAI" class="patreon-button" target="_blank">
<i class="fab fa-patreon"></i>
<span>Support on Patreon</span>
</a>
</div>
<!-- New section for Chinese payment methods -->
<div class="support-section">
<h3><i class="fas fa-qrcode"></i> WeChat Support</h3>

View File

@@ -25,6 +25,9 @@
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> Send to Workflow (Replace)</div>
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> View All LoRAs</div>
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> Download Missing LoRAs</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 delete-item" data-action="delete"><i class="fas fa-trash"></i> Delete Recipe</div>
</div>

195
templates/statistics.html Normal file
View File

@@ -0,0 +1,195 @@
{% extends "base.html" %}
{% block title %}Statistics - LoRA Manager{% endblock %}
{% block page_id %}statistics{% endblock %}
{% block preload %}
{% if not is_initializing %}
<link rel="preload" href="/loras_static/js/statistics.js" as="script" crossorigin="anonymous">
{% endif %}
{% endblock %}
{% block head_scripts %}
<!-- Add Chart.js for statistics page -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
{% endblock %}
{% block init_title %}Initializing Statistics{% endblock %}
{% block init_message %}Loading model data and usage statistics. This may take a moment...{% endblock %}
{% block init_check_url %}/api/stats/collection-overview{% endblock %}
{% block content %}
{% if not is_initializing %}
<!-- Key Metrics Panel -->
<div class="metrics-panel">
<div class="metrics-grid" id="metricsGrid">
<!-- Metrics cards will be populated by JavaScript -->
</div>
</div>
<!-- Main Dashboard Content -->
<div class="dashboard-content">
<!-- Navigation Tabs -->
<div class="dashboard-tabs">
<button class="tab-button active" data-tab="overview">
<i class="fas fa-chart-bar"></i> Overview
</button>
<button class="tab-button" data-tab="usage">
<i class="fas fa-chart-line"></i> Usage Analysis
</button>
<button class="tab-button" data-tab="collection">
<i class="fas fa-layer-group"></i> Collection
</button>
<button class="tab-button" data-tab="storage">
<i class="fas fa-hdd"></i> Storage
</button>
<button class="tab-button" data-tab="insights">
<i class="fas fa-lightbulb"></i> Insights
</button>
</div>
<!-- Tab Content Panels -->
<div class="tab-content">
<!-- Overview Tab -->
<div class="tab-panel active" id="overview-panel">
<div class="panel-grid">
<!-- Collection Overview Chart -->
<div class="chart-container">
<h3><i class="fas fa-pie-chart"></i> Collection Overview</h3>
<div class="chart-wrapper">
<canvas id="collectionPieChart"></canvas>
</div>
</div>
<!-- Base Model Distribution -->
<div class="chart-container">
<h3><i class="fas fa-layer-group"></i> Base Model Distribution</h3>
<div class="chart-wrapper">
<canvas id="baseModelChart"></canvas>
</div>
</div>
<!-- Usage Timeline -->
<div class="chart-container full-width">
<h3><i class="fas fa-chart-line"></i> Usage Trends (Last 30 Days)</h3>
<div class="chart-wrapper">
<canvas id="usageTimelineChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Usage Analysis Tab -->
<div class="tab-panel" id="usage-panel">
<div class="panel-grid">
<!-- Top Used LoRAs -->
<div class="list-container">
<h3><i class="fas fa-star"></i> Most Used LoRAs</h3>
<div class="model-list" id="topLorasList">
<!-- List will be populated by JavaScript -->
</div>
</div>
<!-- Top Used Checkpoints -->
<div class="list-container">
<h3><i class="fas fa-check-circle"></i> Most Used Checkpoints</h3>
<div class="model-list" id="topCheckpointsList">
<!-- List will be populated by JavaScript -->
</div>
</div>
<!-- Usage Distribution Chart -->
<div class="chart-container full-width">
<h3><i class="fas fa-chart-bar"></i> Usage Distribution</h3>
<div class="chart-wrapper">
<canvas id="usageDistributionChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Collection Tab -->
<div class="tab-panel" id="collection-panel">
<div class="panel-grid">
<!-- Tag Cloud -->
<div class="chart-container">
<h3><i class="fas fa-tags"></i> Popular Tags</h3>
<div class="tag-cloud" id="tagCloud">
<!-- Tag cloud will be populated by JavaScript -->
</div>
</div>
<!-- Base Model Timeline -->
<div class="chart-container">
<h3><i class="fas fa-history"></i> Model Types</h3>
<div class="chart-wrapper">
<canvas id="modelTypesChart"></canvas>
</div>
</div>
<!-- Collection Growth -->
<div class="chart-container full-width">
<h3><i class="fas fa-chart-area"></i> Collection Analysis</h3>
<div class="analysis-cards" id="collectionAnalysis">
<!-- Analysis cards will be populated by JavaScript -->
</div>
</div>
</div>
</div>
<!-- Storage Tab -->
<div class="tab-panel" id="storage-panel">
<div class="panel-grid">
<!-- Storage by Model Type -->
<div class="chart-container">
<h3><i class="fas fa-database"></i> Storage Usage</h3>
<div class="chart-wrapper">
<canvas id="storageChart"></canvas>
</div>
</div>
<!-- Largest Models -->
<div class="list-container">
<h3><i class="fas fa-weight-hanging"></i> Largest Models</h3>
<div class="model-list" id="largestModelsList">
<!-- List will be populated by JavaScript -->
</div>
</div>
<!-- Storage Efficiency -->
<div class="chart-container full-width">
<h3><i class="fas fa-chart-scatter"></i> Storage vs Usage Efficiency</h3>
<div class="chart-wrapper">
<canvas id="storageEfficiencyChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Insights Tab -->
<div class="tab-panel" id="insights-panel">
<div class="insights-container">
<h3><i class="fas fa-lightbulb"></i> Smart Insights</h3>
<div class="insights-list" id="insightsList">
<!-- Insights will be populated by JavaScript -->
</div>
<!-- Recommendations -->
<div class="recommendations-section">
<h4><i class="fas fa-tasks"></i> Recommendations</h4>
<div class="recommendations-list" id="recommendationsList">
<!-- Recommendations will be populated by JavaScript -->
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block main_script %}
{% if not is_initializing %}
<script type="module" src="/loras_static/js/statistics.js"></script>
{% endif %}
{% endblock %}

View File

@@ -1,11 +1,12 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import {
getLorasWidgetModule,
LORA_PATTERN,
collectActiveLorasFromChain,
updateConnectedTriggerWords
updateConnectedTriggerWords,
chainCallback
} from "./utils.js";
import { api } from "../../scripts/api.js";
import { addLorasWidget } from "./loras_widget.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
@@ -75,14 +76,14 @@ app.registerExtension({
// Standard mode - update a specific node
const node = app.graph.getNodeById(+id);
if (!node || node.comfyClass !== "Lora Loader (LoraManager)") {
if (!node || (node.comfyClass !== "Lora Loader (LoraManager)" && node.comfyClass !== "Lora Stacker (LoraManager)")) {
console.warn("Node not found or not a LoraLoader:", id);
return;
}
this.updateNodeLoraCode(node, loraCode, mode);
},
// Helper method to update a single node's lora code
updateNodeLoraCode(node, loraCode, mode) {
// Update the input widget with new lora code
@@ -107,90 +108,120 @@ app.registerExtension({
inputWidget.callback(inputWidget.value);
}
},
async nodeCreated(node) {
if (node.comfyClass === "Lora Loader (LoraManager)") {
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass == "Lora Loader (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
// Enable widget serialization
node.serialize_widgets = true;
this.serialize_widgets = true;
node.addInput('clip', 'CLIP', {
"shape": 7
this.addInput("clip", "CLIP", {
shape: 7,
});
node.addInput("lora_stack", 'LORA_STACK', {
"shape": 7 // 7 is the shape of the optional input
this.addInput("lora_stack", "LORA_STACK", {
shape: 7, // 7 is the shape of the optional input
});
// Wait for node to be properly initialized
requestAnimationFrame(async () => {
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
// 0 for input widget, 1 for loras widget
const savedValue = node.widgets_values[1];
existingLoras = savedValue || [];
// Restore saved value if exists
let existingLoras = [];
if (this.widgets_values && this.widgets_values.length > 0) {
// 0 for input widget, 1 for loras widget
const savedValue = this.widgets_values[1];
existingLoras = savedValue || [];
}
// Merge the loras data
const mergedLoras = mergeLoras(
this.widgets[0].value,
existingLoras
);
// Add flag to prevent callback loops
let isUpdating = false;
// Get the widget object directly from the returned object
this.lorasWidget = addLorasWidget(
this,
"loras",
{
defaultVal: mergedLoras, // Pass object directly
},
(value) => {
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(this);
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(this, allActiveLoraNames);
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
try {
// Remove loras that are not in the value array
const inputWidget = this.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, clipStrength) => {
return currentLoras.includes(name) ? match : "";
}
);
// Clean up multiple spaces and trim
newText = newText.replace(/\s+/g, " ").trim();
inputWidget.value = newText;
} finally {
isUpdating = false;
}
// Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
// Add flag to prevent callback loops
let isUpdating = false;
// Dynamically load the appropriate widget module
const lorasModule = await getLorasWidgetModule();
const { addLorasWidget } = lorasModule;
// Get the widget object directly from the returned object
const result = addLorasWidget(node, "loras", {
defaultVal: mergedLoras // Pass object directly
}, (value) => {
// Collect all active loras from this node and its input chain
const allActiveLoraNames = collectActiveLorasFromChain(node);
// Update trigger words for connected toggle nodes with the aggregated lora names
updateConnectedTriggerWords(node, allActiveLoraNames);
}
).widget;
// Prevent recursive calls
if (isUpdating) return;
isUpdating = true;
// Update input widget callback
const inputWidget = this.widgets[0];
inputWidget.callback = (value) => {
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, clipStrength) => {
return currentLoras.includes(name) ? match : '';
});
// Clean up multiple spaces and trim
newText = newText.replace(/\s+/g, ' ').trim();
inputWidget.value = newText;
} finally {
isUpdating = false;
}
try {
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
this.lorasWidget.value = mergedLoras;
} finally {
isUpdating = false;
}
};
// Register this node with the backend
this.registerNode = async () => {
try {
await fetch('/api/register-node', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node_id: this.id,
bgcolor: this.bgcolor,
title: this.title,
graph_id: this.graph.id
})
});
node.lorasWidget = result.widget;
} catch (error) {
console.warn('Failed to register node:', error);
}
};
// 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;
}
};
});
// Ensure the node is registered after creation
// Call registration
// setTimeout(() => {
// this.registerNode();
// }, 0);
});
}
},
});

View File

@@ -1,11 +1,12 @@
import { app } from "../../scripts/app.js";
import {
getLorasWidgetModule,
LORA_PATTERN,
getActiveLorasFromNode,
collectActiveLorasFromChain,
updateConnectedTriggerWords
updateConnectedTriggerWords,
chainCallback
} from "./utils.js";
import { addLorasWidget } from "./loras_widget.js";
function mergeLoras(lorasText, lorasArr) {
const result = [];
@@ -39,35 +40,30 @@ function mergeLoras(lorasText, lorasArr) {
app.registerExtension({
name: "LoraManager.LoraStacker",
async nodeCreated(node) {
if (node.comfyClass === "Lora Stacker (LoraManager)") {
// Enable widget serialization
node.serialize_widgets = true;
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeType.comfyClass === "Lora Stacker (LoraManager)") {
chainCallback(nodeType.prototype, "onNodeCreated", async function() {
// Enable widget serialization
this.serialize_widgets = true;
node.addInput("lora_stack", 'LORA_STACK', {
"shape": 7 // 7 is the shape of the optional input
});
this.addInput("lora_stack", 'LORA_STACK', {
"shape": 7 // 7 is the shape of the optional input
});
// Wait for node to be properly initialized
requestAnimationFrame(async () => {
// Restore saved value if exists
let existingLoras = [];
if (node.widgets_values && node.widgets_values.length > 0) {
if (this.widgets_values && this.widgets_values.length > 0) {
// 0 for input widget, 1 for loras widget
const savedValue = node.widgets_values[1];
const savedValue = this.widgets_values[1];
existingLoras = savedValue || [];
}
// Merge the loras data
const mergedLoras = mergeLoras(node.widgets[0].value, existingLoras);
const mergedLoras = mergeLoras(this.widgets[0].value, existingLoras);
// Add flag to prevent callback loops
let isUpdating = false;
// Dynamically load the appropriate widget module
const lorasModule = await getLorasWidgetModule();
const { addLorasWidget } = lorasModule;
const result = addLorasWidget(node, "loras", {
const result = addLorasWidget(this, "loras", {
defaultVal: mergedLoras // Pass object directly
}, (value) => {
// Prevent recursive calls
@@ -76,7 +72,7 @@ app.registerExtension({
try {
// Remove loras that are not in the value array
const inputWidget = node.widgets[0];
const inputWidget = this.widgets[0];
const currentLoras = value.map(l => l.name);
// Use the constant pattern here as well
@@ -96,39 +92,64 @@ app.registerExtension({
activeLoraNames.add(lora.name);
}
});
updateConnectedTriggerWords(node, activeLoraNames);
updateConnectedTriggerWords(this, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(node);
updateDownstreamLoaders(this);
} finally {
isUpdating = false;
}
});
node.lorasWidget = result.widget;
this.lorasWidget = result.widget;
// Update input widget callback
const inputWidget = node.widgets[0];
const inputWidget = this.widgets[0];
inputWidget.callback = (value) => {
if (isUpdating) return;
isUpdating = true;
try {
const currentLoras = node.lorasWidget.value || [];
const currentLoras = this.lorasWidget.value || [];
const mergedLoras = mergeLoras(value, currentLoras);
node.lorasWidget.value = mergedLoras;
this.lorasWidget.value = mergedLoras;
// Update this stacker's direct trigger toggles with its own active loras
const activeLoraNames = getActiveLorasFromNode(node);
updateConnectedTriggerWords(node, activeLoraNames);
const activeLoraNames = getActiveLorasFromNode(this);
updateConnectedTriggerWords(this, activeLoraNames);
// Find all Lora Loader nodes in the chain that might need updates
updateDownstreamLoaders(node);
updateDownstreamLoaders(this);
} finally {
isUpdating = false;
}
};
// Register this node with the backend
this.registerNode = async () => {
try {
await fetch('/api/register-node', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
node_id: this.id,
bgcolor: this.bgcolor,
title: this.title,
graph_id: this.graph.id
})
});
} catch (error) {
console.warn('Failed to register node:', error);
}
};
// Call registration
// setTimeout(() => {
// this.registerNode();
// }, 0);
});
}
},

View File

@@ -601,7 +601,7 @@ export function addLorasWidget(node, name, opts, callback) {
});
// Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 8) * LORA_ENTRY_HEIGHT);
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(totalVisibleEntries, 10) * LORA_ENTRY_HEIGHT);
updateWidgetHeight(container, calculatedHeight, defaultHeight, node);
};

View File

@@ -49,7 +49,7 @@ app.registerExtension({
// Restore saved value if exists
if (node.widgets_values && node.widgets_values.length > 0) {
// 0 is group mode, 1 is default_active, 2 is input, 3 is tag widget, 4 is original message
// 0 is group mode, 1 is default_active, 2 is tag widget, 3 is original message
const savedValue = node.widgets_values[2];
if (savedValue) {
result.widget.value = Array.isArray(savedValue) ? savedValue : [];

View File

@@ -4,15 +4,20 @@ import { api } from "../../scripts/api.js";
// Register the extension
app.registerExtension({
name: "Lora-Manager.UsageStats",
name: "LoraManager.UsageStats",
init() {
setup() {
// Listen for successful executions
api.addEventListener("execution_success", ({ detail }) => {
if (detail && detail.prompt_id) {
this.updateUsageStats(detail.prompt_id);
}
});
// Listen for registry refresh requests
api.addEventListener("lora_registry_refresh", () => {
this.refreshRegistry();
});
},
async updateUsageStats(promptId) {
@@ -32,5 +37,46 @@ app.registerExtension({
} catch (error) {
console.error("Error updating usage statistics:", error);
}
},
async refreshRegistry() {
try {
// Get current workflow nodes
const prompt = await app.graphToPrompt();
const workflow = prompt.workflow;
if (!workflow || !workflow.nodes) {
console.warn("No workflow nodes found for registry refresh");
return;
}
// Find all Lora nodes
const loraNodes = [];
for (const node of workflow.nodes.values()) {
if (node.type === "Lora Loader (LoraManager)" || node.type === "Lora Stacker (LoraManager)") {
loraNodes.push({
node_id: node.id,
bgcolor: node.bgcolor || null,
title: node.title || node.type,
type: node.type
});
}
}
const response = await fetch('/api/register-nodes', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ nodes: loraNodes }),
});
if (!response.ok) {
console.warn("Failed to register Lora nodes:", response.statusText);
} else {
console.log(`Successfully registered ${loraNodes.length} Lora nodes`);
}
} catch (error) {
console.error("Error refreshing registry:", error);
}
}
});

View File

@@ -1,5 +1,23 @@
export const CONVERTED_TYPE = 'converted-widget';
export function chainCallback(object, property, callback) {
if (object == undefined) {
//This should not happen.
console.error("Tried to add callback to non-existant object")
return;
}
if (property in object) {
const callback_orig = object[property]
object[property] = function () {
const r = callback_orig.apply(this, arguments);
callback.apply(this, arguments);
return r
};
} else {
object[property] = callback;
}
}
export function getComfyUIFrontendVersion() {
return window['__COMFYUI_FRONTEND_VERSION__'] || "0.0.0";
}