mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-22 05:32:12 -03:00
Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8e09fcde | ||
|
|
f54d480f03 | ||
|
|
e68b213fb3 | ||
|
|
132334d500 | ||
|
|
a6f04c6d7e | ||
|
|
854e8bf356 | ||
|
|
6ff883d2d3 | ||
|
|
849b97afba | ||
|
|
1bd2635864 | ||
|
|
79ab0f7b6c | ||
|
|
79011bd257 | ||
|
|
c692713ffb | ||
|
|
df9b554ce1 | ||
|
|
277a8e4682 | ||
|
|
acb52dba09 | ||
|
|
8f10765254 | ||
|
|
0653f59473 | ||
|
|
7a4b5a4667 | ||
|
|
49c4a4068b | ||
|
|
40ad590046 | ||
|
|
30374ae3e6 | ||
|
|
ab22d16bad | ||
|
|
971cd56a4a | ||
|
|
d7cb546c5f | ||
|
|
9d8b7344cd | ||
|
|
2d4f6ae7ce | ||
|
|
d9126807b0 | ||
|
|
cad5fb3fba | ||
|
|
afe23ad6b7 | ||
|
|
fc4327087b | ||
|
|
71762d788f | ||
|
|
6472e00fb0 | ||
|
|
4043846767 | ||
|
|
d3b2bc962c | ||
|
|
54f7b64821 | ||
|
|
82a2a6e669 | ||
|
|
6376d60af5 | ||
|
|
b1e2e3831f | ||
|
|
5de1c8aa82 | ||
|
|
63dc5c2bdb | ||
|
|
7f2d1670a0 | ||
|
|
53c8c337fc | ||
|
|
5b4ec1b2a2 | ||
|
|
64dd2ed141 | ||
|
|
eb57e04e95 | ||
|
|
ae905c8630 | ||
|
|
c157e794f0 | ||
|
|
ed9bae6f6a | ||
|
|
9fe1ce19ad | ||
|
|
6148236cbd | ||
|
|
2471eb518a | ||
|
|
8931b41c76 | ||
|
|
7f523f167d | ||
|
|
446b6d6158 | ||
|
|
2ee057e19b | ||
|
|
afc810f21f | ||
|
|
357052a903 | ||
|
|
39d6d8d04a | ||
|
|
888896c0c0 | ||
|
|
ceee482ecc | ||
|
|
d0ed1213d8 | ||
|
|
f6ef428008 | ||
|
|
e726c4f442 | ||
|
|
402318e586 | ||
|
|
b198cc2a6e | ||
|
|
c3dd4da11b | ||
|
|
ba2e42b06e | ||
|
|
fa0902dc74 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,4 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: PixelPawsAI
|
||||
ko_fi: pixelpawsai
|
||||
custom: ['paypal.me/pixelpawsai']
|
||||
|
||||
34
README.md
34
README.md
@@ -18,10 +18,35 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
|
||||
|
||||
[](https://youtu.be/hvKw31YpE-U)
|
||||
|
||||
## 🌐 Browser Extension
|
||||
Enhance your Civitai browsing experience with our companion browser extension! See which models you already have, download new ones with a single click, and manage your downloads efficiently.
|
||||
|
||||

|
||||
|
||||
<div>
|
||||
<a href="https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb" style="display: inline-block; background-color: #4285F4; color: white; padding: 8px 16px; text-decoration: none; border-radius: 4px; font-weight: bold; margin: 10px 0;">
|
||||
<img src="https://www.google.com/chrome/static/images/chrome-logo.svg" width="20" style="vertical-align: middle; margin-right: 8px;"> Get Extension from Chrome Web Store
|
||||
</a>
|
||||
</div>
|
||||
|
||||
📚 [Learn More: Complete Tutorial](https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension))
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
|
||||
### v0.8.20
|
||||
* **LM Civitai Extension** - Released [browser extension through Chrome Web Store](https://chromewebstore.google.com/detail/lm-civitai-extension/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) that works seamlessly with LoRA Manager to enhance Civitai browsing experience, showing which models are already in your local library, enabling one-click downloads, and providing queue and parallel download support
|
||||
* **Enhanced Lora Loader** - Added support for nunchaku, improving convenience when working with ComfyUI-nunchaku workflows, plus new template workflows for quick onboarding
|
||||
* **WanVideo Integration** - Introduced WanVideo Lora Select (LoraManager) node compatible with ComfyUI-WanVideoWrapper for streamlined lora usage in video workflows, including a template workflow to help you get started quickly
|
||||
|
||||
### 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 +126,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 +289,8 @@ If you find this project helpful, consider supporting its development:
|
||||
|
||||
[](https://ko-fi.com/pixelpawsai)
|
||||
|
||||
[](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
|
||||
|
||||
@@ -4,6 +4,7 @@ from .py.nodes.trigger_word_toggle import TriggerWordToggle
|
||||
from .py.nodes.lora_stacker import LoraStacker
|
||||
from .py.nodes.save_image import SaveImage
|
||||
from .py.nodes.debug_metadata import DebugMetadata
|
||||
from .py.nodes.wanvideo_lora_select import WanVideoLoraSelect
|
||||
# Import metadata collector to install hooks on startup
|
||||
from .py.metadata_collector import init as init_metadata_collector
|
||||
|
||||
@@ -12,7 +13,8 @@ NODE_CLASS_MAPPINGS = {
|
||||
TriggerWordToggle.NAME: TriggerWordToggle,
|
||||
LoraStacker.NAME: LoraStacker,
|
||||
SaveImage.NAME: SaveImage,
|
||||
DebugMetadata.NAME: DebugMetadata
|
||||
DebugMetadata.NAME: DebugMetadata,
|
||||
WanVideoLoraSelect.NAME: WanVideoLoraSelect
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web/comfyui"
|
||||
|
||||
BIN
example_workflows/nunchaku-flux.1-dev.jpg
Normal file
BIN
example_workflows/nunchaku-flux.1-dev.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
1
example_workflows/nunchaku-flux.1-dev.json
Normal file
1
example_workflows/nunchaku-flux.1-dev.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
84
py/config.py
84
py/config.py
@@ -22,7 +22,9 @@ class Config:
|
||||
# 静态路由映射字典, target to route mapping
|
||||
self._route_mappings = {}
|
||||
self.loras_roots = self._init_lora_paths()
|
||||
self.checkpoints_roots = self._init_checkpoint_paths()
|
||||
self.checkpoints_roots = None
|
||||
self.unet_roots = None
|
||||
self.base_models_roots = self._init_checkpoint_paths()
|
||||
# 在初始化时扫描符号链接
|
||||
self._scan_symbolic_links()
|
||||
|
||||
@@ -33,34 +35,26 @@ class Config:
|
||||
def save_folder_paths_to_settings(self):
|
||||
"""Save folder paths to settings.json for standalone mode to use later"""
|
||||
try:
|
||||
# Check if we're running in ComfyUI mode (not standalone)
|
||||
if hasattr(folder_paths, "get_folder_paths") and not isinstance(folder_paths, type):
|
||||
# Get all relevant paths
|
||||
lora_paths = folder_paths.get_folder_paths("loras")
|
||||
checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||
diffuser_paths = folder_paths.get_folder_paths("diffusers")
|
||||
unet_paths = folder_paths.get_folder_paths("unet")
|
||||
|
||||
# Load existing settings
|
||||
settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.json')
|
||||
settings = {}
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
|
||||
# Update settings with paths
|
||||
settings['folder_paths'] = {
|
||||
'loras': lora_paths,
|
||||
'checkpoints': checkpoint_paths,
|
||||
'diffusers': diffuser_paths,
|
||||
'unet': unet_paths
|
||||
}
|
||||
|
||||
# Save settings
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
|
||||
logger.info("Saved folder paths to settings.json")
|
||||
# Check if we're running in ComfyUI mode (not standalone)
|
||||
# Load existing settings
|
||||
settings_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'settings.json')
|
||||
settings = {}
|
||||
if os.path.exists(settings_path):
|
||||
with open(settings_path, 'r', encoding='utf-8') as f:
|
||||
settings = json.load(f)
|
||||
|
||||
# Update settings with paths
|
||||
settings['folder_paths'] = {
|
||||
'loras': self.loras_roots,
|
||||
'checkpoints': self.checkpoints_roots,
|
||||
'unet': self.unet_roots,
|
||||
}
|
||||
|
||||
# Save settings
|
||||
with open(settings_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
|
||||
logger.info("Saved folder paths to settings.json")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save folder paths: {e}")
|
||||
|
||||
@@ -86,7 +80,7 @@ class Config:
|
||||
for root in self.loras_roots:
|
||||
self._scan_directory_links(root)
|
||||
|
||||
for root in self.checkpoints_roots:
|
||||
for root in self.base_models_roots:
|
||||
self._scan_directory_links(root)
|
||||
|
||||
def _scan_directory_links(self, root: str):
|
||||
@@ -178,30 +172,36 @@ class Config:
|
||||
try:
|
||||
# Get checkpoint paths from folder_paths
|
||||
checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||
diffusion_paths = folder_paths.get_folder_paths("diffusers")
|
||||
unet_paths = folder_paths.get_folder_paths("unet")
|
||||
|
||||
# Combine all checkpoint-related paths
|
||||
all_paths = checkpoint_paths + diffusion_paths + unet_paths
|
||||
|
||||
# Filter and normalize paths
|
||||
paths = sorted(set(path.replace(os.sep, "/")
|
||||
for path in all_paths
|
||||
# Sort each list individually
|
||||
checkpoint_paths = sorted(set(path.replace(os.sep, "/")
|
||||
for path in checkpoint_paths
|
||||
if os.path.exists(path)), key=lambda p: p.lower())
|
||||
|
||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(paths) if paths else "[]"))
|
||||
unet_paths = sorted(set(path.replace(os.sep, "/")
|
||||
for path in unet_paths
|
||||
if os.path.exists(path)), key=lambda p: p.lower())
|
||||
|
||||
if not paths:
|
||||
# Combine all checkpoint-related paths, ensuring checkpoint_paths are first
|
||||
all_paths = checkpoint_paths + unet_paths
|
||||
|
||||
self.checkpoints_roots = checkpoint_paths
|
||||
self.unet_roots = unet_paths
|
||||
|
||||
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(all_paths) if all_paths else "[]"))
|
||||
|
||||
if not all_paths:
|
||||
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
|
||||
return []
|
||||
|
||||
# 初始化路径映射,与 LoRA 路径处理方式相同
|
||||
for path in paths:
|
||||
# Initialize path mappings, similar to LoRA path handling
|
||||
for path in all_paths:
|
||||
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
|
||||
if real_path != path:
|
||||
self.add_path_mapping(path, real_path)
|
||||
|
||||
return paths
|
||||
return all_paths
|
||||
except Exception as e:
|
||||
logger.warning(f"Error initializing checkpoint paths: {e}")
|
||||
return []
|
||||
|
||||
@@ -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
|
||||
@@ -61,7 +62,7 @@ class LoraManager:
|
||||
added_targets.add(real_root)
|
||||
|
||||
# Add static routes for each checkpoint root
|
||||
for idx, root in enumerate(config.checkpoints_roots, start=1):
|
||||
for idx, root in enumerate(config.base_models_roots, start=1):
|
||||
preview_path = f'/checkpoints_static/root{idx}/preview'
|
||||
|
||||
real_root = root
|
||||
@@ -87,8 +88,8 @@ class LoraManager:
|
||||
for target_path, link_path in config._path_mappings.items():
|
||||
if target_path not in added_targets:
|
||||
# Determine if this is a checkpoint or lora link based on path
|
||||
is_checkpoint = any(cp_root in link_path for cp_root in config.checkpoints_roots)
|
||||
is_checkpoint = is_checkpoint or any(cp_root in target_path for cp_root in config.checkpoints_roots)
|
||||
is_checkpoint = any(cp_root in link_path for cp_root in config.base_models_roots)
|
||||
is_checkpoint = is_checkpoint or any(cp_root in target_path for cp_root in config.base_models_roots)
|
||||
|
||||
if is_checkpoint:
|
||||
route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview'
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -234,16 +239,44 @@ 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
|
||||
# Helper function to recursively find prompt text for a conditioning object
|
||||
def find_prompt_text_for_conditioning(conditioning_obj, is_positive=True):
|
||||
if conditioning_obj is None:
|
||||
return ""
|
||||
|
||||
if pos_conditioning is not None and id(prompt_data["conditioning"]) == id(pos_conditioning):
|
||||
result["prompt"] = prompt_data.get("text", "")
|
||||
# Try to match conditioning objects with those stored by extractors
|
||||
for prompt_node_id, prompt_data in metadata[PROMPTS].items():
|
||||
# For nodes with single conditioning output
|
||||
if "conditioning" in prompt_data:
|
||||
if id(prompt_data["conditioning"]) == id(conditioning_obj):
|
||||
return prompt_data.get("text", "")
|
||||
|
||||
# For nodes with separate pos_conditioning and neg_conditioning outputs (like TSC_EfficientLoader)
|
||||
if is_positive and "positive_encoded" in prompt_data:
|
||||
if id(prompt_data["positive_encoded"]) == id(conditioning_obj):
|
||||
if "positive_text" in prompt_data:
|
||||
return prompt_data["positive_text"]
|
||||
else:
|
||||
orig_conditioning = prompt_data.get("orig_pos_cond", None)
|
||||
if orig_conditioning is not None:
|
||||
# Recursively find the prompt text for the original conditioning
|
||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=True)
|
||||
|
||||
if not is_positive and "negative_encoded" in prompt_data:
|
||||
if id(prompt_data["negative_encoded"]) == id(conditioning_obj):
|
||||
if "negative_text" in prompt_data:
|
||||
return prompt_data["negative_text"]
|
||||
else:
|
||||
orig_conditioning = prompt_data.get("orig_neg_cond", None)
|
||||
if orig_conditioning is not None:
|
||||
# Recursively find the prompt text for the original conditioning
|
||||
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=False)
|
||||
|
||||
if neg_conditioning is not None and id(prompt_data["conditioning"]) == id(neg_conditioning):
|
||||
result["negative_prompt"] = prompt_data.get("text", "")
|
||||
return ""
|
||||
|
||||
# Find prompt texts using the helper function
|
||||
result["prompt"] = find_prompt_text_for_conditioning(pos_conditioning, is_positive=True)
|
||||
result["negative_prompt"] = find_prompt_text_for_conditioning(neg_conditioning, is_positive=False)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -431,18 +569,56 @@ class CFGGuiderExtractor(NodeMetadataExtractor):
|
||||
|
||||
metadata[SAMPLING][node_id]["parameters"]["cfg"] = cfg_value
|
||||
|
||||
class CR_ApplyControlNetStackExtractor(NodeMetadataExtractor):
|
||||
@staticmethod
|
||||
def extract(node_id, inputs, outputs, metadata):
|
||||
if not inputs:
|
||||
return
|
||||
|
||||
# Save the original conditioning inputs
|
||||
base_positive = inputs.get("base_positive")
|
||||
base_negative = inputs.get("base_negative")
|
||||
|
||||
if base_positive is not None or base_negative is not None:
|
||||
if node_id not in metadata[PROMPTS]:
|
||||
metadata[PROMPTS][node_id] = {"node_id": node_id}
|
||||
|
||||
metadata[PROMPTS][node_id]["orig_pos_cond"] = base_positive
|
||||
metadata[PROMPTS][node_id]["orig_neg_cond"] = base_negative
|
||||
|
||||
@staticmethod
|
||||
def update(node_id, outputs, metadata):
|
||||
# Extract transformed conditionings from outputs
|
||||
# outputs structure: [(base_positive, base_negative, show_help, )]
|
||||
if outputs and isinstance(outputs, list) and len(outputs) > 0:
|
||||
first_output = outputs[0]
|
||||
if isinstance(first_output, tuple) and len(first_output) >= 2:
|
||||
transformed_positive = first_output[0]
|
||||
transformed_negative = first_output[1]
|
||||
|
||||
# Save transformed 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"] = transformed_positive
|
||||
metadata[PROMPTS][node_id]["negative_encoded"] = transformed_negative
|
||||
|
||||
# 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 +627,9 @@ NODE_EXTRACTORS = {
|
||||
"CLIPTextEncode": CLIPTextEncodeExtractor,
|
||||
"CLIPTextEncodeFlux": CLIPTextEncodeFluxExtractor, # Add CLIPTextEncodeFlux
|
||||
"WAS_Text_to_Conditioning": CLIPTextEncodeExtractor,
|
||||
"AdvancedCLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/BlenderNeko/ComfyUI_ADV_CLIP_emb
|
||||
"smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes
|
||||
"CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack
|
||||
# Latent
|
||||
"EmptyLatentImage": ImageSizeExtractor,
|
||||
# Flux
|
||||
|
||||
@@ -2,14 +2,15 @@ import logging
|
||||
from nodes import LoraLoader
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
import asyncio
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_lora_info, extract_lora_name, get_loras_list
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list, nunchaku_load_lora
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LoraManagerLoader:
|
||||
NAME = "Lora Loader (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
@@ -37,19 +38,39 @@ class LoraManagerLoader:
|
||||
|
||||
clip = kwargs.get('clip', None)
|
||||
lora_stack = kwargs.get('lora_stack', None)
|
||||
|
||||
# Check if model is a Nunchaku Flux model - simplified approach
|
||||
is_nunchaku_model = False
|
||||
|
||||
try:
|
||||
model_wrapper = model.model.diffusion_model
|
||||
# Check if model is a Nunchaku Flux model using only class name
|
||||
if model_wrapper.__class__.__name__ == "ComfyFluxWrapper":
|
||||
is_nunchaku_model = True
|
||||
logger.info("Detected Nunchaku Flux model")
|
||||
except (AttributeError, TypeError):
|
||||
# Not a model with the expected structure
|
||||
pass
|
||||
|
||||
# First process lora_stack if available
|
||||
if lora_stack:
|
||||
for lora_path, model_strength, clip_strength in lora_stack:
|
||||
# Apply the LoRA using the provided path and strengths
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||
# Apply the LoRA using the appropriate loader
|
||||
if is_nunchaku_model:
|
||||
# Use our custom function for Flux models
|
||||
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||
# clip remains unchanged for Nunchaku models
|
||||
else:
|
||||
# Use default loader for standard models
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||
|
||||
# Extract lora name for trigger words lookup
|
||||
lora_name = extract_lora_name(lora_path)
|
||||
_, trigger_words = asyncio.run(get_lora_info(lora_name))
|
||||
|
||||
all_trigger_words.extend(trigger_words)
|
||||
# Add clip strength to output if different from model strength
|
||||
if abs(model_strength - clip_strength) > 0.001:
|
||||
# Add clip strength to output if different from model strength (except for Nunchaku models)
|
||||
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
@@ -68,11 +89,17 @@ class LoraManagerLoader:
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = asyncio.run(get_lora_info(lora_name))
|
||||
|
||||
# Apply the LoRA using the resolved path with separate strengths
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||
# Apply the LoRA using the appropriate loader
|
||||
if is_nunchaku_model:
|
||||
# For Nunchaku models, use our custom function
|
||||
model = nunchaku_load_lora(model, lora_path, model_strength)
|
||||
# clip remains unchanged
|
||||
else:
|
||||
# Use default loader for standard models
|
||||
model, clip = LoraLoader().load_lora(model, clip, lora_path, model_strength, clip_strength)
|
||||
|
||||
# Include clip strength in output if different from model strength
|
||||
if abs(model_strength - clip_strength) > 0.001:
|
||||
# Include clip strength in output if different from model strength and not a Nunchaku model
|
||||
if not is_nunchaku_model and abs(model_strength - clip_strength) > 0.001:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength},{clip_strength}")
|
||||
else:
|
||||
loaded_loras.append(f"{lora_name}: {model_strength}")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
from ..services.lora_scanner import LoraScanner
|
||||
from ..config import config
|
||||
import asyncio
|
||||
import os
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_lora_info, extract_lora_name, get_loras_list
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, extract_lora_name, get_loras_list
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,31 +35,11 @@ any_type = AnyType("*")
|
||||
# Common methods extracted from lora_loader.py and lora_stacker.py
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from ..services.lora_scanner import LoraScanner
|
||||
from ..config import config
|
||||
import copy
|
||||
import folder_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_lora_info(lora_name):
|
||||
"""Get the lora path and trigger words from cache"""
|
||||
scanner = await LoraScanner.get_instance()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
file_path = item.get('file_path')
|
||||
if file_path:
|
||||
for root in config.loras_roots:
|
||||
root = root.replace(os.sep, '/')
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(os.sep, '/')
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get('civitai', {})
|
||||
trigger_words = civitai.get('trainedWords', []) if civitai else []
|
||||
return relative_path, trigger_words
|
||||
return lora_name, [] # Fallback if not found
|
||||
|
||||
def extract_lora_name(lora_path):
|
||||
"""Extract the lora name from a lora path (e.g., 'IL\\aorunIllstrious.safetensors' -> 'aorunIllstrious')"""
|
||||
# Get the basename without extension
|
||||
@@ -81,4 +61,70 @@ def get_loras_list(kwargs):
|
||||
# Unexpected format
|
||||
else:
|
||||
logger.warning(f"Unexpected loras format: {type(loras_data)}")
|
||||
return []
|
||||
return []
|
||||
|
||||
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
|
||||
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
|
||||
import safetensors.torch
|
||||
|
||||
state_dict = {}
|
||||
with safetensors.torch.safe_open(path, framework="pt", device=device) as f:
|
||||
for k in f.keys():
|
||||
if filter_prefix and not k.startswith(filter_prefix):
|
||||
continue
|
||||
state_dict[k.removeprefix(filter_prefix)] = f.get_tensor(k)
|
||||
return state_dict
|
||||
|
||||
def to_diffusers(input_lora):
|
||||
"""Simplified version of to_diffusers for Flux LoRA conversion"""
|
||||
import torch
|
||||
from diffusers.utils.state_dict_utils import convert_unet_state_dict_to_peft
|
||||
from diffusers.loaders import FluxLoraLoaderMixin
|
||||
|
||||
if isinstance(input_lora, str):
|
||||
tensors = load_state_dict_in_safetensors(input_lora, device="cpu")
|
||||
else:
|
||||
tensors = {k: v for k, v in input_lora.items()}
|
||||
|
||||
# Convert FP8 tensors to BF16
|
||||
for k, v in tensors.items():
|
||||
if v.dtype not in [torch.float64, torch.float32, torch.bfloat16, torch.float16]:
|
||||
tensors[k] = v.to(torch.bfloat16)
|
||||
|
||||
new_tensors = FluxLoraLoaderMixin.lora_state_dict(tensors)
|
||||
new_tensors = convert_unet_state_dict_to_peft(new_tensors)
|
||||
|
||||
return new_tensors
|
||||
|
||||
def nunchaku_load_lora(model, lora_name, lora_strength):
|
||||
"""Load a Flux LoRA for Nunchaku model"""
|
||||
model_wrapper = model.model.diffusion_model
|
||||
transformer = model_wrapper.model
|
||||
|
||||
# Save the transformer temporarily
|
||||
model_wrapper.model = None
|
||||
ret_model = copy.deepcopy(model) # copy everything except the model
|
||||
ret_model_wrapper = ret_model.model.diffusion_model
|
||||
|
||||
# Restore the model and set it for the copy
|
||||
model_wrapper.model = transformer
|
||||
ret_model_wrapper.model = transformer
|
||||
|
||||
# Get full path to the LoRA file
|
||||
lora_path = folder_paths.get_full_path("loras", lora_name)
|
||||
ret_model_wrapper.loras.append((lora_path, lora_strength))
|
||||
|
||||
# Convert the LoRA to diffusers format
|
||||
sd = to_diffusers(lora_path)
|
||||
|
||||
# Handle embedding adjustment if needed
|
||||
if "transformer.x_embedder.lora_A.weight" in sd:
|
||||
new_in_channels = sd["transformer.x_embedder.lora_A.weight"].shape[1]
|
||||
assert new_in_channels % 4 == 0
|
||||
new_in_channels = new_in_channels // 4
|
||||
|
||||
old_in_channels = ret_model.model.model_config.unet_config["in_channels"]
|
||||
if old_in_channels < new_in_channels:
|
||||
ret_model.model.model_config.unet_config["in_channels"] = new_in_channels
|
||||
|
||||
return ret_model
|
||||
93
py/nodes/wanvideo_lora_select.py
Normal file
93
py/nodes/wanvideo_lora_select.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from comfy.comfy_types import IO # type: ignore
|
||||
import asyncio
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WanVideoLoraSelect:
|
||||
NAME = "WanVideo Lora Select (LoraManager)"
|
||||
CATEGORY = "Lora Manager/stackers"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"low_mem_load": ("BOOLEAN", {"default": False, "tooltip": "Load the LORA model with less VRAM usage, slower loading"}),
|
||||
"text": (IO.STRING, {
|
||||
"multiline": True,
|
||||
"dynamicPrompts": True,
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
"placeholder": "LoRA syntax input: <lora:name:strength>"
|
||||
}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WANVIDLORA", IO.STRING, IO.STRING)
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
FUNCTION = "process_loras"
|
||||
|
||||
def process_loras(self, text, low_mem_load=False, **kwargs):
|
||||
loras_list = []
|
||||
all_trigger_words = []
|
||||
active_loras = []
|
||||
|
||||
# Process existing prev_lora if available
|
||||
prev_lora = kwargs.get('prev_lora', None)
|
||||
if prev_lora is not None:
|
||||
loras_list.extend(prev_lora)
|
||||
|
||||
# Get blocks if available
|
||||
blocks = kwargs.get('blocks', {})
|
||||
selected_blocks = blocks.get("selected_blocks", {})
|
||||
layer_filter = blocks.get("layer_filter", "")
|
||||
|
||||
# Process loras from kwargs with support for both old and new formats
|
||||
loras_from_widget = get_loras_list(kwargs)
|
||||
for lora in loras_from_widget:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
lora_name = lora['name']
|
||||
model_strength = float(lora['strength'])
|
||||
clip_strength = float(lora.get('clipStrength', model_strength))
|
||||
|
||||
# Get lora path and trigger words
|
||||
lora_path, trigger_words = asyncio.run(get_lora_info(lora_name))
|
||||
|
||||
# Create lora item for WanVideo format
|
||||
lora_item = {
|
||||
"path": folder_paths.get_full_path("loras", lora_path),
|
||||
"strength": model_strength,
|
||||
"name": lora_path.split(".")[0],
|
||||
"blocks": selected_blocks,
|
||||
"layer_filter": layer_filter,
|
||||
"low_mem_load": low_mem_load,
|
||||
}
|
||||
|
||||
# Add to list and collect active loras
|
||||
loras_list.append(lora_item)
|
||||
active_loras.append((lora_name, model_strength, clip_strength))
|
||||
|
||||
# Add trigger words to collection
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# Format trigger_words for output
|
||||
trigger_words_text = ",, ".join(all_trigger_words) if all_trigger_words else ""
|
||||
|
||||
# Format active_loras for output
|
||||
formatted_loras = []
|
||||
for name, model_strength, clip_strength in active_loras:
|
||||
if abs(model_strength - clip_strength) > 0.001:
|
||||
# Different model and clip strengths
|
||||
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}:{str(clip_strength).strip()}>")
|
||||
else:
|
||||
# Same strength for both
|
||||
formatted_loras.append(f"<lora:{name}:{str(model_strength).strip()}>")
|
||||
|
||||
active_loras_text = " ".join(formatted_loras)
|
||||
|
||||
return (loras_list, trigger_words_text, active_loras_text)
|
||||
@@ -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', '')
|
||||
|
||||
@@ -19,7 +19,7 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
LORA_HASHES_REGEX = r', Lora hashes:\s*"([^"]+)"'
|
||||
CIVITAI_RESOURCES_REGEX = r', Civitai resources:\s*(\[\{.*?\}\])'
|
||||
CIVITAI_METADATA_REGEX = r', Civitai metadata:\s*(\{.*?\})'
|
||||
EXTRANETS_REGEX = r'<(lora|hypernet):([a-zA-Z0-9_\.\-]+):([0-9.]+)>'
|
||||
EXTRANETS_REGEX = r'<(lora|hypernet):([^:]+):(-?[0-9.]+)>'
|
||||
MODEL_HASH_PATTERN = r'Model hash: ([a-zA-Z0-9]+)'
|
||||
VAE_HASH_PATTERN = r'VAE hash: ([a-zA-Z0-9]+)'
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -50,6 +50,9 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
'from_civitai_image': True
|
||||
}
|
||||
|
||||
# Track already added LoRAs to prevent duplicates
|
||||
added_loras = {} # key: model_version_id or hash, value: index in result["loras"]
|
||||
|
||||
# Extract prompt and negative prompt
|
||||
if "prompt" in metadata:
|
||||
result["gen_params"]["prompt"] = metadata["prompt"]
|
||||
@@ -96,11 +99,17 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
for resource in metadata["resources"]:
|
||||
# Modified to process resources without a type field as potential LoRAs
|
||||
if resource.get("type", "lora") == "lora":
|
||||
lora_hash = resource.get("hash", "")
|
||||
|
||||
# Skip if we've already added this LoRA by hash
|
||||
if lora_hash and lora_hash in added_loras:
|
||||
continue
|
||||
|
||||
lora_entry = {
|
||||
'name': resource.get("name", "Unknown LoRA"),
|
||||
'type': "lora",
|
||||
'weight': float(resource.get("weight", 1.0)),
|
||||
'hash': resource.get("hash", ""),
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': resource.get("name", "Unknown"),
|
||||
@@ -114,7 +123,6 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
# Try to get info from Civitai if hash is available
|
||||
if lora_entry['hash'] and civitai_client:
|
||||
try:
|
||||
lora_hash = lora_entry['hash']
|
||||
civitai_info = await civitai_client.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
@@ -129,43 +137,124 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
lora_entry = populated_entry
|
||||
|
||||
# If we have a version ID from Civitai, track it for deduplication
|
||||
if 'id' in lora_entry and lora_entry['id']:
|
||||
added_loras[str(lora_entry['id'])] = len(result["loras"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA hash {lora_entry['hash']}: {e}")
|
||||
|
||||
# Track by hash if we have it
|
||||
if lora_hash:
|
||||
added_loras[lora_hash] = len(result["loras"])
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process civitaiResources array
|
||||
if "civitaiResources" in metadata and isinstance(metadata["civitaiResources"], list):
|
||||
for resource in metadata["civitaiResources"]:
|
||||
# Modified to process resources without a type field as potential LoRAs
|
||||
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,
|
||||
'name': resource.get("modelName", "Unknown LoRA"),
|
||||
'version': resource.get("modelVersionName", ""),
|
||||
'type': resource.get("type", "lora"),
|
||||
'weight': round(float(resource.get("weight", 1.0)), 2),
|
||||
'existsLocally': False,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
# Skip resources that aren't LoRAs or LyCORIS
|
||||
if resource.get("type") not in ["lora", "lycoris"] and "type" not in resource:
|
||||
continue
|
||||
|
||||
# Try to get info from Civitai if modelVersionId is available
|
||||
if resource.get('modelVersionId') and civitai_client:
|
||||
try:
|
||||
version_id = str(resource.get('modelVersionId'))
|
||||
# Use get_model_version_info instead of get_model_version
|
||||
civitai_info, error = await civitai_client.get_model_version_info(version_id)
|
||||
|
||||
if error:
|
||||
logger.warning(f"Error getting model version info: {error}")
|
||||
continue
|
||||
# Get unique identifier for deduplication
|
||||
version_id = str(resource.get("modelVersionId", ""))
|
||||
|
||||
# Skip if we've already added this LoRA
|
||||
if version_id and version_id in added_loras:
|
||||
continue
|
||||
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
'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"),
|
||||
'weight': round(float(resource.get("weight", 1.0)), 2),
|
||||
'existsLocally': False,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Try to get info from Civitai if modelVersionId is available
|
||||
if version_id and civitai_client:
|
||||
try:
|
||||
# Use get_model_version_info instead of get_model_version
|
||||
civitai_info, error = await civitai_client.get_model_version_info(version_id)
|
||||
|
||||
if error:
|
||||
logger.warning(f"Error getting model version info: {error}")
|
||||
continue
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for model version {version_id}: {e}")
|
||||
|
||||
# Track this LoRA in our deduplication dict
|
||||
if version_id:
|
||||
added_loras[version_id] = len(result["loras"])
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process additionalResources array
|
||||
if "additionalResources" in metadata and isinstance(metadata["additionalResources"], list):
|
||||
for resource in metadata["additionalResources"]:
|
||||
# Skip resources that aren't LoRAs or LyCORIS
|
||||
if resource.get("type") not in ["lora", "lycoris"] and "type" not in resource:
|
||||
continue
|
||||
|
||||
lora_type = resource.get("type", "lora")
|
||||
name = resource.get("name", "")
|
||||
|
||||
# Extract ID from URN format if available
|
||||
version_id = None
|
||||
if name and "civitai:" in name:
|
||||
parts = name.split("@")
|
||||
if len(parts) > 1:
|
||||
version_id = parts[1]
|
||||
|
||||
# Skip if we've already added this LoRA
|
||||
if version_id in added_loras:
|
||||
continue
|
||||
|
||||
lora_entry = {
|
||||
'name': name,
|
||||
'type': lora_type,
|
||||
'weight': float(resource.get("strength", 1.0)),
|
||||
'hash': "",
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# If we have a version ID and civitai client, try to get more info
|
||||
if version_id and civitai_client:
|
||||
try:
|
||||
# Use get_model_version_info with the version ID
|
||||
civitai_info, error = await civitai_client.get_model_version_info(version_id)
|
||||
|
||||
if error:
|
||||
logger.warning(f"Error getting model version info: {error}")
|
||||
else:
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
@@ -177,64 +266,13 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for model version {resource.get('modelVersionId')}: {e}")
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# Process additionalResources array
|
||||
if "additionalResources" in metadata and isinstance(metadata["additionalResources"], list):
|
||||
for resource in metadata["additionalResources"]:
|
||||
# Modified to process resources without a type field as potential LoRAs
|
||||
if resource.get("type") in ["lora", "lycoris"] or "type" not in resource:
|
||||
lora_type = resource.get("type", "lora")
|
||||
name = resource.get("name", "")
|
||||
|
||||
# Extract ID from URN format if available
|
||||
model_id = None
|
||||
if name and "civitai:" in name:
|
||||
parts = name.split("@")
|
||||
if len(parts) > 1:
|
||||
model_id = parts[1]
|
||||
|
||||
lora_entry = {
|
||||
'name': name,
|
||||
'type': lora_type,
|
||||
'weight': float(resource.get("strength", 1.0)),
|
||||
'hash': "",
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# If we have a model ID and civitai client, try to get more info
|
||||
if model_id and civitai_client:
|
||||
try:
|
||||
# Use get_model_version_info with the model ID
|
||||
civitai_info, error = await civitai_client.get_model_version_info(model_id)
|
||||
|
||||
if error:
|
||||
logger.warning(f"Error getting model version info: {error}")
|
||||
else:
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts
|
||||
)
|
||||
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for model ID {model_id}: {e}")
|
||||
|
||||
# Track this LoRA for deduplication
|
||||
if version_id:
|
||||
added_loras[version_id] = len(result["loras"])
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for model ID {version_id}: {e}")
|
||||
|
||||
result["loras"].append(lora_entry)
|
||||
|
||||
# If base model wasn't found earlier, use the most common one from LoRAs
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Dict
|
||||
from server import PromptServer # type: ignore
|
||||
|
||||
from ..utils.routes_common import ModelRouteUtils
|
||||
from ..nodes.utils import get_lora_info
|
||||
from ..utils.utils import get_lora_info
|
||||
|
||||
from ..config import config
|
||||
from ..services.websocket_manager import ws_manager
|
||||
@@ -50,13 +50,14 @@ class ApiRoutes:
|
||||
app.router.add_get('/api/loras', routes.get_loras)
|
||||
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
|
||||
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
|
||||
app.router.add_get('/ws/download-progress', ws_manager.handle_download_connection) # Add new WebSocket route for download progress
|
||||
app.router.add_get('/ws/init-progress', ws_manager.handle_init_connection) # Add new WebSocket route
|
||||
app.router.add_get('/api/lora-roots', routes.get_lora_roots)
|
||||
app.router.add_get('/api/folders', routes.get_folders)
|
||||
app.router.add_get('/api/civitai/versions/{model_id}', routes.get_civitai_versions)
|
||||
app.router.add_get('/api/civitai/model/version/{modelVersionId}', routes.get_civitai_model_by_version)
|
||||
app.router.add_get('/api/civitai/model/hash/{hash}', routes.get_civitai_model_by_hash)
|
||||
app.router.add_post('/api/download-lora', routes.download_lora)
|
||||
app.router.add_post('/api/download-model', routes.download_model)
|
||||
app.router.add_post('/api/move_model', routes.move_model)
|
||||
app.router.add_get('/api/lora-model-description', routes.get_lora_model_description) # Add new route
|
||||
app.router.add_post('/api/loras/save-metadata', routes.save_metadata)
|
||||
@@ -65,7 +66,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
|
||||
@@ -436,69 +437,8 @@ class ApiRoutes:
|
||||
"error": str(e)
|
||||
}, status=500)
|
||||
|
||||
async def download_lora(self, request: web.Request) -> web.Response:
|
||||
async with self._download_lock:
|
||||
try:
|
||||
if self.download_manager is None:
|
||||
self.download_manager = await ServiceRegistry.get_download_manager()
|
||||
|
||||
data = await request.json()
|
||||
|
||||
# Create progress callback
|
||||
async def progress_callback(progress):
|
||||
await ws_manager.broadcast({
|
||||
'status': 'progress',
|
||||
'progress': progress
|
||||
})
|
||||
|
||||
# Check which identifier is provided
|
||||
download_url = data.get('download_url')
|
||||
model_hash = data.get('model_hash')
|
||||
model_version_id = data.get('model_version_id')
|
||||
|
||||
# Validate that at least one identifier is provided
|
||||
if not any([download_url, model_hash, model_version_id]):
|
||||
return web.Response(
|
||||
status=400,
|
||||
text="Missing required parameter: Please provide either 'download_url', 'hash', or 'modelVersionId'"
|
||||
)
|
||||
|
||||
result = await self.download_manager.download_from_civitai(
|
||||
download_url=download_url,
|
||||
model_hash=model_hash,
|
||||
model_version_id=model_version_id,
|
||||
save_dir=data.get('lora_root'),
|
||||
relative_path=data.get('relative_path'),
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
if not result.get('success', False):
|
||||
error_message = result.get('error', 'Unknown error')
|
||||
|
||||
# Return 401 for early access errors
|
||||
if 'early access' in error_message.lower():
|
||||
logger.warning(f"Early access download failed: {error_message}")
|
||||
return web.Response(
|
||||
status=401, # Use 401 status code to match Civitai's response
|
||||
text=error_message
|
||||
)
|
||||
|
||||
return web.Response(status=500, text=error_message)
|
||||
|
||||
return web.json_response(result)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
|
||||
# Check if this might be an early access error
|
||||
if '401' in error_message:
|
||||
logger.warning(f"Early access error (401): {error_message}")
|
||||
return web.Response(
|
||||
status=401,
|
||||
text="Early Access Restriction: This LoRA requires purchase. Please buy early access on Civitai.com."
|
||||
)
|
||||
|
||||
logger.error(f"Error downloading LoRA: {error_message}")
|
||||
return web.Response(status=500, text=error_message)
|
||||
async def download_model(self, request: web.Request) -> web.Response:
|
||||
return await ModelRouteUtils.handle_download_model(request, self.download_manager)
|
||||
|
||||
|
||||
async def move_model(self, request: web.Request) -> web.Response:
|
||||
@@ -792,9 +732,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 +813,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"""
|
||||
|
||||
@@ -54,11 +54,8 @@ class CheckpointsRoutes:
|
||||
app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai)
|
||||
app.router.add_post('/api/checkpoints/relink-civitai', self.relink_civitai) # Add new relink endpoint
|
||||
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
|
||||
|
||||
# Add new WebSocket endpoint for checkpoint progress
|
||||
app.router.add_get('/ws/checkpoint-progress', ws_manager.handle_checkpoint_connection)
|
||||
app.router.add_post('/api/checkpoints/rename', self.rename_checkpoint) # Add new rename endpoint
|
||||
|
||||
# Add new routes for finding duplicates and filename conflicts
|
||||
app.router.add_get('/api/checkpoints/find-duplicates', self.find_duplicate_checkpoints)
|
||||
@@ -541,74 +538,6 @@ class CheckpointsRoutes:
|
||||
"""Handle preview image replacement for checkpoints"""
|
||||
return await ModelRouteUtils.handle_replace_preview(request, self.scanner)
|
||||
|
||||
async def download_checkpoint(self, request: web.Request) -> web.Response:
|
||||
"""Handle checkpoint download request"""
|
||||
async with self._download_lock:
|
||||
# Get the download manager from service registry if not already initialized
|
||||
if self.download_manager is None:
|
||||
self.download_manager = await ServiceRegistry.get_download_manager()
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
|
||||
# Create progress callback that uses checkpoint-specific WebSocket
|
||||
async def progress_callback(progress):
|
||||
await ws_manager.broadcast_checkpoint_progress({
|
||||
'status': 'progress',
|
||||
'progress': progress
|
||||
})
|
||||
|
||||
# Check which identifier is provided
|
||||
download_url = data.get('download_url')
|
||||
model_hash = data.get('model_hash')
|
||||
model_version_id = data.get('model_version_id')
|
||||
|
||||
# Validate that at least one identifier is provided
|
||||
if not any([download_url, model_hash, model_version_id]):
|
||||
return web.Response(
|
||||
status=400,
|
||||
text="Missing required parameter: Please provide either 'download_url', 'hash', or 'modelVersionId'"
|
||||
)
|
||||
|
||||
result = await self.download_manager.download_from_civitai(
|
||||
download_url=download_url,
|
||||
model_hash=model_hash,
|
||||
model_version_id=model_version_id,
|
||||
save_dir=data.get('checkpoint_root'),
|
||||
relative_path=data.get('relative_path', ''),
|
||||
progress_callback=progress_callback,
|
||||
model_type="checkpoint"
|
||||
)
|
||||
|
||||
if not result.get('success', False):
|
||||
error_message = result.get('error', 'Unknown error')
|
||||
|
||||
# Return 401 for early access errors
|
||||
if 'early access' in error_message.lower():
|
||||
logger.warning(f"Early access download failed: {error_message}")
|
||||
return web.Response(
|
||||
status=401,
|
||||
text=f"Early Access Restriction: {error_message}"
|
||||
)
|
||||
|
||||
return web.Response(status=500, text=error_message)
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
|
||||
# Check if this might be an early access error
|
||||
if '401' in error_message:
|
||||
logger.warning(f"Early access error (401): {error_message}")
|
||||
return web.Response(
|
||||
status=401,
|
||||
text="Early Access Restriction: This model requires purchase. Please ensure you have purchased early access and are logged in to Civitai."
|
||||
)
|
||||
|
||||
logger.error(f"Error downloading checkpoint: {error_message}")
|
||||
return web.Response(status=500, text=error_message)
|
||||
|
||||
async def get_checkpoint_roots(self, request):
|
||||
"""Return the checkpoint root directories"""
|
||||
try:
|
||||
@@ -836,3 +765,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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import logging
|
||||
from ..utils.example_images_download_manager import DownloadManager
|
||||
from ..utils.example_images_processor import ExampleImagesProcessor
|
||||
from ..utils.example_images_metadata import MetadataUpdater
|
||||
from ..utils.example_images_file_manager import ExampleImagesFileManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1,31 +1,84 @@
|
||||
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
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
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"""
|
||||
@@ -38,6 +91,8 @@ class MiscRoutes:
|
||||
# Add new route for clearing cache
|
||||
app.router.add_post('/api/clear-cache', MiscRoutes.clear_cache)
|
||||
|
||||
app.router.add_get('/api/health-check', lambda request: web.json_response({'status': 'ok'}))
|
||||
|
||||
# Usage stats routes
|
||||
app.router.add_post('/api/update-usage-stats', MiscRoutes.update_usage_stats)
|
||||
app.router.add_get('/api/get-usage-stats', MiscRoutes.get_usage_stats)
|
||||
@@ -50,6 +105,13 @@ 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)
|
||||
|
||||
# Add new route for checking if a model exists in the library
|
||||
app.router.add_get('/api/check-model-exists', MiscRoutes.check_model_exists)
|
||||
|
||||
@staticmethod
|
||||
async def clear_cache(request):
|
||||
@@ -83,10 +145,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 +461,231 @@ 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)
|
||||
|
||||
@staticmethod
|
||||
async def check_model_exists(request):
|
||||
"""
|
||||
Check if a model with specified modelId and optionally modelVersionId exists in the library
|
||||
|
||||
Expects query parameters:
|
||||
- modelId: int - Civitai model ID (required)
|
||||
- modelVersionId: int - Civitai model version ID (optional)
|
||||
|
||||
Returns:
|
||||
- If modelVersionId is provided: JSON with a boolean 'exists' field
|
||||
- If modelVersionId is not provided: JSON with a list of modelVersionIds that exist in the library
|
||||
"""
|
||||
try:
|
||||
# Get the modelId and modelVersionId from query parameters
|
||||
model_id_str = request.query.get('modelId')
|
||||
model_version_id_str = request.query.get('modelVersionId')
|
||||
|
||||
# Validate modelId parameter (required)
|
||||
if not model_id_str:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Missing required parameter: modelId'
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
# Convert modelId to integer
|
||||
model_id = int(model_id_str)
|
||||
except ValueError:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Parameter modelId must be an integer'
|
||||
}, status=400)
|
||||
|
||||
# Get both lora and checkpoint scanners
|
||||
registry = ServiceRegistry.get_instance()
|
||||
lora_scanner = await registry.get_lora_scanner()
|
||||
checkpoint_scanner = await registry.get_checkpoint_scanner()
|
||||
|
||||
# If modelVersionId is provided, check for specific version
|
||||
if model_version_id_str:
|
||||
try:
|
||||
model_version_id = int(model_version_id_str)
|
||||
except ValueError:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'Parameter modelVersionId must be an integer'
|
||||
}, status=400)
|
||||
|
||||
# Check if the specific version exists in either scanner
|
||||
exists = False
|
||||
model_type = None
|
||||
|
||||
# Check lora scanner first
|
||||
if await lora_scanner.check_model_version_exists(model_id, model_version_id):
|
||||
exists = True
|
||||
model_type = 'lora'
|
||||
# If not found in lora, check checkpoint scanner
|
||||
elif checkpoint_scanner and await checkpoint_scanner.check_model_version_exists(model_id, model_version_id):
|
||||
exists = True
|
||||
model_type = 'checkpoint'
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'exists': exists,
|
||||
'modelType': model_type if exists else None
|
||||
})
|
||||
|
||||
# If modelVersionId is not provided, return all version IDs for the model
|
||||
else:
|
||||
# Get versions from lora scanner first
|
||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||
checkpoint_versions = []
|
||||
|
||||
# Only check checkpoint scanner if no lora versions found
|
||||
if not lora_versions:
|
||||
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
|
||||
|
||||
# Determine model type and combine results
|
||||
model_type = None
|
||||
versions = []
|
||||
|
||||
if lora_versions:
|
||||
model_type = 'lora'
|
||||
versions = lora_versions
|
||||
elif checkpoint_versions:
|
||||
model_type = 'checkpoint'
|
||||
versions = checkpoint_versions
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'modelId': model_id,
|
||||
'modelType': model_type,
|
||||
'versions': versions
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check model existence: {e}", exc_info=True)
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
import base64
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import torch
|
||||
import io
|
||||
import logging
|
||||
from aiohttp import web
|
||||
@@ -648,7 +647,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 +995,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
|
||||
@@ -1018,6 +1017,8 @@ class RecipeRoutes:
|
||||
shape_info = tensor_image.shape
|
||||
logger.debug(f"Tensor shape: {shape_info}, dtype: {tensor_image.dtype}")
|
||||
|
||||
import torch
|
||||
|
||||
# Convert tensor to numpy array
|
||||
if isinstance(tensor_image, torch.Tensor):
|
||||
image_np = tensor_image.cpu().numpy()
|
||||
@@ -1107,7 +1108,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 +1267,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 +1297,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 +1305,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 +1325,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 +1355,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
438
py/routes/stats_routes.py
Normal 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()
|
||||
@@ -33,7 +33,6 @@ class CheckpointScanner(ModelScanner):
|
||||
file_extensions=file_extensions,
|
||||
hash_index=ModelHashIndex()
|
||||
)
|
||||
self._checkpoint_roots = self._init_checkpoint_roots()
|
||||
self._initialized = True
|
||||
|
||||
@classmethod
|
||||
@@ -44,27 +43,9 @@ class CheckpointScanner(ModelScanner):
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def _init_checkpoint_roots(self) -> List[str]:
|
||||
"""Initialize checkpoint roots from ComfyUI settings"""
|
||||
# Get both checkpoint and diffusion_models paths
|
||||
checkpoint_paths = folder_paths.get_folder_paths("checkpoints")
|
||||
diffusion_paths = folder_paths.get_folder_paths("diffusion_models")
|
||||
|
||||
# Combine, normalize and deduplicate paths
|
||||
all_paths = set()
|
||||
for path in checkpoint_paths + diffusion_paths:
|
||||
if os.path.exists(path):
|
||||
norm_path = path.replace(os.sep, "/")
|
||||
all_paths.add(norm_path)
|
||||
|
||||
# Sort for consistent order
|
||||
sorted_paths = sorted(all_paths, key=lambda p: p.lower())
|
||||
|
||||
return sorted_paths
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get checkpoint root directories"""
|
||||
return self._checkpoint_roots
|
||||
return config.base_models_roots
|
||||
|
||||
async def scan_all_models(self) -> List[Dict]:
|
||||
"""Scan all checkpoint directories and return metadata"""
|
||||
@@ -72,7 +53,7 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
# Create scan tasks for each directory
|
||||
scan_tasks = []
|
||||
for root in self._checkpoint_roots:
|
||||
for root in self.get_model_roots():
|
||||
task = asyncio.create_task(self._scan_directory(root))
|
||||
scan_tasks.append(task)
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ class CivitaiClient:
|
||||
logger.error(f"Error fetching model versions: {e}")
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: str, version_id: str = "") -> Optional[Dict]:
|
||||
async def get_model_version(self, model_id: int, version_id: int = None) -> Optional[Dict]:
|
||||
"""Get specific model version with additional metadata
|
||||
|
||||
Args:
|
||||
@@ -237,6 +237,8 @@ class CivitaiClient:
|
||||
"""
|
||||
try:
|
||||
session = await self._ensure_fresh_session()
|
||||
|
||||
# Step 1: Get model data to find version_id if not provided and get additional metadata
|
||||
async with session.get(f"{self.base_url}/models/{model_id}") as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
@@ -244,45 +246,28 @@ class CivitaiClient:
|
||||
data = await response.json()
|
||||
model_versions = data.get('modelVersions', [])
|
||||
|
||||
# Find matching version
|
||||
matched_version = None
|
||||
|
||||
if version_id:
|
||||
# If version_id provided, find exact match
|
||||
for version in model_versions:
|
||||
if str(version.get('id')) == str(version_id):
|
||||
matched_version = version
|
||||
break
|
||||
else:
|
||||
# If no version_id then use the first version
|
||||
matched_version = model_versions[0] if model_versions else None
|
||||
|
||||
# If no match found, return None
|
||||
if not matched_version:
|
||||
# Step 2: Determine the version_id to use
|
||||
target_version_id = version_id
|
||||
if target_version_id is None:
|
||||
target_version_id = model_versions[0].get('id')
|
||||
|
||||
# Step 3: Get detailed version info using the version_id
|
||||
headers = self._get_request_headers()
|
||||
async with session.get(f"{self.base_url}/model-versions/{target_version_id}", headers=headers) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
|
||||
# Build result with modified fields
|
||||
result = matched_version.copy() # Copy to avoid modifying original
|
||||
|
||||
# Replace index with modelId
|
||||
if 'index' in result:
|
||||
del result['index']
|
||||
result['modelId'] = model_id
|
||||
version = await response.json()
|
||||
|
||||
# Add model field with metadata from top level
|
||||
result['model'] = {
|
||||
"name": data.get("name"),
|
||||
"type": data.get("type"),
|
||||
"nsfw": data.get("nsfw", False),
|
||||
"poi": data.get("poi", False),
|
||||
"description": data.get("description"),
|
||||
"tags": data.get("tags", [])
|
||||
}
|
||||
# Step 4: Enrich version_info with model data
|
||||
# Add description and tags from model data
|
||||
version['model']['description'] = data.get("description")
|
||||
version['model']['tags'] = data.get("tags", [])
|
||||
|
||||
# Add creator field from top level
|
||||
result['creator'] = data.get("creator")
|
||||
# Add creator from model data
|
||||
version['creator'] = data.get("creator")
|
||||
|
||||
return result
|
||||
return version
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching model version: {e}")
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import Dict
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH
|
||||
from ..utils.constants import CARD_PREVIEW_WIDTH, VALID_LORA_TYPES
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
from .settings_manager import settings
|
||||
|
||||
# Download to temporary file first
|
||||
import tempfile
|
||||
@@ -48,54 +48,98 @@ class DownloadManager:
|
||||
"""Get the checkpoint scanner from registry"""
|
||||
return await ServiceRegistry.get_checkpoint_scanner()
|
||||
|
||||
async def download_from_civitai(self, download_url: str = None, model_hash: str = None,
|
||||
model_version_id: str = None, save_dir: str = None,
|
||||
relative_path: str = '', progress_callback=None,
|
||||
model_type: str = "lora") -> Dict:
|
||||
async def download_from_civitai(self, model_id: int,
|
||||
model_version_id: int, save_dir: str = None,
|
||||
relative_path: str = '', progress_callback=None, use_default_paths: bool = False) -> Dict:
|
||||
"""Download model from Civitai
|
||||
|
||||
Args:
|
||||
download_url: Direct download URL for the model
|
||||
model_hash: SHA256 hash of the model
|
||||
model_version_id: Civitai model version ID
|
||||
model_id: Civitai model ID
|
||||
model_version_id: Civitai model version ID (optional, if not provided, will download the latest version)
|
||||
save_dir: Directory to save the model to
|
||||
relative_path: Relative path within save_dir
|
||||
progress_callback: Callback function for progress updates
|
||||
model_type: Type of model ('lora' or 'checkpoint')
|
||||
use_default_paths: Flag to indicate whether to use default paths
|
||||
|
||||
Returns:
|
||||
Dict with download result
|
||||
"""
|
||||
try:
|
||||
# Update save directory with relative path if provided
|
||||
if relative_path:
|
||||
save_dir = os.path.join(save_dir, relative_path)
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
# Check if model version already exists in library
|
||||
if model_version_id is not None:
|
||||
# Case 1: model_version_id is provided, check both scanners
|
||||
lora_scanner = await self._get_lora_scanner()
|
||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||
|
||||
# Check lora scanner first
|
||||
if await lora_scanner.check_model_version_exists(model_id, model_version_id):
|
||||
return {'success': False, 'error': 'Model version already exists in lora library'}
|
||||
|
||||
# Check checkpoint scanner
|
||||
if await checkpoint_scanner.check_model_version_exists(model_id, model_version_id):
|
||||
return {'success': False, 'error': 'Model version already exists in checkpoint library'}
|
||||
|
||||
# Get civitai client
|
||||
civitai_client = await self._get_civitai_client()
|
||||
|
||||
# Get version info based on the provided identifier
|
||||
version_info = None
|
||||
error_msg = None
|
||||
|
||||
if model_hash:
|
||||
# Get model by hash
|
||||
version_info = await civitai_client.get_model_by_hash(model_hash)
|
||||
elif model_version_id:
|
||||
# Use model version ID directly
|
||||
version_info, error_msg = await civitai_client.get_model_version_info(model_version_id)
|
||||
elif download_url:
|
||||
# Extract version ID from download URL
|
||||
version_id = download_url.split('/')[-1]
|
||||
version_info, error_msg = await civitai_client.get_model_version_info(version_id)
|
||||
|
||||
version_info = await civitai_client.get_model_version(model_id, model_version_id)
|
||||
|
||||
if not version_info:
|
||||
if error_msg and "model not found" in error_msg.lower():
|
||||
return {'success': False, 'error': f'Model not found on Civitai: {error_msg}'}
|
||||
return {'success': False, 'error': error_msg or 'Failed to fetch model metadata'}
|
||||
return {'success': False, 'error': 'Failed to fetch model metadata'}
|
||||
|
||||
model_type_from_info = version_info.get('model', {}).get('type', '').lower()
|
||||
if model_type_from_info == 'checkpoint':
|
||||
model_type = 'checkpoint'
|
||||
elif model_type_from_info in VALID_LORA_TYPES:
|
||||
model_type = 'lora'
|
||||
else:
|
||||
return {'success': False, 'error': f'Model type "{model_type_from_info}" is not supported for download'}
|
||||
|
||||
# Case 2: model_version_id was None, check after getting version_info
|
||||
if model_version_id is None:
|
||||
version_model_id = version_info.get('modelId')
|
||||
version_id = version_info.get('id')
|
||||
|
||||
if model_type == 'lora':
|
||||
# Check lora scanner
|
||||
lora_scanner = await self._get_lora_scanner()
|
||||
if await lora_scanner.check_model_version_exists(version_model_id, version_id):
|
||||
return {'success': False, 'error': 'Model version already exists in lora library'}
|
||||
elif model_type == 'checkpoint':
|
||||
# Check checkpoint scanner
|
||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||
if await checkpoint_scanner.check_model_version_exists(version_model_id, version_id):
|
||||
return {'success': False, 'error': 'Model version already exists in checkpoint library'}
|
||||
|
||||
# Handle use_default_paths
|
||||
if use_default_paths:
|
||||
# Set save_dir based on model type
|
||||
if model_type == 'checkpoint':
|
||||
default_path = settings.get('default_checkpoint_root')
|
||||
if not default_path:
|
||||
return {'success': False, 'error': 'Default checkpoint root path not set in settings'}
|
||||
save_dir = default_path
|
||||
else: # model_type == 'lora'
|
||||
default_path = settings.get('default_lora_root')
|
||||
if not default_path:
|
||||
return {'success': False, 'error': 'Default lora root path not set in settings'}
|
||||
save_dir = default_path
|
||||
|
||||
# Set relative_path to version_info.baseModel/first_tag if available
|
||||
base_model = version_info.get('baseModel', '')
|
||||
model_tags = version_info.get('model', {}).get('tags', [])
|
||||
if base_model:
|
||||
if model_tags:
|
||||
relative_path = os.path.join(base_model, model_tags[0])
|
||||
else:
|
||||
relative_path = base_model
|
||||
|
||||
# Update save directory with relative path if provided
|
||||
if relative_path:
|
||||
save_dir = os.path.join(save_dir, relative_path)
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
|
||||
# Check if this is an early access model
|
||||
if version_info.get('earlyAccessEndsAt'):
|
||||
@@ -137,18 +181,6 @@ class DownloadManager:
|
||||
metadata = LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
logger.info(f"Creating LoraMetadata for {file_name}")
|
||||
|
||||
# 5.1 Get and update model tags, description and creator info
|
||||
model_id = version_info.get('modelId')
|
||||
if model_id:
|
||||
model_metadata, _ = await civitai_client.get_model_metadata(str(model_id))
|
||||
if model_metadata:
|
||||
if model_metadata.get("tags"):
|
||||
metadata.tags = model_metadata.get("tags", [])
|
||||
if model_metadata.get("description"):
|
||||
metadata.modelDescription = model_metadata.get("description", "")
|
||||
if model_metadata.get("creator"):
|
||||
metadata.civitai["creator"] = model_metadata.get("creator")
|
||||
|
||||
# 6. Start download process
|
||||
result = await self._execute_download(
|
||||
download_url=file_info.get('downloadUrl', ''),
|
||||
@@ -255,7 +287,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":
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
import shutil
|
||||
import time
|
||||
import re
|
||||
from typing import List, Dict, Optional, Set
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from ..utils.models import LoraMetadata
|
||||
from ..config import config
|
||||
@@ -14,7 +10,6 @@ from .model_hash_index import ModelHashIndex # Changed from LoraHashIndex to Mo
|
||||
from .settings_manager import settings
|
||||
from ..utils.constants import NSFW_LEVELS
|
||||
from ..utils.utils import fuzzy_match
|
||||
from .service_registry import ServiceRegistry
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -1362,3 +1362,59 @@ class ModelScanner:
|
||||
if file_name in self._hash_index._duplicate_filenames:
|
||||
if len(self._hash_index._duplicate_filenames[file_name]) <= 1:
|
||||
del self._hash_index._duplicate_filenames[file_name]
|
||||
|
||||
async def check_model_version_exists(self, model_id: int, model_version_id: int) -> bool:
|
||||
"""Check if a specific model version exists in the cache
|
||||
|
||||
Args:
|
||||
model_id: Civitai model ID
|
||||
model_version_id: Civitai model version ID
|
||||
|
||||
Returns:
|
||||
bool: True if the model version exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return False
|
||||
|
||||
for item in cache.raw_data:
|
||||
if (item.get('civitai') and
|
||||
item['civitai'].get('modelId') == model_id and
|
||||
item['civitai'].get('id') == model_version_id):
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking model version existence: {e}")
|
||||
return False
|
||||
|
||||
async def get_model_versions_by_id(self, model_id: int) -> List[Dict]:
|
||||
"""Get all versions of a model by its ID
|
||||
|
||||
Args:
|
||||
model_id: Civitai model ID
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of version information dictionaries
|
||||
"""
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
if not cache or not cache.raw_data:
|
||||
return []
|
||||
|
||||
versions = []
|
||||
for item in cache.raw_data:
|
||||
if (item.get('civitai') and
|
||||
item['civitai'].get('modelId') == model_id and
|
||||
item['civitai'].get('id')):
|
||||
versions.append({
|
||||
'versionId': item['civitai'].get('id'),
|
||||
'name': item['civitai'].get('name'),
|
||||
'fileName': item.get('file_name', '')
|
||||
})
|
||||
|
||||
return versions
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model versions: {e}")
|
||||
return []
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from aiohttp import web
|
||||
from typing import Set, Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -10,7 +11,7 @@ class WebSocketManager:
|
||||
def __init__(self):
|
||||
self._websockets: Set[web.WebSocketResponse] = set()
|
||||
self._init_websockets: Set[web.WebSocketResponse] = set() # New set for initialization progress clients
|
||||
self._checkpoint_websockets: Set[web.WebSocketResponse] = set() # New set for checkpoint download progress
|
||||
self._download_websockets: Dict[str, web.WebSocketResponse] = {} # New dict for download-specific clients
|
||||
|
||||
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""Handle new WebSocket connection"""
|
||||
@@ -39,19 +40,35 @@ class WebSocketManager:
|
||||
finally:
|
||||
self._init_websockets.discard(ws)
|
||||
return ws
|
||||
|
||||
async def handle_checkpoint_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""Handle new WebSocket connection for checkpoint download progress"""
|
||||
|
||||
async def handle_download_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""Handle new WebSocket connection for download progress"""
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self._checkpoint_websockets.add(ws)
|
||||
|
||||
# Get download_id from query parameters
|
||||
download_id = request.query.get('id')
|
||||
|
||||
if not download_id:
|
||||
# Generate a new download ID if not provided
|
||||
download_id = str(uuid4())
|
||||
|
||||
# Store the websocket with its download ID
|
||||
self._download_websockets[download_id] = ws
|
||||
|
||||
try:
|
||||
# Send the download ID back to the client
|
||||
await ws.send_json({
|
||||
'type': 'download_id',
|
||||
'download_id': download_id
|
||||
})
|
||||
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.ERROR:
|
||||
logger.error(f'Checkpoint WebSocket error: {ws.exception()}')
|
||||
logger.error(f'Download WebSocket error: {ws.exception()}')
|
||||
finally:
|
||||
self._checkpoint_websockets.discard(ws)
|
||||
if download_id in self._download_websockets:
|
||||
del self._download_websockets[download_id]
|
||||
return ws
|
||||
|
||||
async def broadcast(self, data: Dict):
|
||||
@@ -84,17 +101,18 @@ class WebSocketManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending initialization progress: {e}")
|
||||
|
||||
async def broadcast_checkpoint_progress(self, data: Dict):
|
||||
"""Broadcast checkpoint download progress to connected clients"""
|
||||
if not self._checkpoint_websockets:
|
||||
async def broadcast_download_progress(self, download_id: str, data: Dict):
|
||||
"""Send progress update to specific download client"""
|
||||
if download_id not in self._download_websockets:
|
||||
logger.debug(f"No WebSocket found for download ID: {download_id}")
|
||||
return
|
||||
|
||||
for ws in self._checkpoint_websockets:
|
||||
try:
|
||||
await ws.send_json(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending checkpoint progress: {e}")
|
||||
|
||||
ws = self._download_websockets[download_id]
|
||||
try:
|
||||
await ws.send_json(data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending download progress: {e}")
|
||||
|
||||
def get_connected_clients_count(self) -> int:
|
||||
"""Get number of connected clients"""
|
||||
return len(self._websockets)
|
||||
@@ -102,10 +120,14 @@ class WebSocketManager:
|
||||
def get_init_clients_count(self) -> int:
|
||||
"""Get number of initialization progress clients"""
|
||||
return len(self._init_websockets)
|
||||
|
||||
def get_checkpoint_clients_count(self) -> int:
|
||||
"""Get number of checkpoint progress clients"""
|
||||
return len(self._checkpoint_websockets)
|
||||
|
||||
def get_download_clients_count(self) -> int:
|
||||
"""Get number of download progress clients"""
|
||||
return len(self._download_websockets)
|
||||
|
||||
def generate_download_id(self) -> str:
|
||||
"""Generate a unique download ID"""
|
||||
return str(uuid4())
|
||||
|
||||
# Global instance
|
||||
ws_manager = WebSocketManager()
|
||||
@@ -7,6 +7,16 @@ 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,
|
||||
"WanVideo Lora Select (LoraManager)": 3
|
||||
}
|
||||
|
||||
# Default ComfyUI node color when bgcolor is null
|
||||
DEFAULT_NODE_COLOR = "#353535"
|
||||
|
||||
# preview extensions
|
||||
PREVIEW_EXTENSIONS = [
|
||||
'.webp',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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", ""),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,9 +8,11 @@ from .model_utils import determine_base_model
|
||||
from .constants import PREVIEW_EXTENSIONS, CARD_PREVIEW_WIDTH
|
||||
from ..config import config
|
||||
from ..services.civitai_client import CivitaiClient
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..services.download_manager import DownloadManager
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,7 +156,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(
|
||||
@@ -564,13 +566,12 @@ class ModelRouteUtils:
|
||||
return web.Response(text=str(e), status=500)
|
||||
|
||||
@staticmethod
|
||||
async def handle_download_model(request: web.Request, download_manager: DownloadManager, model_type="lora") -> web.Response:
|
||||
async def handle_download_model(request: web.Request, download_manager: DownloadManager) -> web.Response:
|
||||
"""Handle model download request
|
||||
|
||||
Args:
|
||||
request: The aiohttp request
|
||||
download_manager: Instance of DownloadManager
|
||||
model_type: Type of model ('lora' or 'checkpoint')
|
||||
|
||||
Returns:
|
||||
web.Response: The HTTP response
|
||||
@@ -578,40 +579,58 @@ class ModelRouteUtils:
|
||||
try:
|
||||
data = await request.json()
|
||||
|
||||
# Create progress callback
|
||||
# Get or generate a download ID
|
||||
download_id = data.get('download_id', ws_manager.generate_download_id())
|
||||
|
||||
# Create progress callback with download ID
|
||||
async def progress_callback(progress):
|
||||
from ..services.websocket_manager import ws_manager
|
||||
await ws_manager.broadcast({
|
||||
await ws_manager.broadcast_download_progress(download_id, {
|
||||
'status': 'progress',
|
||||
'progress': progress
|
||||
'progress': progress,
|
||||
'download_id': download_id
|
||||
})
|
||||
|
||||
# Check which identifier is provided
|
||||
download_url = data.get('download_url')
|
||||
model_hash = data.get('model_hash')
|
||||
model_version_id = data.get('model_version_id')
|
||||
# Check which identifier is provided and convert to int
|
||||
try:
|
||||
model_id = int(data.get('model_id'))
|
||||
except (TypeError, ValueError):
|
||||
return web.Response(
|
||||
status=400,
|
||||
text="Invalid model_id: Must be an integer"
|
||||
)
|
||||
|
||||
# Convert model_version_id to int if provided
|
||||
model_version_id = None
|
||||
if data.get('model_version_id'):
|
||||
try:
|
||||
model_version_id = int(data.get('model_version_id'))
|
||||
except (TypeError, ValueError):
|
||||
return web.Response(
|
||||
status=400,
|
||||
text="Invalid model_version_id: Must be an integer"
|
||||
)
|
||||
|
||||
# Validate that at least one identifier is provided
|
||||
if not any([download_url, model_hash, model_version_id]):
|
||||
# Only model_id is required, model_version_id is optional
|
||||
if not model_id:
|
||||
return web.Response(
|
||||
status=400,
|
||||
text="Missing required parameter: Please provide either 'download_url', 'hash', or 'modelVersionId'"
|
||||
text="Missing required parameter: Please provide 'model_id'"
|
||||
)
|
||||
|
||||
# Use the correct root directory based on model type
|
||||
root_key = 'checkpoint_root' if model_type == 'checkpoint' else 'lora_root'
|
||||
save_dir = data.get(root_key)
|
||||
use_default_paths = data.get('use_default_paths', False)
|
||||
|
||||
result = await download_manager.download_from_civitai(
|
||||
download_url=download_url,
|
||||
model_hash=model_hash,
|
||||
model_id=model_id,
|
||||
model_version_id=model_version_id,
|
||||
save_dir=save_dir,
|
||||
save_dir=data.get('model_root'),
|
||||
relative_path=data.get('relative_path', ''),
|
||||
progress_callback=progress_callback,
|
||||
model_type=model_type
|
||||
use_default_paths=use_default_paths,
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
|
||||
# Include download_id in the response
|
||||
result['download_id'] = download_id
|
||||
|
||||
if not result.get('success', False):
|
||||
error_message = result.get('error', 'Unknown error')
|
||||
|
||||
@@ -638,7 +657,7 @@ class ModelRouteUtils:
|
||||
text="Early Access Restriction: This model requires purchase. Please buy early access on Civitai.com."
|
||||
)
|
||||
|
||||
logger.error(f"Error downloading {model_type}: {error_message}")
|
||||
logger.error(f"Error downloading model: {error_message}")
|
||||
return web.Response(status=500, text=error_message)
|
||||
|
||||
@staticmethod
|
||||
@@ -693,8 +712,10 @@ class ModelRouteUtils:
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
model_id = data.get('model_id')
|
||||
model_version_id = data.get('model_version_id')
|
||||
model_id = int(data.get('model_id'))
|
||||
model_version_id = None
|
||||
if data.get('model_version_id'):
|
||||
model_version_id = int(data.get('model_version_id'))
|
||||
|
||||
if not file_path or not model_id:
|
||||
return web.json_response({"success": False, "error": "Both file_path and model_id are required"}, status=400)
|
||||
@@ -836,3 +857,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)
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
from difflib import SequenceMatcher
|
||||
import requests
|
||||
import tempfile
|
||||
import re
|
||||
import os
|
||||
from bs4 import BeautifulSoup
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
|
||||
async def get_lora_info(lora_name):
|
||||
"""Get the lora path and trigger words from cache"""
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
for item in cache.raw_data:
|
||||
if item.get('file_name') == lora_name:
|
||||
file_path = item.get('file_path')
|
||||
if file_path:
|
||||
for root in config.loras_roots:
|
||||
root = root.replace(os.sep, '/')
|
||||
if file_path.startswith(root):
|
||||
relative_path = os.path.relpath(file_path, root).replace(os.sep, '/')
|
||||
# Get trigger words from civitai metadata
|
||||
civitai = item.get('civitai', {})
|
||||
trigger_words = civitai.get('trainedWords', []) if civitai else []
|
||||
return relative_path, trigger_words
|
||||
return lora_name, []
|
||||
|
||||
def download_twitter_image(url):
|
||||
"""Download image from a URL containing twitter:image meta tag
|
||||
@@ -142,7 +163,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:
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
[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"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "0.8.20-beta"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
"jinja2",
|
||||
"safetensors",
|
||||
"watchdog",
|
||||
"beautifulsoup4",
|
||||
"piexif",
|
||||
"Pillow",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
aiohttp
|
||||
jinja2
|
||||
safetensors
|
||||
watchdog
|
||||
beautifulsoup4
|
||||
piexif
|
||||
Pillow
|
||||
@@ -9,6 +8,5 @@ olefile
|
||||
requests
|
||||
toml
|
||||
numpy
|
||||
torch
|
||||
natsort
|
||||
msgpack
|
||||
msgpack
|
||||
|
||||
@@ -3,6 +3,26 @@ import os
|
||||
import sys
|
||||
import json
|
||||
|
||||
# Create mock modules for py/nodes directory - add this before any other imports
|
||||
def mock_nodes_directory():
|
||||
"""Create mock modules for all Python files in the py/nodes directory"""
|
||||
nodes_dir = os.path.join(os.path.dirname(__file__), 'py', 'nodes')
|
||||
if os.path.exists(nodes_dir):
|
||||
# Create a mock module for the nodes package itself
|
||||
sys.modules['py.nodes'] = type('MockNodesModule', (), {})
|
||||
|
||||
# Create mock modules for all Python files in the nodes directory
|
||||
for file in os.listdir(nodes_dir):
|
||||
if file.endswith('.py') and file != '__init__.py':
|
||||
module_name = file[:-3] # Remove .py extension
|
||||
full_module_name = f'py.nodes.{module_name}'
|
||||
# Create empty module object
|
||||
sys.modules[full_module_name] = type(f'Mock{module_name.capitalize()}Module', (), {})
|
||||
print(f"Created mock module for: {full_module_name}")
|
||||
|
||||
# Run the mocking function before any other imports
|
||||
mock_nodes_directory()
|
||||
|
||||
# Create mock folder_paths module BEFORE any other imports
|
||||
class MockFolderPaths:
|
||||
@staticmethod
|
||||
@@ -232,7 +252,7 @@ class StandaloneLoraManager(LoraManager):
|
||||
added_targets.add(os.path.normpath(real_root))
|
||||
|
||||
# Add static routes for each checkpoint root
|
||||
for idx, root in enumerate(config.checkpoints_roots, start=1):
|
||||
for idx, root in enumerate(config.base_models_roots, start=1):
|
||||
if not os.path.exists(root):
|
||||
logger.warning(f"Checkpoint root path does not exist: {root}")
|
||||
continue
|
||||
@@ -268,8 +288,8 @@ class StandaloneLoraManager(LoraManager):
|
||||
norm_target = os.path.normpath(target_path)
|
||||
if norm_target not in added_targets:
|
||||
# Determine if this is a checkpoint or lora link based on path
|
||||
is_checkpoint = any(os.path.normpath(cp_root) in os.path.normpath(link_path) for cp_root in config.checkpoints_roots)
|
||||
is_checkpoint = is_checkpoint or any(os.path.normpath(cp_root) in norm_target for cp_root in config.checkpoints_roots)
|
||||
is_checkpoint = any(os.path.normpath(cp_root) in os.path.normpath(link_path) for cp_root in config.base_models_roots)
|
||||
is_checkpoint = is_checkpoint or any(os.path.normpath(cp_root) in norm_target for cp_root in config.base_models_roots)
|
||||
|
||||
if is_checkpoint:
|
||||
route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview'
|
||||
@@ -301,13 +321,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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -751,6 +751,29 @@ input:checked + .toggle-slider:before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Add styles for tab with new content indicator */
|
||||
.tab-btn.has-new-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-btn.has-new-content::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--lora-accent);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.1); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Tab content styles */
|
||||
.help-content {
|
||||
padding: var(--space-1) 0;
|
||||
@@ -817,6 +840,37 @@ input:checked + .toggle-slider:before {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* New content badge styles */
|
||||
.new-content-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
background-color: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.new-content-badge.inline {
|
||||
font-size: 0.65em;
|
||||
padding: 1px 4px;
|
||||
margin-left: 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Dark theme adjustments for new content badge */
|
||||
[data-theme="dark"] .new-content-badge {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Update video list styles */
|
||||
.video-list {
|
||||
display: flex;
|
||||
|
||||
520
static/css/components/statistics.css
Normal file
520
static/css/components/statistics.css
Normal 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);
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -179,7 +179,7 @@ export function setupBaseModelEditing(filePath) {
|
||||
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
|
||||
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
|
||||
'Other Models': [
|
||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
@@ -180,7 +180,7 @@ export function setupBaseModelEditing(filePath) {
|
||||
'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER],
|
||||
'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO],
|
||||
'Other Models': [
|
||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW,
|
||||
BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1,
|
||||
BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI,
|
||||
BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM,
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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('')}
|
||||
@@ -245,11 +245,6 @@ function findLocalFile(img, index, exampleFiles) {
|
||||
const match = file.name.match(/image_(\d+)\./);
|
||||
return match && parseInt(match[1]) === index;
|
||||
});
|
||||
|
||||
// If not found by index, just use the same position in the array if available
|
||||
if (!localFile && index < exampleFiles.length) {
|
||||
localFile = exampleFiles[index];
|
||||
}
|
||||
}
|
||||
|
||||
return localFile;
|
||||
@@ -406,7 +401,7 @@ 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);
|
||||
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
|
||||
|
||||
// Re-initialize showcase functionality
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export class CheckpointDownloadManager {
|
||||
this.currentVersion = null;
|
||||
this.versions = [];
|
||||
this.modelInfo = null;
|
||||
this.modelId = null;
|
||||
this.modelVersionId = null;
|
||||
|
||||
// Clear selected folder and remove selection from UI
|
||||
@@ -79,12 +80,12 @@ export class CheckpointDownloadManager {
|
||||
try {
|
||||
this.loadingManager.showSimpleLoading('Fetching model versions...');
|
||||
|
||||
const modelId = this.extractModelId(url);
|
||||
if (!modelId) {
|
||||
this.modelId = this.extractModelId(url);
|
||||
if (!this.modelId) {
|
||||
throw new Error('Invalid Civitai URL format');
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/checkpoints/civitai/versions/${modelId}`);
|
||||
const response = await fetch(`/api/checkpoints/civitai/versions/${this.modelId}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) {
|
||||
@@ -254,7 +255,7 @@ export class CheckpointDownloadManager {
|
||||
).join('');
|
||||
|
||||
// Set default checkpoint root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_checkpoints_root;
|
||||
const defaultRoot = getStorageItem('settings', {}).default_checkpoint_root;
|
||||
if (defaultRoot && data.roots.includes(defaultRoot)) {
|
||||
checkpointRoot.value = defaultRoot;
|
||||
}
|
||||
@@ -296,22 +297,28 @@ export class CheckpointDownloadManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadUrl = this.currentVersion.downloadUrl;
|
||||
if (!downloadUrl) {
|
||||
throw new Error('No download URL available');
|
||||
}
|
||||
|
||||
// Show enhanced loading with progress details
|
||||
const updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||
updateProgress(0, 0, this.currentVersion.name);
|
||||
|
||||
// Setup WebSocket for progress updates using checkpoint-specific endpoint
|
||||
// Generate a unique ID for this download
|
||||
const downloadId = Date.now().toString();
|
||||
|
||||
// Setup WebSocket for progress updates using download-specific endpoint
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/checkpoint-progress`);
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
|
||||
// Handle download ID confirmation
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to checkpoint download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process progress updates for our download
|
||||
if (data.status === 'progress' && data.download_id === downloadId) {
|
||||
// Update progress display with current progress
|
||||
updateProgress(data.progress, 0, this.currentVersion.name);
|
||||
|
||||
@@ -333,14 +340,16 @@ export class CheckpointDownloadManager {
|
||||
// Continue with download even if WebSocket fails
|
||||
};
|
||||
|
||||
// Start download using checkpoint download endpoint
|
||||
const response = await fetch('/api/checkpoints/download', {
|
||||
// Start download using checkpoint download endpoint with download ID
|
||||
const response = await fetch('/api/download-model', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
download_url: downloadUrl,
|
||||
checkpoint_root: checkpointRoot,
|
||||
relative_path: targetFolder
|
||||
model_id: this.modelId,
|
||||
model_version_id: this.currentVersion.id,
|
||||
model_root: checkpointRoot,
|
||||
relative_path: targetFolder,
|
||||
download_id: downloadId
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export class DownloadManager {
|
||||
this.currentVersion = null;
|
||||
this.versions = [];
|
||||
this.modelInfo = null;
|
||||
this.modelId = null;
|
||||
this.modelVersionId = null;
|
||||
|
||||
// Clear selected folder and remove selection from UI
|
||||
@@ -81,12 +82,12 @@ export class DownloadManager {
|
||||
try {
|
||||
this.loadingManager.showSimpleLoading('Fetching model versions...');
|
||||
|
||||
const modelId = this.extractModelId(url);
|
||||
if (!modelId) {
|
||||
this.modelId = this.extractModelId(url);
|
||||
if (!this.modelId) {
|
||||
throw new Error('Invalid Civitai URL format');
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/civitai/versions/${modelId}`);
|
||||
const response = await fetch(`/api/civitai/versions/${this.modelId}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) {
|
||||
@@ -252,24 +253,39 @@ export class DownloadManager {
|
||||
document.getElementById('locationStep').style.display = 'block';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lora-roots');
|
||||
if (!response.ok) {
|
||||
// Fetch LoRA roots
|
||||
const rootsResponse = await fetch('/api/lora-roots');
|
||||
if (!rootsResponse.ok) {
|
||||
throw new Error('Failed to fetch LoRA roots');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rootsData = await rootsResponse.json();
|
||||
const loraRoot = document.getElementById('loraRoot');
|
||||
loraRoot.innerHTML = data.roots.map(root =>
|
||||
loraRoot.innerHTML = rootsData.roots.map(root =>
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
// Set default lora root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
||||
if (defaultRoot && data.roots.includes(defaultRoot)) {
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
loraRoot.value = defaultRoot;
|
||||
}
|
||||
|
||||
// Initialize folder browser after loading roots
|
||||
// Fetch folders dynamically
|
||||
const foldersResponse = await fetch('/api/folders');
|
||||
if (!foldersResponse.ok) {
|
||||
throw new Error('Failed to fetch folders');
|
||||
}
|
||||
|
||||
const foldersData = await foldersResponse.json();
|
||||
const folderBrowser = document.getElementById('folderBrowser');
|
||||
|
||||
// Update folder browser with dynamic content
|
||||
folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
||||
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
|
||||
).join('');
|
||||
|
||||
// Initialize folder browser after loading roots and folders
|
||||
this.initializeFolderBrowser();
|
||||
} catch (error) {
|
||||
showToast(error.message, 'error');
|
||||
@@ -306,22 +322,28 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadUrl = this.currentVersion.downloadUrl;
|
||||
if (!downloadUrl) {
|
||||
throw new Error('No download URL available');
|
||||
}
|
||||
|
||||
// Show enhanced loading with progress details
|
||||
const updateProgress = this.loadingManager.showDownloadProgress(1);
|
||||
updateProgress(0, 0, this.currentVersion.name);
|
||||
|
||||
// Setup WebSocket for progress updates
|
||||
// Generate a unique ID for this download
|
||||
const downloadId = Date.now().toString();
|
||||
|
||||
// Setup WebSocket for progress updates - use download-specific endpoint
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${downloadId}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
|
||||
// Handle download ID confirmation
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process progress updates for our download
|
||||
if (data.status === 'progress' && data.download_id === downloadId) {
|
||||
// Update progress display with current progress
|
||||
updateProgress(data.progress, 0, this.currentVersion.name);
|
||||
|
||||
@@ -343,14 +365,16 @@ export class DownloadManager {
|
||||
// Continue with download even if WebSocket fails
|
||||
};
|
||||
|
||||
// Start download
|
||||
const response = await fetch('/api/download-lora', {
|
||||
// Start download with our download ID
|
||||
const response = await fetch('/api/download-model', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
download_url: downloadUrl,
|
||||
lora_root: loraRoot,
|
||||
relative_path: targetFolder
|
||||
model_id: this.modelId,
|
||||
model_version_id: this.currentVersion.id,
|
||||
model_root: loraRoot,
|
||||
relative_path: targetFolder,
|
||||
download_id: downloadId
|
||||
})
|
||||
});
|
||||
|
||||
@@ -361,6 +385,9 @@ export class DownloadManager {
|
||||
showToast('Download completed successfully', 'success');
|
||||
modalManager.closeModal('downloadModal');
|
||||
|
||||
// Close WebSocket after download completes
|
||||
ws.close();
|
||||
|
||||
// Update state and trigger reload with folder update
|
||||
state.activeFolder = targetFolder;
|
||||
await resetAndReload(true); // Pass true to update folders
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
export class HelpManager {
|
||||
constructor() {
|
||||
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0);
|
||||
this.latestContentTimestamp = 0; // Will be updated from server or config
|
||||
this.latestContentTimestamp = new Date('2025-07-09').getTime(); // Will be updated from server or config
|
||||
this.isInitialized = false;
|
||||
|
||||
// Default latest content data - could be fetched from server
|
||||
@@ -81,6 +81,9 @@ export class HelpManager {
|
||||
if (window.modalManager) {
|
||||
window.modalManager.toggleModal('helpModal');
|
||||
|
||||
// Add visual indicator to Documentation tab if there's new content
|
||||
this.updateDocumentationTabIndicator();
|
||||
|
||||
// Update the last viewed timestamp
|
||||
this.markContentAsViewed();
|
||||
|
||||
@@ -88,6 +91,16 @@ export class HelpManager {
|
||||
this.hideHelpBadge();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add visual indicator to Documentation tab for new content
|
||||
*/
|
||||
updateDocumentationTabIndicator() {
|
||||
const docTab = document.querySelector('.tab-btn[data-tab="documentation"]');
|
||||
if (docTab && this.hasNewContent()) {
|
||||
docTab.classList.add('has-new-content');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark content as viewed by saving current timestamp
|
||||
@@ -105,7 +118,7 @@ export class HelpManager {
|
||||
// For now, we'll just use the hardcoded data from constructor
|
||||
|
||||
// Update the timestamp with the latest data
|
||||
this.latestContentTimestamp = this.latestVideoData.timestamp;
|
||||
this.latestContentTimestamp = Math.max(this.latestContentTimestamp, this.latestVideoData.timestamp);
|
||||
|
||||
// Check again if we need to show the badge with this new data
|
||||
this.updateHelpBadge();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import { updateFolderTags } from '../api/baseModelApi.js';
|
||||
|
||||
class MoveManager {
|
||||
constructor() {
|
||||
@@ -73,32 +73,46 @@ class MoveManager {
|
||||
this.newFolderInput.value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lora-roots');
|
||||
if (!response.ok) {
|
||||
// Fetch LoRA roots
|
||||
const rootsResponse = await fetch('/api/lora-roots');
|
||||
if (!rootsResponse.ok) {
|
||||
throw new Error('Failed to fetch LoRA roots');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
const rootsData = await rootsResponse.json();
|
||||
if (!rootsData.roots || rootsData.roots.length === 0) {
|
||||
throw new Error('No LoRA roots found');
|
||||
}
|
||||
|
||||
// 填充LoRA根目录选择器
|
||||
this.loraRootSelect.innerHTML = data.roots.map(root =>
|
||||
this.loraRootSelect.innerHTML = rootsData.roots.map(root =>
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
// Set default lora root if available
|
||||
const defaultRoot = getStorageItem('settings', {}).default_loras_root;
|
||||
if (defaultRoot && data.roots.includes(defaultRoot)) {
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
this.loraRootSelect.value = defaultRoot;
|
||||
}
|
||||
|
||||
// Fetch folders dynamically
|
||||
const foldersResponse = await fetch('/api/folders');
|
||||
if (!foldersResponse.ok) {
|
||||
throw new Error('Failed to fetch folders');
|
||||
}
|
||||
|
||||
const foldersData = await foldersResponse.json();
|
||||
|
||||
// Update folder browser with dynamic content
|
||||
this.folderBrowser.innerHTML = foldersData.folders.map(folder =>
|
||||
`<div class="folder-item" data-folder="${folder}">${folder}</div>`
|
||||
).join('');
|
||||
|
||||
this.updatePathPreview();
|
||||
modalManager.showModal('moveModal');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching LoRA roots:', error);
|
||||
console.error('Error fetching LoRA roots or folders:', error);
|
||||
showToast(error.message, 'error');
|
||||
}
|
||||
}
|
||||
@@ -151,8 +165,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,11 +183,22 @@ 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});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh folder tags after successful move
|
||||
try {
|
||||
const foldersResponse = await fetch('/api/folders');
|
||||
if (foldersResponse.ok) {
|
||||
const foldersData = await foldersResponse.json();
|
||||
updateFolderTags(foldersData.folders);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing folder tags:', error);
|
||||
}
|
||||
|
||||
modalManager.closeModal('moveModal');
|
||||
|
||||
// If we were in bulk mode, exit it after successful move
|
||||
|
||||
@@ -36,6 +36,16 @@ 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';
|
||||
}
|
||||
|
||||
// Set default for defaultCheckpointRoot if undefined
|
||||
if (state.global.settings.default_checkpoint_root === undefined) {
|
||||
state.global.settings.default_checkpoint_root = '';
|
||||
}
|
||||
|
||||
// Convert old boolean compactMode to new displayDensity string
|
||||
if (typeof state.global.settings.displayDensity === 'undefined') {
|
||||
@@ -102,6 +112,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');
|
||||
@@ -112,6 +128,9 @@ export class SettingsManager {
|
||||
// Load default lora root
|
||||
await this.loadLoraRoots();
|
||||
|
||||
// Load default checkpoint root
|
||||
await this.loadCheckpointRoots();
|
||||
|
||||
// Backend settings are loaded from the template directly
|
||||
}
|
||||
|
||||
@@ -154,6 +173,45 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
async loadCheckpointRoots() {
|
||||
try {
|
||||
const defaultCheckpointRootSelect = document.getElementById('defaultCheckpointRoot');
|
||||
if (!defaultCheckpointRootSelect) return;
|
||||
|
||||
// Fetch checkpoint roots
|
||||
const response = await fetch('/api/checkpoints/roots');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch checkpoint roots');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.roots || data.roots.length === 0) {
|
||||
throw new Error('No checkpoint roots found');
|
||||
}
|
||||
|
||||
// Clear existing options except the first one (No Default)
|
||||
const noDefaultOption = defaultCheckpointRootSelect.querySelector('option[value=""]');
|
||||
defaultCheckpointRootSelect.innerHTML = '';
|
||||
defaultCheckpointRootSelect.appendChild(noDefaultOption);
|
||||
|
||||
// Add options for each root
|
||||
data.roots.forEach(root => {
|
||||
const option = document.createElement('option');
|
||||
option.value = root;
|
||||
option.textContent = root;
|
||||
defaultCheckpointRootSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Set selected value from settings
|
||||
const defaultRoot = state.global.settings.default_checkpoint_root || '';
|
||||
defaultCheckpointRootSelect.value = defaultRoot;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading checkpoint roots:', error);
|
||||
showToast('Failed to load checkpoint roots: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettings() {
|
||||
if (this.isOpen) {
|
||||
modalManager.closeModal('settingsModal');
|
||||
@@ -240,11 +298,15 @@ export class SettingsManager {
|
||||
// Update frontend state
|
||||
if (settingKey === 'default_lora_root') {
|
||||
state.global.settings.default_loras_root = value;
|
||||
} else if (settingKey === 'default_checkpoint_root') {
|
||||
state.global.settings.default_checkpoint_root = value;
|
||||
} else if (settingKey === 'display_density') {
|
||||
state.global.settings.displayDensity = value;
|
||||
|
||||
// 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;
|
||||
@@ -255,7 +317,7 @@ export class SettingsManager {
|
||||
|
||||
try {
|
||||
// For backend settings, make API call
|
||||
if (settingKey === 'default_lora_root') {
|
||||
if (settingKey === 'default_lora_root' || settingKey === 'default_checkpoint_root') {
|
||||
const payload = {};
|
||||
payload[settingKey] = value;
|
||||
|
||||
@@ -401,6 +463,7 @@ export class SettingsManager {
|
||||
const blurMatureContent = document.getElementById('blurMatureContent').checked;
|
||||
const showOnlySFW = document.getElementById('showOnlySFW').checked;
|
||||
const defaultLoraRoot = document.getElementById('defaultLoraRoot').value;
|
||||
const defaultCheckpointRoot = document.getElementById('defaultCheckpointRoot').value;
|
||||
const autoplayOnHover = document.getElementById('autoplayOnHover').checked;
|
||||
const optimizeExampleImages = document.getElementById('optimizeExampleImages').checked;
|
||||
|
||||
@@ -411,6 +474,7 @@ export class SettingsManager {
|
||||
state.global.settings.blurMatureContent = blurMatureContent;
|
||||
state.global.settings.show_only_sfw = showOnlySFW;
|
||||
state.global.settings.default_loras_root = defaultLoraRoot;
|
||||
state.global.settings.default_checkpoint_root = defaultCheckpointRoot;
|
||||
state.global.settings.autoplayOnHover = autoplayOnHover;
|
||||
state.global.settings.optimizeExampleImages = optimizeExampleImages;
|
||||
|
||||
@@ -427,7 +491,8 @@ export class SettingsManager {
|
||||
body: JSON.stringify({
|
||||
civitai_api_key: apiKey,
|
||||
show_only_sfw: showOnlySFW,
|
||||
optimize_example_images: optimizeExampleImages
|
||||
optimize_example_images: optimizeExampleImages,
|
||||
default_checkpoint_root: defaultCheckpointRoot
|
||||
})
|
||||
});
|
||||
|
||||
@@ -506,6 +571,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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,9 +128,12 @@ export class DownloadManager {
|
||||
targetPath += '/' + newFolder;
|
||||
}
|
||||
|
||||
// Generate a unique ID for this batch download
|
||||
const batchDownloadId = Date.now().toString();
|
||||
|
||||
// Set up WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/fetch-progress`);
|
||||
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
|
||||
|
||||
// Show enhanced loading with progress details for multiple items
|
||||
const updateProgress = this.importManager.loadingManager.showDownloadProgress(
|
||||
@@ -145,7 +148,15 @@ export class DownloadManager {
|
||||
// Set up progress tracking for current download
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.status === 'progress') {
|
||||
|
||||
// Handle download ID confirmation
|
||||
if (data.type === 'download_id') {
|
||||
console.log(`Connected to batch download progress with ID: ${data.download_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process progress updates for our current active download
|
||||
if (data.status === 'progress' && data.download_id && data.download_id.startsWith(batchDownloadId)) {
|
||||
// Update current LoRA progress
|
||||
currentLoraProgress = data.progress;
|
||||
|
||||
@@ -188,16 +199,16 @@ export class DownloadManager {
|
||||
updateProgress(0, completedDownloads, lora.name);
|
||||
|
||||
try {
|
||||
// Download the LoRA
|
||||
const response = await fetch('/api/download-lora', {
|
||||
// Download the LoRA with download ID
|
||||
const response = await fetch('/api/download-model', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
download_url: lora.downloadUrl,
|
||||
model_version_id: lora.modelVersionId,
|
||||
model_hash: lora.hash,
|
||||
lora_root: loraRoot,
|
||||
relative_path: targetPath.replace(loraRoot + '/', '')
|
||||
model_id: lora.modelId,
|
||||
model_version_id: lora.id,
|
||||
model_root: loraRoot,
|
||||
relative_path: targetPath.replace(loraRoot + '/', ''),
|
||||
download_id: batchDownloadId
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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
730
static/js/statistics.js
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ export const BASE_MODELS = {
|
||||
// Other models
|
||||
FLUX_1_D: "Flux.1 D",
|
||||
FLUX_1_S: "Flux.1 S",
|
||||
FLUX_1_KONTEXT: "Flux.1 Kontext",
|
||||
AURAFLOW: "AuraFlow",
|
||||
PIXART_A: "PixArt a",
|
||||
PIXART_E: "PixArt E",
|
||||
@@ -78,6 +79,7 @@ export const BASE_MODEL_CLASSES = {
|
||||
// Other models
|
||||
[BASE_MODELS.FLUX_1_D]: "flux-d",
|
||||
[BASE_MODELS.FLUX_1_S]: "flux-s",
|
||||
[BASE_MODELS.FLUX_1_KONTEXT]: "flux-kontext",
|
||||
[BASE_MODELS.AURAFLOW]: "auraflow",
|
||||
[BASE_MODELS.PIXART_A]: "pixart-a",
|
||||
[BASE_MODELS.PIXART_E]: "pixart-e",
|
||||
@@ -101,4 +103,28 @@ export const NSFW_LEVELS = {
|
||||
X: 8,
|
||||
XXX: 16,
|
||||
BLOCKED: 32
|
||||
};
|
||||
};
|
||||
|
||||
// Node type constants
|
||||
export const NODE_TYPES = {
|
||||
LORA_LOADER: 1,
|
||||
LORA_STACKER: 2,
|
||||
WAN_VIDEO_LORA_SELECT: 3
|
||||
};
|
||||
|
||||
// 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,
|
||||
"WanVideo Lora Select (LoraManager)": NODE_TYPES.WAN_VIDEO_LORA_SELECT
|
||||
};
|
||||
|
||||
// Node type icons
|
||||
export const NODE_TYPE_ICONS = {
|
||||
[NODE_TYPES.LORA_LOADER]: "fas fa-l",
|
||||
[NODE_TYPES.LORA_STACKER]: "fas fa-s",
|
||||
[NODE_TYPES.WAN_VIDEO_LORA_SELECT]: "fas fa-w"
|
||||
};
|
||||
|
||||
// Default ComfyUI node color when bgcolor is null
|
||||
export const DEFAULT_NODE_COLOR = "#353535";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export const apiRoutes = {
|
||||
delete: (id) => `/api/loras/${id}`,
|
||||
update: (id) => `/api/loras/${id}`,
|
||||
civitai: (id) => `/api/loras/${id}/civitai`,
|
||||
download: '/api/download-lora',
|
||||
download: '/api/download-model',
|
||||
move: '/api/move-lora',
|
||||
scan: '/api/scan-loras'
|
||||
},
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 supported target 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
|
||||
|
||||
14
static/vendor/chart.js/chart.umd.js
vendored
Normal file
14
static/vendor/chart.js/chart.umd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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>
|
||||
@@ -48,13 +48,7 @@
|
||||
<div class="input-group">
|
||||
<label>Target Folder:</label>
|
||||
<div class="folder-browser" id="folderBrowser">
|
||||
{% for folder in folders %}
|
||||
{% if folder %}
|
||||
<div class="folder-item" data-folder="{{ folder }}">
|
||||
{{ folder }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Folders will be loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
@@ -92,13 +86,7 @@
|
||||
<div class="input-group">
|
||||
<label>Target Folder:</label>
|
||||
<div class="folder-browser" id="moveFolderBrowser">
|
||||
{% for folder in folders %}
|
||||
{% if folder %}
|
||||
<div class="folder-item" data-folder="{{ folder }}">
|
||||
{{ folder }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Folders will be loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
|
||||
@@ -197,6 +197,23 @@
|
||||
Set the default LoRA root directory for downloads, imports and moves
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<label for="defaultCheckpointRoot">Default Checkpoint Root</label>
|
||||
</div>
|
||||
<div class="setting-control select-control">
|
||||
<select id="defaultCheckpointRoot" onchange="settingsManager.saveSelectSetting('defaultCheckpointRoot', 'default_checkpoint_root')">
|
||||
<option value="">No Default</option>
|
||||
<!-- Options will be loaded dynamically -->
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-help">
|
||||
Set the default checkpoint root directory for downloads, imports and moves
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Layout Settings Section -->
|
||||
@@ -226,6 +243,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 +394,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>
|
||||
@@ -526,7 +575,7 @@
|
||||
<div class="docs-section">
|
||||
<h4><i class="fas fa-book-open"></i> Recipes</h4>
|
||||
<ul class="docs-links">
|
||||
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/%F0%9F%93%96-Recipes-Feature-Tutorial-%E2%80%93-ComfyUI-LoRA-Manager" target="_blank">Recipes Tutorial</a></li>
|
||||
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Recipes-Feature-Tutorial-%E2%80%93-ComfyUI-LoRA-Manager" target="_blank">Recipes Tutorial</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -536,6 +585,21 @@
|
||||
<li><a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Configuration" target="_blank">Configuration Options (WIP)</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="docs-section">
|
||||
<h4>
|
||||
<i class="fas fa-puzzle-piece"></i> Extensions
|
||||
<span class="new-content-badge">NEW</span>
|
||||
</h4>
|
||||
<ul class="docs-links">
|
||||
<li>
|
||||
<a href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/LoRA-Manager-Civitai-Extension-(Chrome-Extension)" target="_blank">
|
||||
LM Civitai Extension
|
||||
<span class="new-content-badge inline">NEW</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
195
templates/statistics.html
Normal 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="/loras_static/vendor/chart.js/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 %}
|
||||
@@ -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,18 +76,20 @@ 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)" &&
|
||||
node.comfyClass !== "WanVideo Lora Select (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
|
||||
const inputWidget = node.widgets[0];
|
||||
const inputWidget = node.inputWidget;
|
||||
if (!inputWidget) return;
|
||||
|
||||
// Get the current lora code
|
||||
@@ -107,90 +110,121 @@ 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];
|
||||
this.inputWidget = inputWidget;
|
||||
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);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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,65 @@ 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];
|
||||
this.inputWidget = inputWidget;
|
||||
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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 : [];
|
||||
|
||||
@@ -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,48 @@ 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)" ||
|
||||
node.type === "WanVideo Lora Select (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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
131
web/comfyui/wanvideo_lora_select.js
Normal file
131
web/comfyui/wanvideo_lora_select.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import {
|
||||
LORA_PATTERN,
|
||||
getActiveLorasFromNode,
|
||||
collectActiveLorasFromChain,
|
||||
updateConnectedTriggerWords,
|
||||
chainCallback
|
||||
} from "./utils.js";
|
||||
import { addLorasWidget } from "./loras_widget.js";
|
||||
|
||||
function mergeLoras(lorasText, lorasArr) {
|
||||
const result = [];
|
||||
let match;
|
||||
|
||||
// Reset pattern index before using
|
||||
LORA_PATTERN.lastIndex = 0;
|
||||
|
||||
// Parse text input and create initial entries
|
||||
while ((match = LORA_PATTERN.exec(lorasText)) !== null) {
|
||||
const name = match[1];
|
||||
const modelStrength = Number(match[2]);
|
||||
// Extract clip strength if provided, otherwise use model strength
|
||||
const clipStrength = match[3] ? Number(match[3]) : modelStrength;
|
||||
|
||||
// Find if this lora exists in the array data
|
||||
const existingLora = lorasArr.find(l => l.name === name);
|
||||
|
||||
result.push({
|
||||
name: name,
|
||||
// Use existing strength if available, otherwise use input strength
|
||||
strength: existingLora ? existingLora.strength : modelStrength,
|
||||
active: existingLora ? existingLora.active : true,
|
||||
clipStrength: existingLora ? existingLora.clipStrength : clipStrength,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.WanVideoLoraSelect",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeType.comfyClass === "WanVideo Lora Select (LoraManager)") {
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", async function() {
|
||||
// Enable widget serialization
|
||||
this.serialize_widgets = true;
|
||||
|
||||
// Add optional inputs
|
||||
this.addInput("prev_lora", 'WANVIDLORA', {
|
||||
"shape": 7 // 7 is the shape of the optional input
|
||||
});
|
||||
|
||||
this.addInput("blocks", 'SELECTEDBLOCKS', {
|
||||
"shape": 7 // 7 is the shape of the optional input
|
||||
});
|
||||
|
||||
// Restore saved value if exists
|
||||
let existingLoras = [];
|
||||
if (this.widgets_values && this.widgets_values.length > 0) {
|
||||
// 0 for low_mem_load, 1 for text widget, 2 for loras widget
|
||||
const savedValue = this.widgets_values[2];
|
||||
existingLoras = savedValue || [];
|
||||
}
|
||||
// Merge the loras data
|
||||
const mergedLoras = mergeLoras(this.widgets[1].value, existingLoras);
|
||||
|
||||
// Add flag to prevent callback loops
|
||||
let isUpdating = false;
|
||||
|
||||
const result = addLorasWidget(this, "loras", {
|
||||
defaultVal: mergedLoras // Pass object directly
|
||||
}, (value) => {
|
||||
// Prevent recursive calls
|
||||
if (isUpdating) return;
|
||||
isUpdating = true;
|
||||
|
||||
try {
|
||||
// Remove loras that are not in the value array
|
||||
const inputWidget = this.widgets[1];
|
||||
const currentLoras = value.map(l => l.name);
|
||||
|
||||
// Use the constant pattern here as well
|
||||
let newText = inputWidget.value.replace(LORA_PATTERN, (match, name, strength) => {
|
||||
return currentLoras.includes(name) ? match : '';
|
||||
});
|
||||
|
||||
// Clean up multiple spaces and trim
|
||||
newText = newText.replace(/\s+/g, ' ').trim();
|
||||
|
||||
inputWidget.value = newText;
|
||||
|
||||
// Update this node's direct trigger toggles with its own active loras
|
||||
const activeLoraNames = new Set();
|
||||
value.forEach(lora => {
|
||||
if (lora.active) {
|
||||
activeLoraNames.add(lora.name);
|
||||
}
|
||||
});
|
||||
updateConnectedTriggerWords(this, activeLoraNames);
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
});
|
||||
|
||||
this.lorasWidget = result.widget;
|
||||
|
||||
// Update input widget callback
|
||||
const inputWidget = this.widgets[1];
|
||||
this.inputWidget = inputWidget;
|
||||
inputWidget.callback = (value) => {
|
||||
if (isUpdating) return;
|
||||
isUpdating = true;
|
||||
|
||||
try {
|
||||
const currentLoras = this.lorasWidget.value || [];
|
||||
const mergedLoras = mergeLoras(value, currentLoras);
|
||||
|
||||
this.lorasWidget.value = mergedLoras;
|
||||
|
||||
// Update this node's direct trigger toggles with its own active loras
|
||||
const activeLoraNames = getActiveLorasFromNode(this);
|
||||
updateConnectedTriggerWords(this, activeLoraNames);
|
||||
} finally {
|
||||
isUpdating = false;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
BIN
wiki-images/civitai-model-page.png
Normal file
BIN
wiki-images/civitai-model-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
wiki-images/civitai-models-page.png
Normal file
BIN
wiki-images/civitai-models-page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Reference in New Issue
Block a user