Compare commits

...

29 Commits

Author SHA1 Message Date
Will Miao
0817901bef feat: update README and pyproject.toml for v0.8.10 release; add standalone mode and portable edition features 2025-04-28 18:24:02 +08:00
Will Miao
ac22172e53 Update requirements for standalone mode 2025-04-28 15:14:11 +08:00
Will Miao
fd87fbf31e Update workflow 2025-04-28 07:08:35 +08:00
Will Miao
554be0908f feat: add dynamic filename format patterns for Save Image Node in README 2025-04-28 07:01:33 +08:00
Will Miao
eaec4e5f13 feat: update README and settings.json.example for standalone mode; enhance standalone.py to redirect status requests to loras page 2025-04-27 09:41:33 +08:00
Will Miao
0e7ba27a7d feat: enhance Civitai resource extraction in StandardMetadataParser for improved JSON handling. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/141 2025-04-26 22:12:40 +08:00
Will Miao
c551f5c23b feat: update README with standalone mode instructions and add settings.json.example file 2025-04-26 20:39:24 +08:00
pixelpaws
5159657ae5 Merge pull request #142 from willmiao/dev
Dev
2025-04-26 20:25:26 +08:00
Will Miao
d35db7df72 feat: add standalone mode for LoRA Manager with setup instructions 2025-04-26 20:23:27 +08:00
Will Miao
2b5399c559 feat: enhance folder path retrieval for diffusion models and improve warning messages 2025-04-26 20:08:00 +08:00
Will Miao
9e61bbbd8e feat: improve warning management by removing existing deleted LoRAs and early access warnings 2025-04-26 19:46:48 +08:00
Will Miao
7ce5857cd5 feat: implement standalone mode support with mock modules and path handling 2025-04-26 19:14:38 +08:00
Will Miao
38fbae99fd feat: limit maximum height of loras widget to accommodate up to 5 entries. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/109 2025-04-26 12:00:36 +08:00
Will Miao
b0a9d44b0c Add support for SamplerCustomAdvanced node in metadata extraction 2025-04-26 09:40:44 +08:00
Will Miao
b4e22cd375 feat: update release notes and version to 0.8.9 with new favorites system and UI enhancements 2025-04-25 22:13:16 +08:00
Will Miao
9bc92736a7 feat: enhance session management by ensuring freshness and optimizing connection parameters 2025-04-25 20:54:25 +08:00
pixelpaws
111b34d05c Merge pull request #138 from willmiao/dev
feat: implement theme management with auto-detection and user prefere…
2025-04-25 19:47:17 +08:00
Will Miao
07d9599a2f feat: implement theme management with auto-detection and user preference storage. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/137 2025-04-25 19:39:11 +08:00
pixelpaws
d8194f211d Merge pull request #136 from willmiao/dev
Dev
2025-04-25 17:56:26 +08:00
Will Miao
51a6374c33 feat: add favorites filtering functionality across models and UI components 2025-04-25 17:55:33 +08:00
Will Miao
aa6c6035b6 refactor: consolidate save model metadata functionality across APIs 2025-04-25 13:31:01 +08:00
Will Miao
44b4a7ffbb fix: update requirements to include 'toml' and correct pip install command in README. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/134 2025-04-25 10:26:01 +08:00
Will Miao
e5bb018d22 feat: integrate Font Awesome resources locally. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/131
- Replace CDN references with local resources
- Download and include Font Awesome CSS and webfonts in project
- Remove CDN preconnect as resources are now served locally
- Improve reliability for users with limited network access
2025-04-25 10:09:20 +08:00
Will Miao
79b8a6536e docs: Update README to clarify contribution guidelines and acknowledge project inspirations 2025-04-25 09:48:00 +08:00
Will Miao
3de31cd06a feat: Add functionality to move civitai.info file during model relocation 2025-04-25 09:41:23 +08:00
Will Miao
c579b54d40 fix: Preserve original path separators when mapping real paths in Config. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/132 2025-04-25 09:33:07 +08:00
Will Miao
0a52575e8b feat: Enhance model file retrieval by ensuring primary model is selected from files list. Fixes https://github.com/willmiao/ComfyUI-Lora-Manager/issues/127 2025-04-25 05:45:29 +08:00
Will Miao
23c9a98f66 feat: Add endpoint for scanning and rebuilding recipe cache, and update UI to use new refresh method 2025-04-24 13:23:31 +08:00
Will Miao
796fc33b5b feat: Optimize TCP connection parameters and enhance logging for download operations 2025-04-22 19:43:37 +08:00
53 changed files with 1281 additions and 247 deletions

View File

@@ -20,6 +20,18 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
## Release Notes
### v0.8.10
* **Standalone Mode** - Run LoRA Manager independently from ComfyUI for a lightweight experience that works even with other stable diffusion interfaces
* **Portable Edition** - New one-click portable version for easy startup and updates in standalone mode
* **Enhanced Metadata Collection** - Added support for SamplerCustomAdvanced node in the metadata collector module
* **Improved UI Organization** - Optimized Lora Loader node height to display up to 5 LoRAs at once with scrolling capability for larger collections
### v0.8.9
* **Favorites System** - New functionality to bookmark your favorite LoRAs and checkpoints for quick access and better organization
* **Enhanced UI Controls** - Increased model card button sizes for improved usability and easier interaction
* **Smoother Page Transitions** - Optimized interface switching between pages, eliminating flash issues particularly noticeable in dark theme
* **Bug Fixes & Stability** - Resolved various issues to enhance overall reliability and performance
### v0.8.8
* **Real-time TriggerWord Updates** - Enhanced TriggerWord Toggle node to instantly update when connected Lora Loader or Lora Stacker nodes change, without requiring workflow execution
* **Optimized Metadata Recovery** - Improved utilization of existing .civitai.info files for faster initialization and preservation of metadata from models deleted from CivitAI
@@ -146,7 +158,7 @@ Watch this quick tutorial to learn how to use the new one-click LoRA integration
```bash
git clone https://github.com/willmiao/ComfyUI-Lora-Manager.git
cd ComfyUI-Lora-Manager
pip install requirements.txt
pip install -r requirements.txt
```
## Usage
@@ -167,23 +179,92 @@ pip install requirements.txt
- Paste into the Lora Loader node's text input
- The node will automatically apply preset strength and trigger words
### Filename Format Patterns for Save Image Node
The Save Image Node supports dynamic filename generation using pattern codes. You can customize how your images are named using the following format patterns:
#### Available Pattern Codes
- `%seed%` - Inserts the generation seed number
- `%width%` - Inserts the image width
- `%height%` - Inserts the image height
- `%pprompt:N%` - Inserts the positive prompt (limited to N characters)
- `%nprompt:N%` - Inserts the negative prompt (limited to N characters)
- `%model:N%` - Inserts the model/checkpoint name (limited to N characters)
- `%date%` - Inserts current date/time as "yyyyMMddhhmmss"
- `%date:FORMAT%` - Inserts date using custom format with:
- `yyyy` - 4-digit year
- `yy` - 2-digit year
- `MM` - 2-digit month
- `dd` - 2-digit day
- `hh` - 2-digit hour
- `mm` - 2-digit minute
- `ss` - 2-digit second
#### Examples
- `image_%seed%``image_1234567890`
- `gen_%width%x%height%``gen_512x768`
- `%model:10%_%seed%``dreamshape_1234567890`
- `%date:yyyy-MM-dd%``2025-04-28`
- `%pprompt:20%_%seed%``beautiful landscape_1234567890`
- `%model%_%date:yyMMdd%_%seed%``dreamshaper_v8_250428_1234567890`
You can combine multiple patterns to create detailed, organized filenames for your generated images.
### Standalone Mode
You can now run LoRA Manager independently from ComfyUI:
1. **For ComfyUI users**:
- Launch ComfyUI with LoRA Manager at least once to initialize the necessary path information in the `settings.json` file.
- Make sure dependencies are installed: `pip install -r requirements.txt`
- From your ComfyUI root directory, run:
```bash
python custom_nodes\comfyui-lora-manager\standalone.py
```
- Access the interface at: `http://localhost:8188/loras`
- You can specify a different host or port with arguments:
```bash
python custom_nodes\comfyui-lora-manager\standalone.py --host 127.0.0.1 --port 9000
```
2. **For non-ComfyUI users**:
- Copy the provided `settings.json.example` file to create a new file named `settings.json`
- Edit `settings.json` to include your correct model folder paths and CivitAI API key
- Install required dependencies: `pip install -r requirements.txt`
- Run standalone mode:
```bash
python standalone.py
```
- Access the interface through your browser at: `http://localhost:8188/loras`
This standalone mode provides a lightweight option for managing your model and recipe collection without needing to run the full ComfyUI environment, making it useful even for users who primarily use other stable diffusion interfaces.
---
## Contributing
Thank you for your interest in contributing to ComfyUI LoRA Manager! As this project is currently in its early stages and undergoing rapid development and refactoring, we are temporarily not accepting pull requests.
However, your feedback and ideas are extremely valuable to us:
- Please feel free to open issues for any bugs you encounter
- Submit feature requests through GitHub issues
- Share your suggestions for improvements
We appreciate your understanding and look forward to potentially accepting code contributions once the project architecture stabilizes.
---
## Credits
This project has been inspired by and benefited from other excellent ComfyUI extensions:
- [ComfyUI-SaveImageWithMetaData](https://github.com/Comfy-Community/ComfyUI-SaveImageWithMetaData) - For the image metadata functionality
- [ComfyUI-SaveImageWithMetaData](https://github.com/nkchocoai/ComfyUI-SaveImageWithMetaData) - For the image metadata functionality
- [rgthree-comfy](https://github.com/rgthree/rgthree-comfy) - For the lora loader functionality
---
## Contributing
If you have suggestions, bug reports, or improvements, feel free to open an issue or contribute directly to the codebase. Pull requests are always welcome!
---
## ☕ Support
If you find this project helpful, consider supporting its development:
@@ -196,3 +277,4 @@ Join our Discord community for support, discussions, and updates:
[Discord Server](https://discord.gg/vcqNrWVFvM)
---
````

View File

@@ -3,6 +3,11 @@ import platform
import folder_paths # type: ignore
from typing import List
import logging
import sys
import json
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
logger = logging.getLogger(__name__)
@@ -18,9 +23,46 @@ class Config:
self._route_mappings = {}
self.loras_roots = self._init_lora_paths()
self.checkpoints_roots = self._init_checkpoint_paths()
self.temp_directory = folder_paths.get_temp_directory()
# 在初始化时扫描符号链接
self._scan_symbolic_links()
if not standalone_mode:
# Save the paths to settings.json when running in ComfyUI mode
self.save_folder_paths_to_settings()
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")
except Exception as e:
logger.warning(f"Failed to save folder paths: {e}")
def _is_link(self, path: str) -> bool:
try:
@@ -103,58 +145,66 @@ class Config:
def _init_lora_paths(self) -> List[str]:
"""Initialize and validate LoRA paths from ComfyUI settings"""
raw_paths = folder_paths.get_folder_paths("loras")
# Normalize and resolve symlinks, store mapping from resolved -> original
path_map = {}
for path in raw_paths:
if os.path.exists(path):
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
path_map[real_path] = path_map.get(real_path, path) # preserve first seen
# Now sort and use only the deduplicated real paths
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
print("Found LoRA roots:", "\n - " + "\n - ".join(unique_paths))
if not unique_paths:
raise ValueError("No valid loras folders found in ComfyUI configuration")
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
try:
raw_paths = folder_paths.get_folder_paths("loras")
# Normalize and resolve symlinks, store mapping from resolved -> original
path_map = {}
for path in raw_paths:
if os.path.exists(path):
real_path = os.path.normpath(os.path.realpath(path)).replace(os.sep, '/')
path_map[real_path] = path_map.get(real_path, path.replace(os.sep, "/")) # preserve first seen
# Now sort and use only the deduplicated real paths
unique_paths = sorted(path_map.values(), key=lambda p: p.lower())
logger.info("Found LoRA roots:" + ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]"))
if not unique_paths:
logger.warning("No valid loras folders found in ComfyUI configuration")
return []
for original_path in unique_paths:
real_path = os.path.normpath(os.path.realpath(original_path)).replace(os.sep, '/')
if real_path != original_path:
self.add_path_mapping(original_path, real_path)
return unique_paths
except Exception as e:
logger.warning(f"Error initializing LoRA paths: {e}")
return []
def _init_checkpoint_paths(self) -> List[str]:
"""Initialize and validate checkpoint paths from ComfyUI settings"""
# 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
if os.path.exists(path)), key=lambda p: p.lower())
print("Found checkpoint roots:", paths)
if not paths:
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
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
if os.path.exists(path)), key=lambda p: p.lower())
logger.info("Found checkpoint roots:" + ("\n - " + "\n - ".join(paths) if paths else "[]"))
if not paths:
logger.warning("No valid checkpoint folders found in ComfyUI configuration")
return []
# 初始化路径映射,与 LoRA 路径处理方式相同
for path in 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
except Exception as e:
logger.warning(f"Error initializing checkpoint paths: {e}")
return []
# 初始化路径映射,与 LoRA 路径处理方式相同
for path in 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
def get_preview_static_url(self, preview_path: str) -> str:
"""Convert local preview path to static URL"""

View File

@@ -9,9 +9,13 @@ from .routes.update_routes import UpdateRoutes
from .routes.usage_stats_routes import UsageStatsRoutes
from .services.service_registry import ServiceRegistry
import logging
import sys
logger = logging.getLogger(__name__)
# Check if we're in standalone mode
STANDALONE_MODE = 'nodes' not in sys.modules
class LoraManager:
"""Main entry point for LoRA Manager plugin"""
@@ -20,6 +24,9 @@ class LoraManager:
"""Initialize and register all routes"""
app = PromptServer.instance.app
# Configure aiohttp access logger to be less verbose
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
added_targets = set() # Track already added target paths
# Add static routes for each lora root
@@ -108,6 +115,9 @@ class LoraManager:
async def _initialize_services(cls):
"""Initialize all services using the ServiceRegistry"""
try:
# Ensure aiohttp access logger is configured with reduced verbosity
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
# Initialize CivitaiClient first to ensure it's ready for other services
civitai_client = await ServiceRegistry.get_civitai_client()
@@ -137,6 +147,12 @@ class LoraManager:
# Initialize recipe scanner if needed
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
# Initialize metadata collector if not in standalone mode
if not STANDALONE_MODE:
from .metadata_collector import init as init_metadata
init_metadata()
logger.debug("Metadata collector initialized")
# Create low-priority initialization tasks
asyncio.create_task(lora_scanner.initialize_in_background(), name='lora_cache_init')
asyncio.create_task(checkpoint_scanner.initialize_in_background(), name='checkpoint_cache_init')

View File

@@ -1,18 +1,32 @@
import os
import importlib
from .metadata_hook import MetadataHook
from .metadata_registry import MetadataRegistry
import sys
def init():
# Install hooks to collect metadata during execution
MetadataHook.install()
# Initialize registry
registry = MetadataRegistry()
print("ComfyUI Metadata Collector initialized")
def get_metadata(prompt_id=None):
"""Helper function to get metadata from the registry"""
registry = MetadataRegistry()
return registry.get_metadata(prompt_id)
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
if not standalone_mode:
from .metadata_hook import MetadataHook
from .metadata_registry import MetadataRegistry
def init():
# Install hooks to collect metadata during execution
MetadataHook.install()
# Initialize registry
registry = MetadataRegistry()
print("ComfyUI Metadata Collector initialized")
def get_metadata(prompt_id=None):
"""Helper function to get metadata from the registry"""
registry = MetadataRegistry()
return registry.get_metadata(prompt_id)
else:
# Standalone mode - provide dummy implementations
def init():
print("ComfyUI Metadata Collector disabled in standalone mode")
def get_metadata(prompt_id=None):
"""Dummy implementation for standalone mode"""
return {}

View File

@@ -1,4 +1,8 @@
import json
import sys
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
from .constants import MODELS, PROMPTS, SAMPLING, LORAS, SIZE
@@ -11,7 +15,16 @@ class MetadataProcessor:
primary_sampler = None
primary_sampler_id = None
# First, check for KSamplerAdvanced with add_noise="enable"
# First, check for SamplerCustomAdvanced
prompt = metadata.get("current_prompt")
if prompt and prompt.original_prompt:
for node_id, node_info in prompt.original_prompt.items():
if node_info.get("class_type") == "SamplerCustomAdvanced":
# Found a SamplerCustomAdvanced node
if node_id in metadata.get(SAMPLING, {}):
return node_id, metadata[SAMPLING][node_id]
# Next, check for KSamplerAdvanced with add_noise="enable"
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
parameters = sampler_info.get("parameters", {})
add_noise = parameters.get("add_noise")
@@ -22,7 +35,7 @@ class MetadataProcessor:
primary_sampler_id = node_id
break
# If no KSamplerAdvanced found, fall back to traditional KSampler with denoise=1
# If no specialized sampler found, fall back to traditional KSampler with denoise=1
if primary_sampler is None:
for node_id, sampler_info in metadata.get(SAMPLING, {}).items():
parameters = sampler_info.get("parameters", {})
@@ -152,22 +165,60 @@ class MetadataProcessor:
# Trace connections from the primary sampler
if prompt and primary_sampler_id:
# Trace positive prompt - look specifically for CLIPTextEncode
positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
# Check if this is a SamplerCustomAdvanced node
is_custom_advanced = False
if prompt.original_prompt and primary_sampler_id in prompt.original_prompt:
is_custom_advanced = prompt.original_prompt[primary_sampler_id].get("class_type") == "SamplerCustomAdvanced"
# Find any FluxGuidance nodes in the positive conditioning path
flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "FluxGuidance", max_depth=5)
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
params["guidance"] = flux_params.get("guidance")
if is_custom_advanced:
# For SamplerCustomAdvanced, trace specific inputs
# 1. Trace sigmas input to find BasicScheduler
scheduler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sigmas", "BasicScheduler", max_depth=5)
if scheduler_node_id and scheduler_node_id in metadata.get(SAMPLING, {}):
scheduler_params = metadata[SAMPLING][scheduler_node_id].get("parameters", {})
params["steps"] = scheduler_params.get("steps")
params["scheduler"] = scheduler_params.get("scheduler")
# 2. Trace sampler input to find KSamplerSelect
sampler_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "sampler", "KSamplerSelect", max_depth=5)
if sampler_node_id and sampler_node_id in metadata.get(SAMPLING, {}):
sampler_params = metadata[SAMPLING][sampler_node_id].get("parameters", {})
params["sampler"] = sampler_params.get("sampler_name")
# 3. Trace guider input for FluxGuidance and CLIPTextEncode
guider_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "guider", max_depth=5)
if guider_node_id:
# Look for FluxGuidance along the guider path
flux_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "FluxGuidance", max_depth=5)
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
params["guidance"] = flux_params.get("guidance")
# Find CLIPTextEncode for positive prompt (through conditioning)
positive_node_id = MetadataProcessor.trace_node_input(prompt, guider_node_id, "conditioning", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
# Trace negative prompt - look specifically for CLIPTextEncode
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", "CLIPTextEncode", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
else:
# Original tracing for standard samplers
# Trace positive prompt - look specifically for CLIPTextEncode
positive_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "CLIPTextEncode", max_depth=10)
if positive_node_id and positive_node_id in metadata.get(PROMPTS, {}):
params["prompt"] = metadata[PROMPTS][positive_node_id].get("text", "")
# Find any FluxGuidance nodes in the positive conditioning path
flux_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "positive", "FluxGuidance", max_depth=5)
if flux_node_id and flux_node_id in metadata.get(SAMPLING, {}):
flux_params = metadata[SAMPLING][flux_node_id].get("parameters", {})
params["guidance"] = flux_params.get("guidance")
# Trace negative prompt - look specifically for CLIPTextEncode
negative_node_id = MetadataProcessor.trace_node_input(prompt, primary_sampler_id, "negative", "CLIPTextEncode", max_depth=10)
if negative_node_id and negative_node_id in metadata.get(PROMPTS, {}):
params["negative_prompt"] = metadata[PROMPTS][negative_node_id].get("text", "")
# Size extraction is same for all sampler types
# Check if the sampler itself has size information (from latent_image)
if primary_sampler_id in metadata.get(SIZE, {}):
width = metadata[SIZE][primary_sampler_id].get("width")
@@ -229,6 +280,10 @@ class MetadataProcessor:
@staticmethod
def to_dict(metadata):
"""Convert extracted metadata to the ComfyUI output.json format"""
if standalone_mode:
# Return empty dictionary in standalone mode
return {}
params = MetadataProcessor.extract_generation_params(metadata)
# Convert all values to strings to match output.json format

View File

@@ -257,12 +257,85 @@ class VAEDecodeExtractor(NodeMetadataExtractor):
if "first_decode" not in metadata[IMAGES]:
metadata[IMAGES]["first_decode"] = metadata[IMAGES][node_id]
class KSamplerSelectExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "sampler_name" not in inputs:
return
sampling_params = {}
if "sampler_name" in inputs:
sampling_params["sampler_name"] = inputs["sampler_name"]
metadata[SAMPLING][node_id] = {
"parameters": sampling_params,
"node_id": node_id
}
class BasicSchedulerExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
sampling_params = {}
for key in ["scheduler", "steps", "denoise"]:
if key in inputs:
sampling_params[key] = inputs[key]
metadata[SAMPLING][node_id] = {
"parameters": sampling_params,
"node_id": node_id
}
class SamplerCustomAdvancedExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
sampling_params = {}
# Handle noise.seed as seed
if "noise" in inputs and inputs["noise"] is not None and hasattr(inputs["noise"], "seed"):
noise = inputs["noise"]
sampling_params["seed"] = noise.seed
metadata[SAMPLING][node_id] = {
"parameters": sampling_params,
"node_id": node_id
}
# Extract latent image dimensions if available
if "latent_image" in inputs and inputs["latent_image"] is not None:
latent = inputs["latent_image"]
if isinstance(latent, dict) and "samples" in latent:
# Extract dimensions from latent tensor
samples = latent["samples"]
if hasattr(samples, "shape") and len(samples.shape) >= 3:
# Correct shape interpretation: [batch_size, channels, height/8, width/8]
# Multiply by 8 to get actual pixel dimensions
height = int(samples.shape[2] * 8)
width = int(samples.shape[3] * 8)
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": width,
"height": height,
"node_id": node_id
}
# Registry of node-specific extractors
NODE_EXTRACTORS = {
# Sampling
"KSampler": SamplerExtractor,
"KSamplerAdvanced": KSamplerAdvancedExtractor, # Add KSamplerAdvanced
"SamplerCustomAdvanced": SamplerExtractor, # Add SamplerCustomAdvanced
"KSamplerAdvanced": KSamplerAdvancedExtractor,
"SamplerCustomAdvanced": SamplerCustomAdvancedExtractor, # Updated to use dedicated extractor
# Sampling Selectors
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
# Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor,
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor

View File

@@ -125,6 +125,7 @@ class ApiRoutes:
# Get filter parameters
base_models = request.query.get('base_models', None)
tags = request.query.get('tags', None)
favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # New parameter
# New parameters for recipe filtering
lora_hash = request.query.get('lora_hash', None)
@@ -155,7 +156,8 @@ class ApiRoutes:
base_models=filters.get('base_model', None),
tags=filters.get('tags', None),
search_options=search_options,
hash_filters=hash_filters
hash_filters=hash_filters,
favorites_only=favorites_only # Pass favorites_only parameter
)
# Get all available folders from cache
@@ -195,6 +197,7 @@ class ApiRoutes:
"from_civitai": lora.get("from_civitai", True),
"usage_tips": lora.get("usage_tips", ""),
"notes": lora.get("notes", ""),
"favorite": lora.get("favorite", False), # Include favorite status in response
"civitai": ModelRouteUtils.filter_civitai_data(lora.get("civitai", {}))
}

View File

@@ -69,6 +69,7 @@ class CheckpointsRoutes:
fuzzy_search = request.query.get('fuzzy_search', 'false').lower() == 'true'
base_models = request.query.getall('base_model', [])
tags = request.query.getall('tag', [])
favorites_only = request.query.get('favorites_only', 'false').lower() == 'true' # Add favorites_only parameter
# Process search options
search_options = {
@@ -101,7 +102,8 @@ class CheckpointsRoutes:
base_models=base_models,
tags=tags,
search_options=search_options,
hash_filters=hash_filters
hash_filters=hash_filters,
favorites_only=favorites_only # Pass favorites_only parameter
)
# Format response items
@@ -123,7 +125,8 @@ class CheckpointsRoutes:
async def get_paginated_data(self, page, page_size, sort_by='name',
folder=None, search=None, fuzzy_search=False,
base_models=None, tags=None,
search_options=None, hash_filters=None):
search_options=None, hash_filters=None,
favorites_only=False): # Add favorites_only parameter with default False
"""Get paginated and filtered checkpoint data"""
cache = await self.scanner.get_cached_data()
@@ -181,6 +184,13 @@ class CheckpointsRoutes:
if not cp.get('preview_nsfw_level') or cp.get('preview_nsfw_level') < NSFW_LEVELS['R']
]
# Apply favorites filtering if enabled
if favorites_only:
filtered_data = [
cp for cp in filtered_data
if cp.get('favorite', False) is True
]
# Apply folder filtering
if folder is not None:
if search_options.get('recursive', False):
@@ -276,6 +286,7 @@ class CheckpointsRoutes:
"from_civitai": checkpoint.get("from_civitai", True),
"notes": checkpoint.get("notes", ""),
"model_type": checkpoint.get("model_type", "checkpoint"),
"favorite": checkpoint.get("favorite", False),
"civitai": ModelRouteUtils.filter_civitai_data(checkpoint.get("civitai", {}))
}

View File

@@ -10,16 +10,25 @@ from typing import Dict
import tempfile
import json
import asyncio
import sys
from ..utils.exif_utils import ExifUtils
from ..utils.recipe_parsers import RecipeParserFactory
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..config import config
from ..metadata_collector import get_metadata # Add MetadataCollector import
from ..metadata_collector.metadata_processor import MetadataProcessor # Add MetadataProcessor import
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
from ..utils.utils import download_civitai_image
from ..services.service_registry import ServiceRegistry # Add ServiceRegistry import
from ..metadata_collector.metadata_registry import MetadataRegistry
# Only import MetadataRegistry in non-standalone mode
if not standalone_mode:
# Import metadata_collector functions and classes conditionally
from ..metadata_collector import get_metadata # Add MetadataCollector import
from ..metadata_collector.metadata_processor import MetadataProcessor # Add MetadataProcessor import
from ..metadata_collector.metadata_registry import MetadataRegistry
logger = logging.getLogger(__name__)
@@ -74,6 +83,9 @@ class RecipeRoutes:
# Add route to get recipes for a specific Lora
app.router.add_get('/api/recipes/for-lora', routes.get_recipes_for_lora)
# Add new endpoint for scanning and rebuilding the recipe cache
app.router.add_get('/api/recipes/scan', routes.scan_recipes)
async def _init_cache(self, app):
"""Initialize cache on startup"""
@@ -801,8 +813,11 @@ class RecipeRoutes:
return web.json_response({"error": "No generation metadata found"}, status=400)
# Get the most recent image from metadata registry instead of temp directory
metadata_registry = MetadataRegistry()
latest_image = metadata_registry.get_first_decoded_image()
if not standalone_mode:
metadata_registry = MetadataRegistry()
latest_image = metadata_registry.get_first_decoded_image()
else:
latest_image = None
if not latest_image:
return web.json_response({"error": "No recent images found to use for recipe. Try generating an image first."}, status=400)
@@ -1255,3 +1270,24 @@ class RecipeRoutes:
except Exception as e:
logger.error(f"Error getting recipes for Lora: {str(e)}")
return web.json_response({'success': False, 'error': str(e)}, status=500)
async def scan_recipes(self, request: web.Request) -> web.Response:
"""API endpoint for scanning and rebuilding the recipe cache"""
try:
# Ensure services are initialized
await self.init_services()
# Force refresh the recipe cache
logger.info("Manually triggering recipe cache rebuild")
await self.recipe_scanner.get_cached_data(force_refresh=True)
return web.json_response({
'success': True,
'message': 'Recipe cache refreshed successfully'
})
except Exception as e:
logger.error(f"Error refreshing recipe cache: {e}", exc_info=True)
return web.json_response({
'success': False,
'error': str(e)
}, status=500)

View File

@@ -34,6 +34,7 @@ class CivitaiClient:
'User-Agent': 'ComfyUI-LoRA-Manager/1.0'
}
self._session = None
self._session_created_at = None
# Set default buffer size to 1MB for higher throughput
self.chunk_size = 1024 * 1024
@@ -44,8 +45,8 @@ class CivitaiClient:
# Optimize TCP connection parameters
connector = aiohttp.TCPConnector(
ssl=True,
limit=10, # Increase parallel connections
ttl_dns_cache=300, # DNS cache time
limit=3, # Further reduced from 5 to 3
ttl_dns_cache=0, # Disabled DNS caching completely
force_close=False, # Keep connections for reuse
enable_cleanup_closed=True
)
@@ -57,7 +58,18 @@ class CivitaiClient:
trust_env=trust_env,
timeout=timeout
)
self._session_created_at = datetime.now()
return self._session
async def _ensure_fresh_session(self):
"""Refresh session if it's been open too long"""
if self._session is not None:
if not hasattr(self, '_session_created_at') or \
(datetime.now() - self._session_created_at).total_seconds() > 300: # 5 minutes
await self.close()
self._session = None
return await self.session
def _parse_content_disposition(self, header: str) -> str:
"""Parse filename from content-disposition header"""
@@ -103,13 +115,15 @@ class CivitaiClient:
Returns:
Tuple[bool, str]: (success, save_path or error message)
"""
session = await self.session
logger.debug(f"Resolving DNS for: {url}")
session = await self._ensure_fresh_session()
try:
headers = self._get_request_headers()
# Add Range header to allow resumable downloads
headers['Accept-Encoding'] = 'identity' # Disable compression for better chunked downloads
logger.debug(f"Starting download from: {url}")
async with session.get(url, headers=headers, allow_redirects=True) as response:
if response.status != 200:
# Handle 401 unauthorized responses
@@ -124,6 +138,7 @@ class CivitaiClient:
return False, "Access forbidden: You don't have permission to download this file."
# Generic error response for other status codes
logger.error(f"Download failed for {url} with status {response.status}")
return False, f"Download failed with status {response.status}"
# Get filename from content-disposition header
@@ -170,7 +185,7 @@ class CivitaiClient:
async def get_model_by_hash(self, model_hash: str) -> Optional[Dict]:
try:
session = await self.session
session = await self._ensure_fresh_session()
async with session.get(f"{self.base_url}/model-versions/by-hash/{model_hash}") as response:
if response.status == 200:
return await response.json()
@@ -181,7 +196,7 @@ class CivitaiClient:
async def download_preview_image(self, image_url: str, save_path: str):
try:
session = await self.session
session = await self._ensure_fresh_session()
async with session.get(image_url) as response:
if response.status == 200:
content = await response.read()
@@ -196,7 +211,7 @@ class CivitaiClient:
async def get_model_versions(self, model_id: str) -> List[Dict]:
"""Get all versions of a model with local availability info"""
try:
session = await self.session # 等待获取 session
session = await self._ensure_fresh_session() # Use fresh session
async with session.get(f"{self.base_url}/models/{model_id}") as response:
if response.status != 200:
return None
@@ -222,12 +237,14 @@ class CivitaiClient:
- An error message if there was an error, or None on success
"""
try:
session = await self.session
session = await self._ensure_fresh_session()
url = f"{self.base_url}/model-versions/{version_id}"
headers = self._get_request_headers()
logger.debug(f"Resolving DNS for model version info: {url}")
async with session.get(url, headers=headers) as response:
if response.status == 200:
logger.debug(f"Successfully fetched model version info for: {version_id}")
return await response.json(), None
# Handle specific error cases
@@ -242,6 +259,7 @@ class CivitaiClient:
return None, "Model not found (status 404)"
# Other error cases
logger.error(f"Failed to fetch model info for {version_id} (status {response.status})")
return None, f"Failed to fetch model info (status {response.status})"
except Exception as e:
error_msg = f"Error fetching model version info: {e}"
@@ -260,7 +278,7 @@ class CivitaiClient:
- The HTTP status code from the request
"""
try:
session = await self.session
session = await self._ensure_fresh_session()
headers = self._get_request_headers()
url = f"{self.base_url}/models/{model_id}"
@@ -304,10 +322,11 @@ class CivitaiClient:
async def _get_hash_from_civitai(self, model_version_id: str) -> Optional[str]:
"""Get hash from Civitai API"""
try:
if not self._session:
session = await self._ensure_fresh_session()
if not session:
return None
version_info = await self._session.get(f"{self.base_url}/model-versions/{model_version_id}")
version_info = await session.get(f"{self.base_url}/model-versions/{model_version_id}")
if not version_info or not version_info.json().get('files'):
return None

View File

@@ -88,16 +88,16 @@ class DownloadManager:
version_info = None
error_msg = None
if 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)
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 model_hash:
# Get model by hash
version_info = await civitai_client.get_model_by_hash(model_hash)
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)
if not version_info:

View File

@@ -122,7 +122,8 @@ class LoraScanner(ModelScanner):
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'name',
folder: str = None, search: str = None, fuzzy_search: bool = False,
base_models: list = None, tags: list = None,
search_options: dict = None, hash_filters: dict = None) -> Dict:
search_options: dict = None, hash_filters: dict = None,
favorites_only: bool = False) -> Dict:
"""Get paginated and filtered lora data
Args:
@@ -136,6 +137,7 @@ class LoraScanner(ModelScanner):
tags: List of tags to filter by
search_options: Dictionary with search options (filename, modelname, tags, recursive)
hash_filters: Dictionary with hash filtering options (single_hash or multiple_hashes)
favorites_only: Filter for favorite models only
"""
cache = await self.get_cached_data()
@@ -194,6 +196,13 @@ class LoraScanner(ModelScanner):
if not lora.get('preview_nsfw_level') or lora.get('preview_nsfw_level') < NSFW_LEVELS['R']
]
# Apply favorites filtering if enabled
if favorites_only:
filtered_data = [
lora for lora in filtered_data
if lora.get('favorite', False) is True
]
# Apply folder filtering
if folder is not None:
if search_options.get('recursive', False):

View File

@@ -736,6 +736,12 @@ class ModelScanner:
shutil.move(source_metadata, target_metadata)
metadata = await self._update_metadata_paths(target_metadata, target_file)
# Move civitai.info file if exists
source_civitai = os.path.join(source_dir, f"{base_name}.civitai.info")
if os.path.exists(source_civitai):
target_civitai = os.path.join(target_path, f"{base_name}.civitai.info")
shutil.move(source_civitai, target_civitai)
for ext in PREVIEW_EXTENSIONS:
source_preview = os.path.join(source_dir, f"{base_name}{ext}")
if os.path.exists(source_preview):

View File

@@ -22,6 +22,7 @@ class BaseModelMetadata:
tags: List[str] = None # Model tags
modelDescription: str = "" # Full model description
civitai_deleted: bool = False # Whether deleted from Civitai
favorite: bool = False # Whether the model is a favorite
def __post_init__(self):
# Initialize empty lists to avoid mutable default parameter issue

View File

@@ -97,8 +97,9 @@ class RecipeMetadataParser(ABC):
# Process file information if available
if 'files' in civitai_info:
# Find the primary model file (type="Model" and primary=true) in the files list
model_file = next((file for file in civitai_info.get('files', [])
if file.get('type') == 'Model'), None)
if file.get('type') == 'Model' and file.get('primary') == True), None)
if model_file:
# Get size
@@ -402,27 +403,43 @@ class StandardMetadataParser(RecipeMetadataParser):
# Extract Civitai resources
if 'Civitai resources:' in user_comment:
resources_part = user_comment.split('Civitai resources:', 1)[1]
if '],' in resources_part:
resources_json = resources_part.split('],', 1)[0] + ']'
try:
resources = json.loads(resources_json)
# Filter loras and checkpoints
for resource in resources:
if resource.get('type') == 'lora':
# 确保 weight 字段被正确保留
lora_entry = resource.copy()
# 如果找不到 weight默认为 1.0
if 'weight' not in lora_entry:
lora_entry['weight'] = 1.0
# Ensure modelVersionName is included
if 'modelVersionName' not in lora_entry:
lora_entry['modelVersionName'] = ''
metadata['loras'].append(lora_entry)
elif resource.get('type') == 'checkpoint':
metadata['checkpoint'] = resource
except json.JSONDecodeError:
pass
resources_part = user_comment.split('Civitai resources:', 1)[1].strip()
# Look for the opening and closing brackets to extract the JSON array
if resources_part.startswith('['):
# Find the position of the closing bracket
bracket_count = 0
end_pos = -1
for i, char in enumerate(resources_part):
if char == '[':
bracket_count += 1
elif char == ']':
bracket_count -= 1
if bracket_count == 0:
end_pos = i
break
if end_pos != -1:
resources_json = resources_part[:end_pos+1]
try:
resources = json.loads(resources_json)
# Filter loras and checkpoints
for resource in resources:
if resource.get('type') == 'lora':
# 确保 weight 字段被正确保留
lora_entry = resource.copy()
# 如果找不到 weight默认为 1.0
if 'weight' not in lora_entry:
lora_entry['weight'] = 1.0
# Ensure modelVersionName is included
if 'modelVersionName' not in lora_entry:
lora_entry['modelVersionName'] = ''
metadata['loras'].append(lora_entry)
elif resource.get('type') == 'checkpoint':
metadata['checkpoint'] = resource
except json.JSONDecodeError:
pass
return metadata
except Exception as e:

View File

@@ -1,5 +1,6 @@
import os
import json
import sys
import time
import asyncio
import logging
@@ -7,8 +8,13 @@ from typing import Dict, Set
from ..config import config
from ..services.service_registry import ServiceRegistry
from ..metadata_collector.metadata_registry import MetadataRegistry
from ..metadata_collector.constants import MODELS, LORAS
# Check if running in standalone mode
standalone_mode = 'nodes' not in sys.modules
if not standalone_mode:
from ..metadata_collector.metadata_registry import MetadataRegistry
from ..metadata_collector.constants import MODELS, LORAS
logger = logging.getLogger(__name__)

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "LoRA Manager for ComfyUI - Access it at http://localhost:8188/loras for managing LoRA models with previews and metadata integration."
version = "0.8.8"
version = "0.8.10"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
@@ -12,7 +12,8 @@ dependencies = [
"piexif",
"Pillow",
"olefile", # for getting rid of warning message
"requests"
"requests",
"toml"
]
[project.urls]

View File

@@ -6,4 +6,7 @@ beautifulsoup4
piexif
Pillow
olefile
requests
requests
toml
numpy
torch

14
settings.json.example Normal file
View File

@@ -0,0 +1,14 @@
{
"civitai_api_key": "your_civitai_api_key_here",
"show_only_sfw": false,
"folder_paths": {
"loras": [
"C:/path/to/your/loras_folder",
"C:/path/to/another/loras_folder"
],
"checkpoints": [
"C:/path/to/your/checkpoints_folder",
"C:/path/to/another/checkpoints_folder"
]
}
}

347
standalone.py Normal file
View File

@@ -0,0 +1,347 @@
import os
import sys
import json
# Create mock folder_paths module BEFORE any other imports
class MockFolderPaths:
@staticmethod
def get_folder_paths(folder_name):
# Load paths from settings.json
settings_path = os.path.join(os.path.dirname(__file__), 'settings.json')
try:
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
settings = json.load(f)
# For diffusion_models, combine unet and diffusers paths
if folder_name == "diffusion_models":
paths = []
if 'folder_paths' in settings:
if 'unet' in settings['folder_paths']:
paths.extend(settings['folder_paths']['unet'])
if 'diffusers' in settings['folder_paths']:
paths.extend(settings['folder_paths']['diffusers'])
# Filter out paths that don't exist
valid_paths = [p for p in paths if os.path.exists(p)]
if valid_paths:
return valid_paths
else:
print(f"Warning: No valid paths found for {folder_name}")
# For other folder names, return their paths directly
elif 'folder_paths' in settings and folder_name in settings['folder_paths']:
paths = settings['folder_paths'][folder_name]
valid_paths = [p for p in paths if os.path.exists(p)]
if valid_paths:
return valid_paths
else:
print(f"Warning: No valid paths found for {folder_name}")
except Exception as e:
print(f"Error loading folder paths from settings: {e}")
# Fallback to empty list if no paths found
return []
@staticmethod
def get_temp_directory():
return os.path.join(os.path.dirname(__file__), 'temp')
@staticmethod
def set_temp_directory(path):
os.makedirs(path, exist_ok=True)
return path
# Create mock server module with PromptServer
class MockPromptServer:
def __init__(self):
self.app = None
def send_sync(self, *args, **kwargs):
pass
# Create mock metadata_collector module
class MockMetadataCollector:
def init(self):
pass
def get_metadata(self, prompt_id=None):
return {}
# Initialize basic mocks before any imports
sys.modules['folder_paths'] = MockFolderPaths()
sys.modules['server'] = type('server', (), {'PromptServer': MockPromptServer()})
sys.modules['py.metadata_collector'] = MockMetadataCollector()
# Now we can safely import modules that depend on folder_paths and server
import argparse
import asyncio
import logging
from aiohttp import web
# Setup logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("lora-manager-standalone")
# Configure aiohttp access logger to be less verbose
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
# Now we can import the global config from our local modules
from py.config import config
class StandaloneServer:
"""Server implementation for standalone mode"""
def __init__(self):
self.app = web.Application(logger=logger)
self.instance = self # Make it compatible with PromptServer.instance pattern
# Ensure the app's access logger is configured to reduce verbosity
self.app._subapps = [] # Ensure this exists to avoid AttributeError
# Configure access logging for the app
self.app.on_startup.append(self._configure_access_logger)
async def _configure_access_logger(self, app):
"""Configure access logger to reduce verbosity"""
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
# If using aiohttp>=3.8.0, configure access logger through app directly
if hasattr(app, 'access_logger'):
app.access_logger.setLevel(logging.WARNING)
async def setup(self):
"""Set up the standalone server"""
# Create placeholders for compatibility with ComfyUI's implementation
self.last_prompt_id = None
self.last_node_id = None
self.client_id = None
# Set up routes
self.setup_routes()
# Add startup and shutdown handlers
self.app.on_startup.append(self.on_startup)
self.app.on_shutdown.append(self.on_shutdown)
def setup_routes(self):
"""Set up basic routes"""
# Add a simple status endpoint
self.app.router.add_get('/', self.handle_status)
async def handle_status(self, request):
"""Handle status request by redirecting to loras page"""
# Redirect to loras page instead of showing status
raise web.HTTPFound('/loras')
# Original JSON response (commented out)
# return web.json_response({
# "status": "running",
# "mode": "standalone",
# "loras_roots": config.loras_roots,
# "checkpoints_roots": config.checkpoints_roots
# })
async def on_startup(self, app):
"""Startup handler"""
logger.info("LoRA Manager standalone server starting...")
async def on_shutdown(self, app):
"""Shutdown handler"""
logger.info("LoRA Manager standalone server shutting down...")
def send_sync(self, event_type, data, sid=None):
"""Stub for compatibility with PromptServer"""
# In standalone mode, we don't have the same websocket system
pass
async def start(self, host='127.0.0.1', port=8188):
"""Start the server"""
runner = web.AppRunner(self.app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
# Log the server address with a clickable localhost URL regardless of the actual binding
logger.info(f"Server started at http://127.0.0.1:{port}")
# Keep the server running
while True:
await asyncio.sleep(3600) # Sleep for a long time
async def publish_loop(self):
"""Stub for compatibility with PromptServer"""
# This method exists in ComfyUI's server but we don't need it
pass
# After all mocks are in place, import LoraManager
from py.lora_manager import LoraManager
class StandaloneLoraManager(LoraManager):
"""Extended LoraManager for standalone mode"""
@classmethod
def add_routes(cls, server_instance):
"""Initialize and register all routes for standalone mode"""
app = server_instance.app
# Store app in a global-like location for compatibility
sys.modules['server'].PromptServer.instance = server_instance
# Configure aiohttp access logger to be less verbose
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
added_targets = set() # Track already added target paths
# Add static routes for each lora root
for idx, root in enumerate(config.loras_roots, start=1):
if not os.path.exists(root):
logger.warning(f"Lora root path does not exist: {root}")
continue
preview_path = f'/loras_static/root{idx}/preview'
# Check if this root is a link path in the mappings
real_root = root
for target, link in config._path_mappings.items():
if os.path.normpath(link) == os.path.normpath(root):
# If so, route should point to the target (real path)
real_root = target
break
# Normalize and standardize path display for consistency
display_root = real_root.replace('\\', '/')
# Add static route for original path - use the normalized path
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {display_root}")
# Record route mapping with normalized path
config.add_route_mapping(real_root, preview_path)
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):
if not os.path.exists(root):
logger.warning(f"Checkpoint root path does not exist: {root}")
continue
preview_path = f'/checkpoints_static/root{idx}/preview'
# Check if this root is a link path in the mappings
real_root = root
for target, link in config._path_mappings.items():
if os.path.normpath(link) == os.path.normpath(root):
# If so, route should point to the target (real path)
real_root = target
break
# Normalize and standardize path display for consistency
display_root = real_root.replace('\\', '/')
# Add static route for original path
app.router.add_static(preview_path, real_root)
logger.info(f"Added static route {preview_path} -> {display_root}")
# Record route mapping
config.add_route_mapping(real_root, preview_path)
added_targets.add(os.path.normpath(real_root))
# Add static routes for symlink target paths that aren't already covered
link_idx = {
'lora': 1,
'checkpoint': 1
}
for target_path, link_path in config._path_mappings.items():
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)
if is_checkpoint:
route_path = f'/checkpoints_static/link_{link_idx["checkpoint"]}/preview'
link_idx["checkpoint"] += 1
else:
route_path = f'/loras_static/link_{link_idx["lora"]}/preview'
link_idx["lora"] += 1
# Display path with forward slashes for consistency
display_target = target_path.replace('\\', '/')
app.router.add_static(route_path, target_path)
logger.info(f"Added static route for link target {route_path} -> {display_target}")
config.add_route_mapping(target_path, route_path)
added_targets.add(norm_target)
# Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path)
# Setup feature routes
from py.routes.lora_routes import LoraRoutes
from py.routes.api_routes import ApiRoutes
from py.routes.recipe_routes import RecipeRoutes
from py.routes.checkpoints_routes import CheckpointsRoutes
from py.routes.update_routes import UpdateRoutes
from py.routes.usage_stats_routes import UsageStatsRoutes
lora_routes = LoraRoutes()
checkpoints_routes = CheckpointsRoutes()
# Initialize routes
lora_routes.setup_routes(app)
checkpoints_routes.setup_routes(app)
ApiRoutes.setup_routes(app)
RecipeRoutes.setup_routes(app)
UpdateRoutes.setup_routes(app)
UsageStatsRoutes.setup_routes(app)
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())
# Add cleanup
app.on_shutdown.append(cls._cleanup)
app.on_shutdown.append(ApiRoutes.cleanup)
def parse_args():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description="LoRA Manager Standalone Server")
parser.add_argument("--host", type=str, default="0.0.0.0",
help="Host address to bind the server to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8188,
help="Port to bind the server to (default: 8188, access via http://localhost:8188/loras)")
# parser.add_argument("--loras", type=str, nargs="+",
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
# parser.add_argument("--checkpoints", type=str, nargs="+",
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
parser.add_argument("--log-level", type=str, default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level")
return parser.parse_args()
async def main():
"""Main entry point for standalone mode"""
args = parse_args()
# Set log level
logging.getLogger().setLevel(getattr(logging, args.log_level))
# Explicitly configure aiohttp access logger regardless of selected log level
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
# Create the server instance
server = StandaloneServer()
# Initialize routes via the standalone lora manager
StandaloneLoraManager.add_routes(server)
# Set up and start the server
await server.setup()
await server.start(host=args.host, port=args.port)
if __name__ == "__main__":
try:
# Run the main function
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Server stopped by user")

View File

@@ -59,6 +59,16 @@ html, body {
--scrollbar-width: 8px; /* 添加滚动条宽度变量 */
}
html[data-theme="dark"] {
background-color: #1a1a1a !important;
color-scheme: dark;
}
html[data-theme="light"] {
background-color: #ffffff !important;
color-scheme: light;
}
[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;

View File

@@ -192,12 +192,43 @@
margin-left: var(--space-1);
cursor: pointer;
color: white;
transition: opacity 0.2s;
font-size: 0.9em;
transition: opacity 0.2s, transform 0.15s ease;
font-size: 1.0em; /* Increased from 0.9em for better visibility */
width: 16px; /* Fixed width for consistent spacing */
height: 16px; /* Fixed height for larger touch target */
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 4px; /* Add padding to increase clickable area */
box-sizing: content-box; /* Ensure padding adds to dimensions */
position: relative; /* For proper positioning */
margin: 0; /* Reset margin */
}
.card-actions i::before {
position: absolute; /* Position the icon glyph */
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* Center the icon */
}
.card-actions {
display: flex;
gap: var(--space-1); /* Use gap instead of margin for spacing between icons */
align-items: center;
}
.card-actions i:hover {
opacity: 0.8;
opacity: 0.9;
transform: scale(1.1);
background-color: rgba(255, 255, 255, 0.1);
}
/* Style for active favorites */
.favorite-active {
color: #ffc107 !important; /* Gold color for favorites */
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
}
/* 响应式设计 */

View File

@@ -81,6 +81,22 @@
opacity: 1;
}
/* Controls */
.control-group button.favorite-filter {
position: relative;
overflow: hidden;
}
.control-group button.favorite-filter.active {
background: var(--lora-accent);
color: white;
}
.control-group button.favorite-filter i {
margin-right: 4px;
color: #ffc107;
}
/* Active state for buttons that can be toggled */
.control-group button.active {
background: var(--lora-accent);

View File

@@ -45,6 +45,11 @@ export async function loadMoreModels(options = {}) {
params.append('folder', pageState.activeFolder);
}
// Add favorites filter parameter if enabled
if (pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
// Add search parameters if there's a search term
if (pageState.filters?.search) {
params.append('search', pageState.filters.search);

View File

@@ -62,8 +62,13 @@ export async function refreshSingleCheckpointMetadata(filePath) {
return refreshSingleModelMetadata(filePath, 'checkpoint');
}
// Save checkpoint metadata (similar to the Lora version)
export async function saveCheckpointMetadata(filePath, data) {
/**
* Save model metadata to the server
* @param {string} filePath - Path to the model file
* @param {Object} data - Metadata to save
* @returns {Promise} - Promise that resolves with the server response
*/
export async function saveModelMetadata(filePath, data) {
const response = await fetch('/api/checkpoints/save-metadata', {
method: 'POST',
headers: {
@@ -79,5 +84,5 @@ export async function saveCheckpointMetadata(filePath, data) {
throw new Error('Failed to save metadata');
}
return await response.json();
return response.json();
}

View File

@@ -9,6 +9,31 @@ import {
refreshSingleModelMetadata
} from './baseModelApi.js';
/**
* Save model metadata to the server
* @param {string} filePath - File path
* @param {Object} data - Data to save
* @returns {Promise} Promise of the save operation
*/
export async function saveModelMetadata(filePath, data) {
const response = await fetch('/api/loras/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return response.json();
}
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
return loadMoreModels({
resetPage,

View File

@@ -2,7 +2,7 @@ import { showToast, copyToClipboard } 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 } from '../api/checkpointApi.js';
import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js';
export function createCheckpointCard(checkpoint) {
const card = document.createElement('div');
@@ -17,6 +17,7 @@ export function createCheckpointCard(checkpoint) {
card.dataset.from_civitai = checkpoint.from_civitai;
card.dataset.notes = checkpoint.notes || '';
card.dataset.base_model = checkpoint.base_model || 'Unknown';
card.dataset.favorite = checkpoint.favorite ? 'true' : 'false';
// Store metadata if available
if (checkpoint.civitai) {
@@ -65,6 +66,9 @@ export function createCheckpointCard(checkpoint) {
const isVideo = previewUrl.endsWith('.mp4');
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
// Get favorite status from checkpoint data
const isFavorite = checkpoint.favorite === true;
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
@@ -82,6 +86,9 @@ export function createCheckpointCard(checkpoint) {
${checkpoint.base_model}
</span>
<div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
</i>
<i class="fas fa-globe"
title="${checkpoint.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!checkpoint.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
@@ -198,6 +205,39 @@ export function createCheckpointCard(checkpoint) {
});
}
// 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
});
// Update the UI
if (newFavoriteState) {
starIcon.classList.remove('far');
starIcon.classList.add('fas', 'favorite-active');
starIcon.title = 'Remove from favorites';
card.dataset.favorite = 'true';
showToast('Added to favorites', 'success');
} else {
starIcon.classList.remove('fas', 'favorite-active');
starIcon.classList.add('far');
starIcon.title = 'Add to favorites';
card.dataset.favorite = 'false';
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();

View File

@@ -1,5 +1,5 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { refreshSingleCheckpointMetadata, saveCheckpointMetadata } from '../../api/checkpointApi.js';
import { refreshSingleCheckpointMetadata, saveModelMetadata } from '../../api/checkpointApi.js';
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
@@ -82,7 +82,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
if (!filePath) return;
try {
await saveCheckpointMetadata(filePath, { preview_nsfw_level: level });
await saveModelMetadata(filePath, { preview_nsfw_level: level });
// Update card data
const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`);

View File

@@ -1,5 +1,5 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { refreshSingleLoraMetadata } from '../../api/loraApi.js';
import { refreshSingleLoraMetadata, saveModelMetadata } from '../../api/loraApi.js';
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
import { NSFW_LEVELS } from '../../utils/constants.js';
import { getStorageItem } from '../../utils/storageHelpers.js';
@@ -111,22 +111,7 @@ export class LoraContextMenu extends BaseContextMenu {
}
async saveModelMetadata(filePath, data) {
const response = await fetch('/api/loras/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return await response.json();
return saveModelMetadata(filePath, data);
}
updateCardBlurEffect(card, level) {

View File

@@ -3,7 +3,7 @@ import { state } from '../state/index.js';
import { showLoraModal } from './loraModal/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS } from '../utils/constants.js';
import { replacePreview, deleteModel } from '../api/loraApi.js'
import { replacePreview, deleteModel, saveModelMetadata } from '../api/loraApi.js'
export function createLoraCard(lora) {
const card = document.createElement('div');
@@ -20,6 +20,7 @@ export function createLoraCard(lora) {
card.dataset.usage_tips = lora.usage_tips;
card.dataset.notes = lora.notes;
card.dataset.meta = JSON.stringify(lora.civitai || {});
card.dataset.favorite = lora.favorite ? 'true' : 'false';
// Store tags and model description
if (lora.tags && Array.isArray(lora.tags)) {
@@ -65,6 +66,9 @@ export function createLoraCard(lora) {
const isVideo = previewUrl.endsWith('.mp4');
const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop';
// Get favorite status from the lora data
const isFavorite = lora.favorite === true;
card.innerHTML = `
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
@@ -82,6 +86,9 @@ export function createLoraCard(lora) {
${lora.base_model}
</span>
<div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}"
title="${isFavorite ? 'Remove from favorites' : 'Add to favorites'}">
</i>
<i class="fas fa-globe"
title="${lora.from_civitai ? 'View on Civitai' : 'Not available from Civitai'}"
${!lora.from_civitai ? 'style="opacity: 0.5; cursor: not-allowed"' : ''}>
@@ -135,6 +142,7 @@ export function createLoraCard(lora) {
base_model: card.dataset.base_model,
usage_tips: card.dataset.usage_tips,
notes: card.dataset.notes,
favorite: card.dataset.favorite === 'true',
// Parse civitai metadata from the card's dataset
civitai: (() => {
try {
@@ -198,6 +206,39 @@ export function createLoraCard(lora) {
});
}
// 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
});
// Update the UI
if (newFavoriteState) {
starIcon.classList.remove('far');
starIcon.classList.add('fas', 'favorite-active');
starIcon.title = 'Remove from favorites';
card.dataset.favorite = 'true';
showToast('Added to favorites', 'success');
} else {
starIcon.classList.remove('fas', 'favorite-active');
starIcon.classList.add('far');
starIcon.title = 'Add to favorites';
card.dataset.favorite = 'false';
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();

View File

@@ -5,31 +5,7 @@
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js';
/**
* Save model metadata to the server
* @param {string} filePath - Path to the model file
* @param {Object} data - Metadata to save
* @returns {Promise} - Promise that resolves with the server response
*/
export async function saveModelMetadata(filePath, data) {
const response = await fetch('/api/checkpoints/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return response.json();
}
import { saveModelMetadata } from '../../api/checkpointApi.js';
/**
* Set up model name editing functionality

View File

@@ -11,9 +11,9 @@ import { setupTabSwitching, loadModelDescription } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing,
saveModelMetadata
setupFileNameEditing
} from './ModelMetadata.js';
import { saveModelMetadata } from '../../api/checkpointApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateCheckpointCard } from '../../utils/cardUpdater.js';

View File

@@ -2,7 +2,6 @@
import { PageControls } from './PageControls.js';
import { loadMoreLoras, fetchCivitai, resetAndReload, refreshLoras } from '../../api/loraApi.js';
import { getSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast } from '../../utils/uiHelpers.js';
/**
* LorasControls class - Extends PageControls for LoRA-specific functionality

View File

@@ -1,6 +1,6 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem } from '../../utils/storageHelpers.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
import { showToast } from '../../utils/uiHelpers.js';
/**
@@ -26,6 +26,9 @@ export class PageControls {
// Initialize event listeners
this.initEventListeners();
// Initialize favorites filter button state
this.initFavoritesFilter();
console.log(`PageControls initialized for ${pageType} page`);
}
@@ -121,6 +124,12 @@ export class PageControls {
bulkButton.addEventListener('click', () => this.toggleBulkMode());
}
}
// Favorites filter button handler
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.addEventListener('click', () => this.toggleFavoritesOnly());
}
}
/**
@@ -385,4 +394,50 @@ export class PageControls {
showToast('Failed to clear custom filter: ' + error.message, 'error');
}
}
/**
* Initialize the favorites filter button state
*/
initFavoritesFilter() {
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
// Get current state from session storage with page-specific key
const storageKey = `show_favorites_only_${this.pageType}`;
const showFavoritesOnly = getSessionItem(storageKey, false);
// Update button state
if (showFavoritesOnly) {
favoriteFilterBtn.classList.add('active');
}
// Update app state
this.pageState.showFavoritesOnly = showFavoritesOnly;
}
}
/**
* Toggle favorites-only filter and reload models
*/
async toggleFavoritesOnly() {
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
// Toggle the filter state in storage
const storageKey = `show_favorites_only_${this.pageType}`;
const currentState = this.pageState.showFavoritesOnly;
const newState = !currentState;
// Update session storage
setSessionItem(storageKey, newState);
// Update state
this.pageState.showFavoritesOnly = newState;
// Update button appearance
if (favoriteFilterBtn) {
favoriteFilterBtn.classList.toggle('active', newState);
}
// Reload models with new filter
await this.resetAndReload(true);
}
}

View File

@@ -5,31 +5,7 @@
import { showToast } from '../../utils/uiHelpers.js';
import { BASE_MODELS } from '../../utils/constants.js';
import { updateLoraCard } from '../../utils/cardUpdater.js';
/**
* 保存模型元数据到服务器
* @param {string} filePath - 文件路径
* @param {Object} data - 要保存的数据
* @returns {Promise} 保存操作的Promise
*/
export async function saveModelMetadata(filePath, data) {
const response = await fetch('/api/loras/save-metadata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath,
...data
})
});
if (!response.ok) {
throw new Error('Failed to save metadata');
}
return response.json();
}
import { saveModelMetadata } from '../../api/loraApi.js';
/**
* 设置模型名称编辑功能

View File

@@ -2,8 +2,7 @@
* PresetTags.js
* 处理LoRA模型预设参数标签相关的功能模块
*/
import { saveModelMetadata } from './ModelMetadata.js';
import { showToast } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from '../../api/loraApi.js';
/**
* 解析预设参数

View File

@@ -3,7 +3,7 @@
* 处理LoRA模型触发词相关的功能模块
*/
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { saveModelMetadata } from './ModelMetadata.js';
import { saveModelMetadata } from '../../api/loraApi.js';
/**
* 渲染触发词

View File

@@ -13,9 +13,9 @@ import { loadRecipesForLora } from './RecipeTab.js'; // Add import for recipe ta
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing,
saveModelMetadata
setupFileNameEditing
} from './ModelMetadata.js';
import { saveModelMetadata } from '../../api/loraApi.js';
import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js';
import { updateLoraCard } from '../../utils/cardUpdater.js';

View File

@@ -146,6 +146,18 @@ export class ImportManager {
if (totalSizeDisplay) {
totalSizeDisplay.textContent = 'Calculating...';
}
// Remove any existing deleted LoRAs warning
const deletedLorasWarning = document.getElementById('deletedLorasWarning');
if (deletedLorasWarning) {
deletedLorasWarning.remove();
}
// Remove any existing early access warning
const earlyAccessWarning = document.getElementById('earlyAccessWarning');
if (earlyAccessWarning) {
earlyAccessWarning.remove();
}
}
toggleImportMode(mode) {
@@ -532,17 +544,17 @@ export class ImportManager {
const nextButton = document.querySelector('#detailsStep .primary-btn');
if (!nextButton) return;
// Always clean up previous warnings first
const existingWarning = document.getElementById('deletedLorasWarning');
if (existingWarning) {
existingWarning.remove();
}
// Count deleted LoRAs
const deletedLoras = this.recipeData.loras.filter(lora => lora.isDeleted).length;
// If we have deleted LoRAs, show a warning and update button text
if (deletedLoras > 0) {
// Remove any existing warning
const existingWarning = document.getElementById('deletedLorasWarning');
if (existingWarning) {
existingWarning.remove();
}
// Create a new warning container above the buttons
const buttonsContainer = document.querySelector('#detailsStep .modal-actions') || nextButton.parentNode;
const warningContainer = document.createElement('div');

View File

@@ -268,6 +268,32 @@ class RecipeManager {
}
}
/**
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/
async refreshRecipes() {
try {
// Call the new endpoint to rebuild the recipe cache
const response = await fetch('/api/recipes/scan');
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache');
}
// After successful cache rebuild, load the recipes
await this.loadRecipes(true);
appCore.showToast('Refresh complete', 'success');
} catch (error) {
console.error('Error refreshing recipes:', error);
appCore.showToast(error.message || 'Failed to refresh recipes', 'error');
// Still try to load recipes even if scan failed
await this.loadRecipes(true);
}
}
async _loadSpecificRecipe(recipeId) {
try {
// Fetch specific recipe by ID

View File

@@ -42,6 +42,7 @@ export const state = {
bulkMode: false,
selectedLoras: new Set(),
loraMetadataCache: new Map(),
showFavoritesOnly: false,
},
recipes: {
@@ -61,7 +62,8 @@ export const state = {
tags: [],
search: ''
},
pageSize: 20
pageSize: 20,
showFavoritesOnly: false,
},
checkpoints: {
@@ -80,7 +82,8 @@ export const state = {
filters: {
baseModel: [],
tags: []
}
},
showFavoritesOnly: false,
}
},

View File

@@ -114,13 +114,55 @@ export function restoreFolderFilter() {
}
export function initTheme() {
document.body.dataset.theme = getStorageItem('theme') || 'dark';
const savedTheme = getStorageItem('theme') || 'auto';
applyTheme(savedTheme);
// Update theme when system preference changes (for 'auto' mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = getStorageItem('theme') || 'auto';
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
export function toggleTheme() {
const theme = document.body.dataset.theme === 'light' ? 'dark' : 'light';
document.body.dataset.theme = theme;
setStorageItem('theme', theme);
const currentTheme = getStorageItem('theme') || 'auto';
let newTheme;
if (currentTheme === 'dark') {
newTheme = 'light';
} else {
newTheme = 'dark';
}
setStorageItem('theme', newTheme);
applyTheme(newTheme);
// Force a repaint to ensure theme changes are applied immediately
document.body.style.display = 'none';
document.body.offsetHeight; // Trigger a reflow
document.body.style.display = '';
return newTheme;
}
// Add a new helper function to apply the theme
function applyTheme(theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const htmlElement = document.documentElement;
// Remove any existing theme attributes
htmlElement.removeAttribute('data-theme');
// Apply the appropriate theme
if (theme === 'dark' || (theme === 'auto' && prefersDark)) {
htmlElement.setAttribute('data-theme', 'dark');
document.body.dataset.theme = 'dark';
} else {
htmlElement.setAttribute('data-theme', 'light');
document.body.dataset.theme = 'light';
}
}
export function toggleFolder(tag) {

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/loras_static/css/style.css">
{% block page_css %}{% endblock %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
<link rel="stylesheet" href="/loras_static/vendor/font-awesome/css/all.min.css"
crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="icon" type="image/png" sizes="32x32" href="/loras_static/images/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/loras_static/images/favicon-16x16.png">
@@ -17,7 +17,7 @@
{% block preload %}{% endblock %}
<!-- 优化字体加载 -->
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/webfonts/fa-solid-900.woff2"
<link rel="preload" href="/loras_static/vendor/font-awesome/webfonts/fa-solid-900.woff2"
as="font" type="font/woff2" crossorigin>
<!-- 添加性能监控 -->
@@ -35,7 +35,7 @@
<!-- 添加资源加载策略 -->
<link rel="preconnect" href="https://civitai.com">
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<!-- <link rel="preconnect" href="https://cdnjs.cloudflare.com"> -->
<script>
// 计算滚动条宽度并设置CSS变量
@@ -48,6 +48,20 @@
document.documentElement.style.setProperty('--scrollbar-width', scrollbarWidth + 'px');
});
</script>
<script>
(function() {
// Apply theme immediately based on stored preference
const STORAGE_PREFIX = 'lora_manager_';
const savedTheme = localStorage.getItem(STORAGE_PREFIX + 'theme') || 'auto';
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (savedTheme === 'dark' || (savedTheme === 'auto' && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
{% block head_scripts %}{% endblock %}
</head>

View File

@@ -35,6 +35,11 @@
</button>
</div>
{% endif %}
<div class="control-group">
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter" title="Show favorites only">
<i class="fas fa-star"></i> Favorites
</button>
</div>
<div id="customFilterIndicator" class="control-group hidden">
<div class="filter-active">
<i class="fas fa-filter"></i> <span class="customFilterText" title=""></span>

View File

@@ -37,7 +37,7 @@
<div class="controls">
<div class="action-buttons">
<div title="Refresh recipe list" class="control-group">
<button onclick="recipeManager.loadRecipes(true)"><i class="fas fa-sync"></i> Refresh</button>
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> Refresh</button>
</div>
<div title="Import recipes" class="control-group">
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> Import</button>

View File

@@ -900,7 +900,7 @@ export function addLorasWidget(node, name, opts, callback) {
});
// Calculate height based on number of loras and fixed sizes
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (lorasData.length * LORA_ENTRY_HEIGHT);
const calculatedHeight = CONTAINER_PADDING + HEADER_HEIGHT + (Math.min(lorasData.length, 5) * LORA_ENTRY_HEIGHT);
updateWidgetHeight(calculatedHeight);
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long