mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
11 Commits
03e1fa75c5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4000b7f7e7 | ||
|
|
76c15105e6 | ||
|
|
b11c90e19b | ||
|
|
9f5d2d0c18 | ||
|
|
a0dc5229f4 | ||
|
|
61c31ecbd0 | ||
|
|
1ae1b0d607 | ||
|
|
8dd849892d | ||
|
|
a32325402e | ||
|
|
05ebd7493d | ||
|
|
90986bd795 |
3
package-lock.json
generated
3
package-lock.json
generated
@@ -114,7 +114,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -138,7 +137,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1613,7 +1611,6 @@
|
||||
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.0.1",
|
||||
"data-urls": "^5.0.0",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
import comfy.sd
|
||||
import folder_paths
|
||||
import comfy.sd # type: ignore
|
||||
import folder_paths # type: ignore
|
||||
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,6 +56,9 @@ class LoraCyclerLM:
|
||||
clip_strength = float(cycler_config.get("clip_strength", 1.0))
|
||||
sort_by = "filename"
|
||||
|
||||
# Include "no lora" option
|
||||
include_no_lora = cycler_config.get("include_no_lora", False)
|
||||
|
||||
# Dual-index mechanism for batch queue synchronization
|
||||
execution_index = cycler_config.get("execution_index") # Can be None
|
||||
# next_index_from_config = cycler_config.get("next_index") # Not used on backend
|
||||
@@ -71,7 +74,10 @@ class LoraCyclerLM:
|
||||
|
||||
total_count = len(lora_list)
|
||||
|
||||
if total_count == 0:
|
||||
# Calculate effective total count (includes no lora option if enabled)
|
||||
effective_total_count = total_count + 1 if include_no_lora else total_count
|
||||
|
||||
if total_count == 0 and not include_no_lora:
|
||||
logger.warning("[LoraCyclerLM] No LoRAs available in pool")
|
||||
return {
|
||||
"result": ([],),
|
||||
@@ -93,42 +99,66 @@ class LoraCyclerLM:
|
||||
else:
|
||||
actual_index = current_index
|
||||
|
||||
# Clamp index to valid range (1-based)
|
||||
clamped_index = max(1, min(actual_index, total_count))
|
||||
# Clamp index to valid range (1-based, includes no lora if enabled)
|
||||
clamped_index = max(1, min(actual_index, effective_total_count))
|
||||
|
||||
# Get LoRA at current index (convert to 0-based for list access)
|
||||
current_lora = lora_list[clamped_index - 1]
|
||||
# Check if current index is the "no lora" option (last position when include_no_lora is True)
|
||||
is_no_lora = include_no_lora and clamped_index == effective_total_count
|
||||
|
||||
# Build LORA_STACK with single LoRA
|
||||
lora_path, _ = get_lora_info(current_lora["file_name"])
|
||||
if not lora_path:
|
||||
logger.warning(
|
||||
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
||||
)
|
||||
if is_no_lora:
|
||||
# "No LoRA" option - return empty stack
|
||||
lora_stack = []
|
||||
current_lora_name = "No LoRA"
|
||||
current_lora_filename = "No LoRA"
|
||||
else:
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
# Get LoRA at current index (convert to 0-based for list access)
|
||||
current_lora = lora_list[clamped_index - 1]
|
||||
current_lora_name = current_lora["file_name"]
|
||||
current_lora_filename = current_lora["file_name"]
|
||||
|
||||
# Build LORA_STACK with single LoRA
|
||||
if current_lora["file_name"] == "None":
|
||||
lora_path = None
|
||||
else:
|
||||
lora_path, _ = get_lora_info(current_lora["file_name"])
|
||||
|
||||
if not lora_path:
|
||||
if current_lora["file_name"] != "None":
|
||||
logger.warning(
|
||||
f"[LoraCyclerLM] Could not find path for LoRA: {current_lora['file_name']}"
|
||||
)
|
||||
lora_stack = []
|
||||
else:
|
||||
# Normalize path separators
|
||||
lora_path = lora_path.replace("/", os.sep)
|
||||
lora_stack = [(lora_path, model_strength, clip_strength)]
|
||||
|
||||
# Calculate next index (wrap to 1 if at end)
|
||||
next_index = clamped_index + 1
|
||||
if next_index > total_count:
|
||||
if next_index > effective_total_count:
|
||||
next_index = 1
|
||||
|
||||
# Get next LoRA for UI display (what will be used next generation)
|
||||
next_lora = lora_list[next_index - 1]
|
||||
next_display_name = next_lora["file_name"]
|
||||
is_next_no_lora = include_no_lora and next_index == effective_total_count
|
||||
if is_next_no_lora:
|
||||
next_display_name = "No LoRA"
|
||||
next_lora_filename = "No LoRA"
|
||||
else:
|
||||
next_lora = lora_list[next_index - 1]
|
||||
next_display_name = next_lora["file_name"]
|
||||
next_lora_filename = next_lora["file_name"]
|
||||
|
||||
return {
|
||||
"result": (lora_stack,),
|
||||
"ui": {
|
||||
"current_index": [clamped_index],
|
||||
"next_index": [next_index],
|
||||
"total_count": [total_count],
|
||||
"current_lora_name": [current_lora["file_name"]],
|
||||
"current_lora_filename": [current_lora["file_name"]],
|
||||
"total_count": [
|
||||
total_count
|
||||
], # Return actual LoRA count, not effective_total_count
|
||||
"current_lora_name": [current_lora_name],
|
||||
"current_lora_filename": [current_lora_filename],
|
||||
"next_lora_name": [next_display_name],
|
||||
"next_lora_filename": [next_lora["file_name"]],
|
||||
"next_lora_filename": [next_lora_filename],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ class LoraPoolLM:
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"favoritesOnly": False,
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": [], "exclude": [], "useRegex": False},
|
||||
},
|
||||
"preview": {"matchCount": 0, "lastUpdated": 0},
|
||||
}
|
||||
|
||||
@@ -7,10 +7,8 @@ and tracks the last used combination for reuse.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
from ..utils.utils import get_lora_info
|
||||
from .utils import extract_lora_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
import torch
|
||||
import comfy.sd
|
||||
import comfy.sd # type: ignore
|
||||
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -101,6 +100,8 @@ class UNETLoaderLM:
|
||||
Returns:
|
||||
Tuple of (MODEL,)
|
||||
"""
|
||||
import torch
|
||||
|
||||
# Get absolute path from cache using ComfyUI-style name
|
||||
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
|
||||
|
||||
@@ -143,6 +144,7 @@ class UNETLoaderLM:
|
||||
Returns:
|
||||
Tuple of (MODEL,)
|
||||
"""
|
||||
import torch
|
||||
from .gguf_import_helper import get_gguf_modules
|
||||
|
||||
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
|
||||
|
||||
@@ -309,6 +309,13 @@ class ModelListingHandler:
|
||||
else:
|
||||
allow_selling_generated_content = None # None means no filter applied
|
||||
|
||||
# Name pattern filters for LoRA Pool
|
||||
name_pattern_include = request.query.getall("name_pattern_include", [])
|
||||
name_pattern_exclude = request.query.getall("name_pattern_exclude", [])
|
||||
name_pattern_use_regex = (
|
||||
request.query.get("name_pattern_use_regex", "false").lower() == "true"
|
||||
)
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
@@ -328,6 +335,9 @@ class ModelListingHandler:
|
||||
"credit_required": credit_required,
|
||||
"allow_selling_generated_content": allow_selling_generated_content,
|
||||
"model_types": model_types,
|
||||
"name_pattern_include": name_pattern_include,
|
||||
"name_pattern_exclude": name_pattern_exclude,
|
||||
"name_pattern_use_regex": name_pattern_use_regex,
|
||||
**self._parse_specific_params(request),
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class LoraService(BaseModelService):
|
||||
# Resolve sub_type using priority: sub_type > model_type > civitai.model.type > default
|
||||
# Normalize to lowercase for consistent API responses
|
||||
sub_type = resolve_sub_type(lora_data).lower()
|
||||
|
||||
|
||||
return {
|
||||
"model_name": lora_data["model_name"],
|
||||
"file_name": lora_data["file_name"],
|
||||
@@ -48,7 +48,9 @@ class LoraService(BaseModelService):
|
||||
"notes": lora_data.get("notes", ""),
|
||||
"favorite": lora_data.get("favorite", False),
|
||||
"update_available": bool(lora_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(lora_data.get("skip_metadata_refresh", False)),
|
||||
"skip_metadata_refresh": bool(
|
||||
lora_data.get("skip_metadata_refresh", False)
|
||||
),
|
||||
"sub_type": sub_type,
|
||||
"civitai": self.filter_civitai_data(
|
||||
lora_data.get("civitai", {}), minimal=True
|
||||
@@ -62,6 +64,68 @@ class LoraService(BaseModelService):
|
||||
if first_letter:
|
||||
data = self._filter_by_first_letter(data, first_letter)
|
||||
|
||||
# Handle name pattern filters
|
||||
name_pattern_include = kwargs.get("name_pattern_include", [])
|
||||
name_pattern_exclude = kwargs.get("name_pattern_exclude", [])
|
||||
name_pattern_use_regex = kwargs.get("name_pattern_use_regex", False)
|
||||
|
||||
if name_pattern_include or name_pattern_exclude:
|
||||
import re
|
||||
|
||||
def matches_pattern(name, pattern, use_regex):
|
||||
"""Check if name matches pattern (regex or substring)"""
|
||||
if not name:
|
||||
return False
|
||||
if use_regex:
|
||||
try:
|
||||
return bool(re.search(pattern, name, re.IGNORECASE))
|
||||
except re.error:
|
||||
# Invalid regex, fall back to substring match
|
||||
return pattern.lower() in name.lower()
|
||||
else:
|
||||
return pattern.lower() in name.lower()
|
||||
|
||||
def matches_any_pattern(name, patterns, use_regex):
|
||||
"""Check if name matches any of the patterns"""
|
||||
if not patterns:
|
||||
return True
|
||||
return any(matches_pattern(name, p, use_regex) for p in patterns)
|
||||
|
||||
filtered = []
|
||||
for lora in data:
|
||||
model_name = lora.get("model_name", "")
|
||||
file_name = lora.get("file_name", "")
|
||||
names_to_check = [n for n in [model_name, file_name] if n]
|
||||
|
||||
# Check exclude patterns first
|
||||
excluded = False
|
||||
if name_pattern_exclude:
|
||||
for name in names_to_check:
|
||||
if matches_any_pattern(
|
||||
name, name_pattern_exclude, name_pattern_use_regex
|
||||
):
|
||||
excluded = True
|
||||
break
|
||||
|
||||
if excluded:
|
||||
continue
|
||||
|
||||
# Check include patterns
|
||||
if name_pattern_include:
|
||||
included = False
|
||||
for name in names_to_check:
|
||||
if matches_any_pattern(
|
||||
name, name_pattern_include, name_pattern_use_regex
|
||||
):
|
||||
included = True
|
||||
break
|
||||
if not included:
|
||||
continue
|
||||
|
||||
filtered.append(lora)
|
||||
|
||||
data = filtered
|
||||
|
||||
return data
|
||||
|
||||
def _filter_by_first_letter(self, data: List[Dict], letter: str) -> List[Dict]:
|
||||
@@ -368,9 +432,7 @@ class LoraService(BaseModelService):
|
||||
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||
)
|
||||
else:
|
||||
clip_str = round(
|
||||
rng.uniform(clip_strength_min, clip_strength_max), 2
|
||||
)
|
||||
clip_str = round(rng.uniform(clip_strength_min, clip_strength_max), 2)
|
||||
|
||||
result_loras.append(
|
||||
{
|
||||
@@ -485,12 +547,69 @@ class LoraService(BaseModelService):
|
||||
if bool(lora.get("license_flags", 127) & (1 << 1))
|
||||
]
|
||||
|
||||
# Apply name pattern filters
|
||||
name_patterns = filter_section.get("namePatterns", {})
|
||||
include_patterns = name_patterns.get("include", [])
|
||||
exclude_patterns = name_patterns.get("exclude", [])
|
||||
use_regex = name_patterns.get("useRegex", False)
|
||||
|
||||
if include_patterns or exclude_patterns:
|
||||
import re
|
||||
|
||||
def matches_pattern(name, pattern, use_regex):
|
||||
"""Check if name matches pattern (regex or substring)"""
|
||||
if not name:
|
||||
return False
|
||||
if use_regex:
|
||||
try:
|
||||
return bool(re.search(pattern, name, re.IGNORECASE))
|
||||
except re.error:
|
||||
# Invalid regex, fall back to substring match
|
||||
return pattern.lower() in name.lower()
|
||||
else:
|
||||
return pattern.lower() in name.lower()
|
||||
|
||||
def matches_any_pattern(name, patterns, use_regex):
|
||||
"""Check if name matches any of the patterns"""
|
||||
if not patterns:
|
||||
return True
|
||||
return any(matches_pattern(name, p, use_regex) for p in patterns)
|
||||
|
||||
filtered = []
|
||||
for lora in available_loras:
|
||||
model_name = lora.get("model_name", "")
|
||||
file_name = lora.get("file_name", "")
|
||||
names_to_check = [n for n in [model_name, file_name] if n]
|
||||
|
||||
# Check exclude patterns first
|
||||
excluded = False
|
||||
if exclude_patterns:
|
||||
for name in names_to_check:
|
||||
if matches_any_pattern(name, exclude_patterns, use_regex):
|
||||
excluded = True
|
||||
break
|
||||
|
||||
if excluded:
|
||||
continue
|
||||
|
||||
# Check include patterns
|
||||
if include_patterns:
|
||||
included = False
|
||||
for name in names_to_check:
|
||||
if matches_any_pattern(name, include_patterns, use_regex):
|
||||
included = True
|
||||
break
|
||||
if not included:
|
||||
continue
|
||||
|
||||
filtered.append(lora)
|
||||
|
||||
available_loras = filtered
|
||||
|
||||
return available_loras
|
||||
|
||||
async def get_cycler_list(
|
||||
self,
|
||||
pool_config: Optional[Dict] = None,
|
||||
sort_by: str = "filename"
|
||||
self, pool_config: Optional[Dict] = None, sort_by: str = "filename"
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get filtered and sorted LoRA list for cycling.
|
||||
@@ -518,16 +637,16 @@ class LoraService(BaseModelService):
|
||||
available_loras,
|
||||
key=lambda x: (
|
||||
(x.get("model_name") or x.get("file_name", "")).lower(),
|
||||
x.get("file_path", "").lower()
|
||||
)
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
)
|
||||
else: # Default to filename
|
||||
available_loras = sorted(
|
||||
available_loras,
|
||||
key=lambda x: (
|
||||
x.get("file_name", "").lower(),
|
||||
x.get("file_path", "").lower()
|
||||
)
|
||||
x.get("file_path", "").lower(),
|
||||
),
|
||||
)
|
||||
|
||||
# Return minimal data needed for cycling
|
||||
|
||||
@@ -14,7 +14,6 @@ from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.civitai_utils import resolve_license_info
|
||||
from .model_cache import ModelCache
|
||||
from .model_hash_index import ModelHashIndex
|
||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||
from .model_lifecycle_service import delete_model_artifacts
|
||||
from .service_registry import ServiceRegistry
|
||||
from .websocket_manager import ws_manager
|
||||
@@ -1442,14 +1441,13 @@ class ModelScanner:
|
||||
file_path = self._hash_index.get_path(sha256.lower())
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(file_path)[0]
|
||||
|
||||
for ext in PREVIEW_EXTENSIONS:
|
||||
preview_path = f"{base_name}{ext}"
|
||||
if os.path.exists(preview_path):
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
|
||||
dir_path = os.path.dirname(file_path)
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
preview_path = find_preview_file(base_name, dir_path)
|
||||
if preview_path:
|
||||
return config.get_preview_static_url(preview_path)
|
||||
|
||||
return None
|
||||
|
||||
async def get_top_tags(self, limit: int = 20) -> List[Dict[str, any]]:
|
||||
|
||||
@@ -40,49 +40,39 @@ async def calculate_sha256(file_path: str) -> str:
|
||||
return sha256_hash.hexdigest()
|
||||
|
||||
def find_preview_file(base_name: str, dir_path: str) -> str:
|
||||
"""Find preview file for given base name in directory"""
|
||||
|
||||
"""Find preview file for given base name in directory.
|
||||
|
||||
Performs an exact-case check first (fast path), then falls back to a
|
||||
case-insensitive scan so that files like ``model.WEBP`` or ``model.Png``
|
||||
are discovered on case-sensitive filesystems.
|
||||
"""
|
||||
|
||||
temp_extensions = PREVIEW_EXTENSIONS.copy()
|
||||
# Add example extension for compatibility
|
||||
# https://github.com/willmiao/ComfyUI-Lora-Manager/issues/225
|
||||
# The preview image will be optimized to lora-name.webp, so it won't affect other logic
|
||||
temp_extensions.append(".example.0.jpeg")
|
||||
|
||||
# Fast path: exact-case match
|
||||
for ext in temp_extensions:
|
||||
full_pattern = os.path.join(dir_path, f"{base_name}{ext}")
|
||||
if os.path.exists(full_pattern):
|
||||
# Check if this is an image and not already webp
|
||||
# TODO: disable the optimization for now, maybe add a config option later
|
||||
# if ext.lower().endswith(('.jpg', '.jpeg', '.png')) and not ext.lower().endswith('.webp'):
|
||||
# try:
|
||||
# # Optimize the image to webp format
|
||||
# webp_path = os.path.join(dir_path, f"{base_name}.webp")
|
||||
|
||||
# # Use ExifUtils to optimize the image
|
||||
# with open(full_pattern, 'rb') as f:
|
||||
# image_data = f.read()
|
||||
|
||||
# optimized_data, _ = ExifUtils.optimize_image(
|
||||
# image_data=image_data,
|
||||
# target_width=CARD_PREVIEW_WIDTH,
|
||||
# format='webp',
|
||||
# quality=85,
|
||||
# preserve_metadata=False
|
||||
# )
|
||||
|
||||
# # Save the optimized webp file
|
||||
# with open(webp_path, 'wb') as f:
|
||||
# f.write(optimized_data)
|
||||
|
||||
# logger.debug(f"Optimized preview image from {full_pattern} to {webp_path}")
|
||||
# return webp_path.replace(os.sep, "/")
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error optimizing preview image {full_pattern}: {e}")
|
||||
# # Fall back to original file if optimization fails
|
||||
# return full_pattern.replace(os.sep, "/")
|
||||
|
||||
# Return the original path for webp images or non-image files
|
||||
return full_pattern.replace(os.sep, "/")
|
||||
|
||||
|
||||
# Slow path: case-insensitive match for systems with mixed-case extensions
|
||||
# (e.g. .WEBP, .Png, .JPG placed manually or by external tools)
|
||||
try:
|
||||
dir_entries = os.listdir(dir_path)
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
base_lower = base_name.lower()
|
||||
for ext in temp_extensions:
|
||||
target = f"{base_lower}{ext}" # ext is already lowercase
|
||||
for entry in dir_entries:
|
||||
if entry.lower() == target:
|
||||
return os.path.join(dir_path, entry).replace(os.sep, "/")
|
||||
|
||||
return ""
|
||||
|
||||
def get_preview_extension(preview_path: str) -> str:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
addopts = -v --import-mode=importlib -m "not performance"
|
||||
addopts = -v --import-mode=importlib -m "not performance" --ignore=__init__.py
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
|
||||
@@ -251,7 +251,7 @@ export class BaseModelApiClient {
|
||||
replaceModelPreview(filePath) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*,video/mp4';
|
||||
input.accept = 'image/*,image/webp,video/mp4';
|
||||
|
||||
input.onchange = async () => {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
|
||||
@@ -369,3 +369,289 @@ async def test_pool_filter_combined_all_filters(lora_service):
|
||||
# - tags: tag1 ✓
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["file_name"] == "match_all.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_filter_name_patterns_include_text(lora_service):
|
||||
"""Test filtering by name patterns with text matching (useRegex=False)."""
|
||||
sample_loras = [
|
||||
{
|
||||
"file_name": "character_anime_v1.safetensors",
|
||||
"model_name": "Anime Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "character_realistic_v1.safetensors",
|
||||
"model_name": "Realistic Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "style_watercolor_v1.safetensors",
|
||||
"model_name": "Watercolor Style",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
]
|
||||
|
||||
# Test include patterns with text matching
|
||||
pool_config = {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": ["character"], "exclude": [], "useRegex": False},
|
||||
}
|
||||
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
assert len(filtered) == 2
|
||||
file_names = {lora["file_name"] for lora in filtered}
|
||||
assert file_names == {
|
||||
"character_anime_v1.safetensors",
|
||||
"character_realistic_v1.safetensors",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_filter_name_patterns_exclude_text(lora_service):
|
||||
"""Test excluding by name patterns with text matching (useRegex=False)."""
|
||||
sample_loras = [
|
||||
{
|
||||
"file_name": "character_anime_v1.safetensors",
|
||||
"model_name": "Anime Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "character_realistic_v1.safetensors",
|
||||
"model_name": "Realistic Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "style_watercolor_v1.safetensors",
|
||||
"model_name": "Watercolor Style",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
]
|
||||
|
||||
# Test exclude patterns with text matching
|
||||
pool_config = {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": [], "exclude": ["anime"], "useRegex": False},
|
||||
}
|
||||
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
assert len(filtered) == 2
|
||||
file_names = {lora["file_name"] for lora in filtered}
|
||||
assert file_names == {
|
||||
"character_realistic_v1.safetensors",
|
||||
"style_watercolor_v1.safetensors",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_filter_name_patterns_include_regex(lora_service):
|
||||
"""Test filtering by name patterns with regex matching (useRegex=True)."""
|
||||
sample_loras = [
|
||||
{
|
||||
"file_name": "character_anime_v1.safetensors",
|
||||
"model_name": "Anime Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "character_realistic_v1.safetensors",
|
||||
"model_name": "Realistic Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "style_watercolor_v1.safetensors",
|
||||
"model_name": "Watercolor Style",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
]
|
||||
|
||||
# Test include patterns with regex matching - match files starting with "character_"
|
||||
pool_config = {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": ["^character_"], "exclude": [], "useRegex": True},
|
||||
}
|
||||
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
assert len(filtered) == 2
|
||||
file_names = {lora["file_name"] for lora in filtered}
|
||||
assert file_names == {
|
||||
"character_anime_v1.safetensors",
|
||||
"character_realistic_v1.safetensors",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_filter_name_patterns_exclude_regex(lora_service):
|
||||
"""Test excluding by name patterns with regex matching (useRegex=True)."""
|
||||
sample_loras = [
|
||||
{
|
||||
"file_name": "character_anime_v1.safetensors",
|
||||
"model_name": "Anime Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "character_realistic_v1.safetensors",
|
||||
"model_name": "Realistic Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "style_watercolor_v1.safetensors",
|
||||
"model_name": "Watercolor Style",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
]
|
||||
|
||||
# Test exclude patterns with regex matching - exclude files ending with "_v1.safetensors"
|
||||
pool_config = {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {
|
||||
"include": [],
|
||||
"exclude": ["_v1\\.safetensors$"],
|
||||
"useRegex": True,
|
||||
},
|
||||
}
|
||||
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
assert len(filtered) == 0 # All files match the exclude pattern
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_filter_name_patterns_combined(lora_service):
|
||||
"""Test combining include and exclude name patterns."""
|
||||
sample_loras = [
|
||||
{
|
||||
"file_name": "character_anime_v1.safetensors",
|
||||
"model_name": "Anime Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "character_realistic_v1.safetensors",
|
||||
"model_name": "Realistic Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "style_watercolor_v1.safetensors",
|
||||
"model_name": "Watercolor Style",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
]
|
||||
|
||||
# Test include "character" but exclude "anime"
|
||||
pool_config = {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {
|
||||
"include": ["character"],
|
||||
"exclude": ["anime"],
|
||||
"useRegex": False,
|
||||
},
|
||||
}
|
||||
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["file_name"] == "character_realistic_v1.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_filter_name_patterns_model_name_fallback(lora_service):
|
||||
"""Test that name pattern filtering falls back to model_name when file_name doesn't match."""
|
||||
sample_loras = [
|
||||
{
|
||||
"file_name": "abc123.safetensors",
|
||||
"model_name": "Super Anime Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
{
|
||||
"file_name": "def456.safetensors",
|
||||
"model_name": "Realistic Portrait",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
]
|
||||
|
||||
# Should match model_name even if file_name doesn't contain the pattern
|
||||
pool_config = {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": ["anime"], "exclude": [], "useRegex": False},
|
||||
}
|
||||
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0]["file_name"] == "abc123.safetensors"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pool_filter_name_patterns_invalid_regex(lora_service):
|
||||
"""Test that invalid regex falls back to substring matching."""
|
||||
sample_loras = [
|
||||
{
|
||||
"file_name": "character_anime[test]_v1.safetensors",
|
||||
"model_name": "Anime Character",
|
||||
"base_model": "Illustrious",
|
||||
"folder": "",
|
||||
"license_flags": build_license_flags(None),
|
||||
},
|
||||
]
|
||||
|
||||
# Invalid regex pattern (unclosed character class) should fall back to substring matching
|
||||
# The pattern "anime[" is invalid regex but valid substring - it exists in the filename
|
||||
pool_config = {
|
||||
"baseModels": [],
|
||||
"tags": {"include": [], "exclude": []},
|
||||
"folders": {"include": [], "exclude": []},
|
||||
"license": {"noCreditRequired": False, "allowSelling": False},
|
||||
"namePatterns": {"include": ["anime["], "exclude": [], "useRegex": True},
|
||||
}
|
||||
|
||||
# Should not crash and should match using substring fallback
|
||||
filtered = await lora_service._apply_pool_filters(sample_loras, pool_config)
|
||||
assert len(filtered) == 1 # Substring match works even with invalid regex
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="lora-cycler-widget">
|
||||
<LoraCyclerSettingsView
|
||||
:current-index="state.currentIndex.value"
|
||||
:total-count="state.totalCount.value"
|
||||
:current-lora-name="state.currentLoraName.value"
|
||||
:total-count="displayTotalCount"
|
||||
:current-lora-name="displayLoraName"
|
||||
:current-lora-filename="state.currentLoraFilename.value"
|
||||
:model-strength="state.modelStrength.value"
|
||||
:clip-strength="state.clipStrength.value"
|
||||
@@ -16,11 +16,14 @@
|
||||
:is-pause-disabled="hasQueuedPrompts"
|
||||
:is-workflow-executing="state.isWorkflowExecuting.value"
|
||||
:executing-repeat-step="state.executingRepeatStep.value"
|
||||
:include-no-lora="state.includeNoLora.value"
|
||||
:is-no-lora="isNoLora"
|
||||
@update:current-index="handleIndexUpdate"
|
||||
@update:model-strength="state.modelStrength.value = $event"
|
||||
@update:clip-strength="state.clipStrength.value = $event"
|
||||
@update:use-custom-clip-range="handleUseCustomClipRangeChange"
|
||||
@update:repeat-count="handleRepeatCountChange"
|
||||
@update:include-no-lora="handleIncludeNoLoraChange"
|
||||
@toggle-pause="handleTogglePause"
|
||||
@reset-index="handleResetIndex"
|
||||
@open-lora-selector="isModalOpen = true"
|
||||
@@ -30,6 +33,7 @@
|
||||
:visible="isModalOpen"
|
||||
:lora-list="cachedLoraList"
|
||||
:current-index="state.currentIndex.value"
|
||||
:include-no-lora="state.includeNoLora.value"
|
||||
@close="isModalOpen = false"
|
||||
@select="handleModalSelect"
|
||||
/>
|
||||
@@ -37,7 +41,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import LoraCyclerSettingsView from './lora-cycler/LoraCyclerSettingsView.vue'
|
||||
import LoraListModal from './lora-cycler/LoraListModal.vue'
|
||||
import { useLoraCyclerState } from '../composables/useLoraCyclerState'
|
||||
@@ -102,6 +106,31 @@ const isModalOpen = ref(false)
|
||||
// Cache for LoRA list (used by modal)
|
||||
const cachedLoraList = ref<LoraItem[]>([])
|
||||
|
||||
// Computed: display total count (includes no lora option if enabled)
|
||||
const displayTotalCount = computed(() => {
|
||||
const baseCount = state.totalCount.value
|
||||
return state.includeNoLora.value ? baseCount + 1 : baseCount
|
||||
})
|
||||
|
||||
// Computed: display LoRA name (shows "No LoRA" if on the last index and includeNoLora is enabled)
|
||||
const displayLoraName = computed(() => {
|
||||
const currentIndex = state.currentIndex.value
|
||||
const totalCount = state.totalCount.value
|
||||
|
||||
// If includeNoLora is enabled and we're on the last position (no lora slot)
|
||||
if (state.includeNoLora.value && currentIndex === totalCount + 1) {
|
||||
return 'No LoRA'
|
||||
}
|
||||
|
||||
// Otherwise show the normal LoRA name
|
||||
return state.currentLoraName.value
|
||||
})
|
||||
|
||||
// Computed: check if currently on "No LoRA" option
|
||||
const isNoLora = computed(() => {
|
||||
return state.includeNoLora.value && state.currentIndex.value === state.totalCount.value + 1
|
||||
})
|
||||
|
||||
// Get pool config from connected node
|
||||
const getPoolConfig = (): LoraPoolConfig | null => {
|
||||
// Check if getPoolConfig method exists on node (added by main.ts)
|
||||
@@ -113,7 +142,17 @@ const getPoolConfig = (): LoraPoolConfig | null => {
|
||||
|
||||
// Update display from LoRA list and index
|
||||
const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
|
||||
if (loraList.length > 0 && index > 0 && index <= loraList.length) {
|
||||
const actualLoraCount = loraList.length
|
||||
|
||||
// If index is beyond actual LoRA count, it means we're on the "no lora" option
|
||||
if (state.includeNoLora.value && index === actualLoraCount + 1) {
|
||||
state.currentLoraName.value = 'No LoRA'
|
||||
state.currentLoraFilename.value = 'No LoRA'
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, show normal LoRA info
|
||||
if (actualLoraCount > 0 && index > 0 && index <= actualLoraCount) {
|
||||
const currentLora = loraList[index - 1]
|
||||
if (currentLora) {
|
||||
state.currentLoraName.value = currentLora.file_name
|
||||
@@ -124,6 +163,14 @@ const updateDisplayFromLoraList = (loraList: LoraItem[], index: number) => {
|
||||
|
||||
// Handle index update from user
|
||||
const handleIndexUpdate = async (newIndex: number) => {
|
||||
// Calculate max valid index (includes no lora slot if enabled)
|
||||
const maxIndex = state.includeNoLora.value
|
||||
? state.totalCount.value + 1
|
||||
: state.totalCount.value
|
||||
|
||||
// Clamp index to valid range
|
||||
const clampedIndex = Math.max(1, Math.min(newIndex, maxIndex || 1))
|
||||
|
||||
// Reset execution state when user manually changes index
|
||||
// This ensures the next execution starts from the user-set index
|
||||
;(props.widget as any)[HAS_EXECUTED] = false
|
||||
@@ -134,14 +181,14 @@ const handleIndexUpdate = async (newIndex: number) => {
|
||||
executionQueue.length = 0
|
||||
hasQueuedPrompts.value = false
|
||||
|
||||
state.setIndex(newIndex)
|
||||
state.setIndex(clampedIndex)
|
||||
|
||||
// Refresh list to update current LoRA display
|
||||
try {
|
||||
const poolConfig = getPoolConfig()
|
||||
const loraList = await state.fetchCyclerList(poolConfig)
|
||||
cachedLoraList.value = loraList
|
||||
updateDisplayFromLoraList(loraList, newIndex)
|
||||
updateDisplayFromLoraList(loraList, clampedIndex)
|
||||
} catch (error) {
|
||||
console.error('[LoraCyclerWidget] Error updating index:', error)
|
||||
}
|
||||
@@ -169,6 +216,17 @@ const handleRepeatCountChange = (newValue: number) => {
|
||||
state.displayRepeatUsed.value = 0
|
||||
}
|
||||
|
||||
// Handle include no lora toggle
|
||||
const handleIncludeNoLoraChange = (newValue: boolean) => {
|
||||
state.includeNoLora.value = newValue
|
||||
|
||||
// If turning off and current index is beyond the actual LoRA count,
|
||||
// clamp it to the last valid LoRA index
|
||||
if (!newValue && state.currentIndex.value > state.totalCount.value) {
|
||||
state.currentIndex.value = Math.max(1, state.totalCount.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle pause toggle
|
||||
const handleTogglePause = () => {
|
||||
state.togglePause()
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
:exclude-tags="state.excludeTags.value"
|
||||
:include-folders="state.includeFolders.value"
|
||||
:exclude-folders="state.excludeFolders.value"
|
||||
:include-patterns="state.includePatterns.value"
|
||||
:exclude-patterns="state.excludePatterns.value"
|
||||
:use-regex="state.useRegex.value"
|
||||
:no-credit-required="state.noCreditRequired.value"
|
||||
:allow-selling="state.allowSelling.value"
|
||||
:preview-items="state.previewItems.value"
|
||||
@@ -16,6 +19,9 @@
|
||||
@open-modal="openModal"
|
||||
@update:include-folders="state.includeFolders.value = $event"
|
||||
@update:exclude-folders="state.excludeFolders.value = $event"
|
||||
@update:include-patterns="state.includePatterns.value = $event"
|
||||
@update:exclude-patterns="state.excludePatterns.value = $event"
|
||||
@update:use-regex="state.useRegex.value = $event"
|
||||
@update:no-credit-required="state.noCreditRequired.value = $event"
|
||||
@update:allow-selling="state.allowSelling.value = $event"
|
||||
@refresh="state.refreshPreview"
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
@click="handleOpenSelector"
|
||||
>
|
||||
<span class="progress-label">{{ isWorkflowExecuting ? 'Using LoRA:' : 'Next LoRA:' }}</span>
|
||||
<span class="progress-name clickable" :class="{ disabled: isPauseDisabled }" :title="currentLoraFilename">
|
||||
<span class="progress-name clickable"
|
||||
:class="{ disabled: isPauseDisabled, 'no-lora': isNoLora }"
|
||||
:title="currentLoraFilename">
|
||||
{{ currentLoraName || 'None' }}
|
||||
<svg class="selector-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
@@ -160,6 +162,27 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include No LoRA Toggle -->
|
||||
<div class="setting-section">
|
||||
<div class="section-header-with-toggle">
|
||||
<label class="setting-label">
|
||||
Add "No LoRA" step
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-switch"
|
||||
:class="{ 'toggle-switch--active': includeNoLora }"
|
||||
@click="$emit('update:includeNoLora', !includeNoLora)"
|
||||
role="switch"
|
||||
:aria-checked="includeNoLora"
|
||||
title="Add an iteration without LoRA for comparison"
|
||||
>
|
||||
<span class="toggle-switch__track"></span>
|
||||
<span class="toggle-switch__thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -182,6 +205,8 @@ const props = defineProps<{
|
||||
isPauseDisabled: boolean
|
||||
isWorkflowExecuting: boolean
|
||||
executingRepeatStep: number
|
||||
includeNoLora: boolean
|
||||
isNoLora?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -190,6 +215,7 @@ const emit = defineEmits<{
|
||||
'update:clipStrength': [value: number]
|
||||
'update:useCustomClipRange': [value: boolean]
|
||||
'update:repeatCount': [value: number]
|
||||
'update:includeNoLora': [value: boolean]
|
||||
'toggle-pause': []
|
||||
'reset-index': []
|
||||
'open-lora-selector': []
|
||||
@@ -346,6 +372,16 @@ const onRepeatBlur = (event: Event) => {
|
||||
color: rgba(191, 219, 254, 1);
|
||||
}
|
||||
|
||||
.progress-name.no-lora {
|
||||
font-style: italic;
|
||||
color: rgba(226, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
.progress-name.clickable.no-lora:hover:not(.disabled) {
|
||||
background: rgba(160, 174, 192, 0.2);
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
|
||||
.progress-name.clickable.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -35,7 +35,10 @@
|
||||
v-for="item in filteredList"
|
||||
:key="item.index"
|
||||
class="lora-item"
|
||||
:class="{ active: currentIndex === item.index }"
|
||||
:class="{
|
||||
active: currentIndex === item.index,
|
||||
'no-lora-item': item.lora.file_name === 'No LoRA'
|
||||
}"
|
||||
@mouseenter="showPreview(item.lora.file_name, $event)"
|
||||
@mouseleave="hidePreview"
|
||||
@click="selectLora(item.index)"
|
||||
@@ -65,6 +68,7 @@ const props = defineProps<{
|
||||
visible: boolean
|
||||
loraList: LoraItem[]
|
||||
currentIndex: number
|
||||
includeNoLora?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -79,7 +83,8 @@ const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
let previewTooltip: any = null
|
||||
|
||||
const subtitleText = computed(() => {
|
||||
const total = props.loraList.length
|
||||
const baseTotal = props.loraList.length
|
||||
const total = props.includeNoLora ? baseTotal + 1 : baseTotal
|
||||
const filtered = filteredList.value.length
|
||||
if (filtered === total) {
|
||||
return `Total: ${total} LoRA${total !== 1 ? 's' : ''}`
|
||||
@@ -88,11 +93,19 @@ const subtitleText = computed(() => {
|
||||
})
|
||||
|
||||
const filteredList = computed<LoraListItem[]>(() => {
|
||||
const list = props.loraList.map((lora, idx) => ({
|
||||
const list: LoraListItem[] = props.loraList.map((lora, idx) => ({
|
||||
index: idx + 1,
|
||||
lora
|
||||
}))
|
||||
|
||||
// Add "No LoRA" option at the end if includeNoLora is enabled
|
||||
if (props.includeNoLora) {
|
||||
list.push({
|
||||
index: list.length + 1,
|
||||
lora: { file_name: 'No LoRA' } as LoraItem
|
||||
})
|
||||
}
|
||||
|
||||
if (!searchQuery.value.trim()) {
|
||||
return list
|
||||
}
|
||||
@@ -303,6 +316,15 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.lora-item.no-lora-item .lora-name {
|
||||
font-style: italic;
|
||||
color: rgba(226, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
.lora-item.no-lora-item:hover .lora-name {
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
@edit-exclude="$emit('open-modal', 'excludeFolders')"
|
||||
/>
|
||||
|
||||
<NamePatternsSection
|
||||
:include-patterns="includePatterns"
|
||||
:exclude-patterns="excludePatterns"
|
||||
:use-regex="useRegex"
|
||||
@update:include-patterns="$emit('update:includePatterns', $event)"
|
||||
@update:exclude-patterns="$emit('update:excludePatterns', $event)"
|
||||
@update:use-regex="$emit('update:useRegex', $event)"
|
||||
/>
|
||||
|
||||
<LicenseSection
|
||||
:no-credit-required="noCreditRequired"
|
||||
:allow-selling="allowSelling"
|
||||
@@ -46,6 +55,7 @@
|
||||
import BaseModelSection from './sections/BaseModelSection.vue'
|
||||
import TagsSection from './sections/TagsSection.vue'
|
||||
import FoldersSection from './sections/FoldersSection.vue'
|
||||
import NamePatternsSection from './sections/NamePatternsSection.vue'
|
||||
import LicenseSection from './sections/LicenseSection.vue'
|
||||
import LoraPoolPreview from './LoraPoolPreview.vue'
|
||||
import type { BaseModelOption, LoraItem } from '../../composables/types'
|
||||
@@ -61,6 +71,10 @@ defineProps<{
|
||||
// Folders
|
||||
includeFolders: string[]
|
||||
excludeFolders: string[]
|
||||
// Name patterns
|
||||
includePatterns: string[]
|
||||
excludePatterns: string[]
|
||||
useRegex: boolean
|
||||
// License
|
||||
noCreditRequired: boolean
|
||||
allowSelling: boolean
|
||||
@@ -74,6 +88,9 @@ defineEmits<{
|
||||
'open-modal': [modal: ModalType]
|
||||
'update:includeFolders': [value: string[]]
|
||||
'update:excludeFolders': [value: string[]]
|
||||
'update:includePatterns': [value: string[]]
|
||||
'update:excludePatterns': [value: string[]]
|
||||
'update:useRegex': [value: boolean]
|
||||
'update:noCreditRequired': [value: boolean]
|
||||
'update:allowSelling': [value: boolean]
|
||||
refresh: []
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<span class="section__title">NAME PATTERNS</span>
|
||||
<label class="section__toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="useRegex"
|
||||
@change="$emit('update:useRegex', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="section__toggle-label">Use Regex</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section__columns">
|
||||
<!-- Include column -->
|
||||
<div class="section__column">
|
||||
<div class="section__column-header">
|
||||
<span class="section__column-title section__column-title--include">INCLUDE</span>
|
||||
</div>
|
||||
<div class="section__input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
v-model="includeInput"
|
||||
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
|
||||
class="section__input"
|
||||
@keydown.enter="addInclude"
|
||||
/>
|
||||
<button type="button" class="section__add-btn" @click="addInclude">+</button>
|
||||
</div>
|
||||
<div class="section__patterns">
|
||||
<FilterChip
|
||||
v-for="pattern in includePatterns"
|
||||
:key="pattern"
|
||||
:label="pattern"
|
||||
variant="include"
|
||||
removable
|
||||
@remove="removeInclude(pattern)"
|
||||
/>
|
||||
<div v-if="includePatterns.length === 0" class="section__empty">
|
||||
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exclude column -->
|
||||
<div class="section__column">
|
||||
<div class="section__column-header">
|
||||
<span class="section__column-title section__column-title--exclude">EXCLUDE</span>
|
||||
</div>
|
||||
<div class="section__input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
v-model="excludeInput"
|
||||
:placeholder="useRegex ? 'Add regex pattern...' : 'Add text pattern...'"
|
||||
class="section__input"
|
||||
@keydown.enter="addExclude"
|
||||
/>
|
||||
<button type="button" class="section__add-btn" @click="addExclude">+</button>
|
||||
</div>
|
||||
<div class="section__patterns">
|
||||
<FilterChip
|
||||
v-for="pattern in excludePatterns"
|
||||
:key="pattern"
|
||||
:label="pattern"
|
||||
variant="exclude"
|
||||
removable
|
||||
@remove="removeExclude(pattern)"
|
||||
/>
|
||||
<div v-if="excludePatterns.length === 0" class="section__empty">
|
||||
{{ useRegex ? 'No regex patterns' : 'No text patterns' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import FilterChip from '../shared/FilterChip.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
includePatterns: string[]
|
||||
excludePatterns: string[]
|
||||
useRegex: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:includePatterns': [value: string[]]
|
||||
'update:excludePatterns': [value: string[]]
|
||||
'update:useRegex': [value: boolean]
|
||||
}>()
|
||||
|
||||
const includeInput = ref('')
|
||||
const excludeInput = ref('')
|
||||
|
||||
const addInclude = () => {
|
||||
const pattern = includeInput.value.trim()
|
||||
if (pattern && !props.includePatterns.includes(pattern)) {
|
||||
emit('update:includePatterns', [...props.includePatterns, pattern])
|
||||
includeInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const addExclude = () => {
|
||||
const pattern = excludeInput.value.trim()
|
||||
if (pattern && !props.excludePatterns.includes(pattern)) {
|
||||
emit('update:excludePatterns', [...props.excludePatterns, pattern])
|
||||
excludeInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeInclude = (pattern: string) => {
|
||||
emit('update:includePatterns', props.includePatterns.filter(p => p !== pattern))
|
||||
}
|
||||
|
||||
const removeExclude = (pattern: string) => {
|
||||
emit('update:excludePatterns', props.excludePatterns.filter(p => p !== pattern))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.section__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section__toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section__toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section__column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.section__column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.section__column-title {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.section__column-title--include {
|
||||
color: #4299e1;
|
||||
}
|
||||
|
||||
.section__column-title--exclude {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.section__input-wrapper {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--comfy-input-border, #444);
|
||||
border-radius: 4px;
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.section__input:focus {
|
||||
border-color: #4299e1;
|
||||
}
|
||||
|
||||
.section__add-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border: 1px solid var(--comfy-input-border, #444);
|
||||
border-radius: 4px;
|
||||
color: var(--fg-color, #fff);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.section__add-btn:hover {
|
||||
background: var(--comfy-input-bg-hover, #444);
|
||||
border-color: #4299e1;
|
||||
}
|
||||
|
||||
.section__patterns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.section__empty {
|
||||
font-size: 10px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.3;
|
||||
font-style: italic;
|
||||
min-height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,12 @@ export interface LoraPoolConfig {
|
||||
noCreditRequired: boolean
|
||||
allowSelling: boolean
|
||||
}
|
||||
namePatterns: {
|
||||
include: string[]
|
||||
exclude: string[]
|
||||
useRegex: boolean
|
||||
}
|
||||
includeEmptyLora?: boolean // Optional, deprecated (moved to Cycler)
|
||||
}
|
||||
preview: { matchCount: number; lastUpdated: number }
|
||||
}
|
||||
@@ -84,6 +90,8 @@ export interface CyclerConfig {
|
||||
repeat_count: number // How many times each LoRA should repeat (default: 1)
|
||||
repeat_used: number // How many times current index has been used
|
||||
is_paused: boolean // Whether iteration is paused
|
||||
// Include "no LoRA" option in cycle
|
||||
include_no_lora: boolean // Whether to include empty LoRA option
|
||||
}
|
||||
|
||||
// Widget config union type
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ComponentWidget, CyclerConfig, LoraPoolConfig } from './types'
|
||||
export interface CyclerLoraItem {
|
||||
file_name: string
|
||||
model_name: string
|
||||
file_path: string
|
||||
}
|
||||
|
||||
export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
@@ -34,6 +35,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
const repeatUsed = ref(0) // How many times current index has been used (internal tracking)
|
||||
const displayRepeatUsed = ref(0) // For UI display, deferred updates like currentIndex
|
||||
const isPaused = ref(false) // Whether iteration is paused
|
||||
const includeNoLora = ref(false) // Whether to include empty LoRA option in cycle
|
||||
|
||||
// Execution progress tracking (visual feedback)
|
||||
const isWorkflowExecuting = ref(false) // Workflow is currently running
|
||||
@@ -58,6 +60,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
repeat_count: repeatCount.value,
|
||||
repeat_used: repeatUsed.value,
|
||||
is_paused: isPaused.value,
|
||||
include_no_lora: includeNoLora.value,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -75,6 +78,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
repeat_count: repeatCount.value,
|
||||
repeat_used: repeatUsed.value,
|
||||
is_paused: isPaused.value,
|
||||
include_no_lora: includeNoLora.value,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,12 +97,13 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
sortBy.value = config.sort_by || 'filename'
|
||||
currentLoraName.value = config.current_lora_name || ''
|
||||
currentLoraFilename.value = config.current_lora_filename || ''
|
||||
// Advanced index control features
|
||||
repeatCount.value = config.repeat_count ?? 1
|
||||
repeatUsed.value = config.repeat_used ?? 0
|
||||
isPaused.value = config.is_paused ?? false
|
||||
// Note: execution_index and next_index are not restored from config
|
||||
// as they are transient values used only during batch execution
|
||||
// Advanced index control features
|
||||
repeatCount.value = config.repeat_count ?? 1
|
||||
repeatUsed.value = config.repeat_used ?? 0
|
||||
isPaused.value = config.is_paused ?? false
|
||||
includeNoLora.value = config.include_no_lora ?? false
|
||||
// Note: execution_index and next_index are not restored from config
|
||||
// as they are transient values used only during batch execution
|
||||
} finally {
|
||||
isRestoring = false
|
||||
}
|
||||
@@ -111,7 +116,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
// Calculate the next index (wrap to 1 if at end)
|
||||
const current = executionIndex.value ?? currentIndex.value
|
||||
let next = current + 1
|
||||
if (totalCount.value > 0 && next > totalCount.value) {
|
||||
// Total count includes no lora option if enabled
|
||||
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
|
||||
if (effectiveTotalCount > 0 && next > effectiveTotalCount) {
|
||||
next = 1
|
||||
}
|
||||
nextIndex.value = next
|
||||
@@ -122,7 +129,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
if (nextIndex.value === null) {
|
||||
// First execution uses current_index, so next is current + 1
|
||||
let next = currentIndex.value + 1
|
||||
if (totalCount.value > 0 && next > totalCount.value) {
|
||||
// Total count includes no lora option if enabled
|
||||
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
|
||||
if (effectiveTotalCount > 0 && next > effectiveTotalCount) {
|
||||
next = 1
|
||||
}
|
||||
nextIndex.value = next
|
||||
@@ -230,7 +239,9 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
|
||||
// Set index manually
|
||||
const setIndex = (index: number) => {
|
||||
if (index >= 1 && index <= totalCount.value) {
|
||||
// Total count includes no lora option if enabled
|
||||
const effectiveTotalCount = includeNoLora.value ? totalCount.value + 1 : totalCount.value
|
||||
if (index >= 1 && index <= effectiveTotalCount) {
|
||||
currentIndex.value = index
|
||||
}
|
||||
}
|
||||
@@ -272,6 +283,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
repeatCount,
|
||||
repeatUsed,
|
||||
isPaused,
|
||||
includeNoLora,
|
||||
], () => {
|
||||
widget.value = buildConfig()
|
||||
}, { deep: true })
|
||||
@@ -294,6 +306,7 @@ export function useLoraCyclerState(widget: ComponentWidget<CyclerConfig>) {
|
||||
repeatUsed,
|
||||
displayRepeatUsed,
|
||||
isPaused,
|
||||
includeNoLora,
|
||||
isWorkflowExecuting,
|
||||
executingRepeatStep,
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ export function useLoraPoolApi() {
|
||||
foldersExclude?: string[]
|
||||
noCreditRequired?: boolean
|
||||
allowSelling?: boolean
|
||||
namePatternsInclude?: string[]
|
||||
namePatternsExclude?: string[]
|
||||
namePatternsUseRegex?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
@@ -92,6 +95,13 @@ export function useLoraPoolApi() {
|
||||
urlParams.set('allow_selling_generated_content', String(params.allowSelling))
|
||||
}
|
||||
|
||||
// Name pattern filters
|
||||
params.namePatternsInclude?.forEach(pattern => urlParams.append('name_pattern_include', pattern))
|
||||
params.namePatternsExclude?.forEach(pattern => urlParams.append('name_pattern_exclude', pattern))
|
||||
if (params.namePatternsUseRegex !== undefined) {
|
||||
urlParams.set('name_pattern_use_regex', String(params.namePatternsUseRegex))
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/lm/loras/list?${urlParams}`)
|
||||
const data = await response.json()
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
const excludeFolders = ref<string[]>([])
|
||||
const noCreditRequired = ref(false)
|
||||
const allowSelling = ref(false)
|
||||
const includePatterns = ref<string[]>([])
|
||||
const excludePatterns = ref<string[]>([])
|
||||
const useRegex = ref(false)
|
||||
|
||||
// Available options from API
|
||||
const availableBaseModels = ref<BaseModelOption[]>([])
|
||||
@@ -52,6 +55,11 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
license: {
|
||||
noCreditRequired: noCreditRequired.value,
|
||||
allowSelling: allowSelling.value
|
||||
},
|
||||
namePatterns: {
|
||||
include: includePatterns.value,
|
||||
exclude: excludePatterns.value,
|
||||
useRegex: useRegex.value
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
@@ -94,6 +102,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
updateIfChanged(excludeFolders, filters.folders?.exclude || [])
|
||||
updateIfChanged(noCreditRequired, filters.license?.noCreditRequired ?? false)
|
||||
updateIfChanged(allowSelling, filters.license?.allowSelling ?? false)
|
||||
updateIfChanged(includePatterns, filters.namePatterns?.include || [])
|
||||
updateIfChanged(excludePatterns, filters.namePatterns?.exclude || [])
|
||||
updateIfChanged(useRegex, filters.namePatterns?.useRegex ?? false)
|
||||
|
||||
// matchCount doesn't trigger watchers, so direct assignment is fine
|
||||
matchCount.value = preview?.matchCount || 0
|
||||
@@ -125,6 +136,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
foldersExclude: excludeFolders.value,
|
||||
noCreditRequired: noCreditRequired.value || undefined,
|
||||
allowSelling: allowSelling.value || undefined,
|
||||
namePatternsInclude: includePatterns.value,
|
||||
namePatternsExclude: excludePatterns.value,
|
||||
namePatternsUseRegex: useRegex.value,
|
||||
pageSize: 6
|
||||
})
|
||||
|
||||
@@ -150,7 +164,10 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
includeFolders,
|
||||
excludeFolders,
|
||||
noCreditRequired,
|
||||
allowSelling
|
||||
allowSelling,
|
||||
includePatterns,
|
||||
excludePatterns,
|
||||
useRegex
|
||||
], onFilterChange, { deep: true })
|
||||
|
||||
return {
|
||||
@@ -162,6 +179,9 @@ export function useLoraPoolState(widget: ComponentWidget<LoraPoolConfig>) {
|
||||
excludeFolders,
|
||||
noCreditRequired,
|
||||
allowSelling,
|
||||
includePatterns,
|
||||
excludePatterns,
|
||||
useRegex,
|
||||
|
||||
// Available options
|
||||
availableBaseModels,
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
} from './mode-change-handler'
|
||||
|
||||
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
||||
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
|
||||
const LORA_POOL_WIDGET_MIN_HEIGHT = 520
|
||||
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
|
||||
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
|
||||
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
||||
const LORA_CYCLER_WIDGET_MIN_WIDTH = 380
|
||||
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 314
|
||||
const LORA_CYCLER_WIDGET_MIN_HEIGHT = 344
|
||||
const LORA_CYCLER_WIDGET_MAX_HEIGHT = LORA_CYCLER_WIDGET_MIN_HEIGHT
|
||||
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
|
||||
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200
|
||||
|
||||
@@ -84,7 +84,8 @@ describe('useLoraCyclerState', () => {
|
||||
current_lora_filename: '',
|
||||
repeat_count: 1,
|
||||
repeat_used: 0,
|
||||
is_paused: false
|
||||
is_paused: false,
|
||||
include_no_lora: false
|
||||
})
|
||||
|
||||
expect(state.currentIndex.value).toBe(5)
|
||||
|
||||
4
vue-widgets/tests/fixtures/mockConfigs.ts
vendored
4
vue-widgets/tests/fixtures/mockConfigs.ts
vendored
@@ -24,6 +24,7 @@ export function createMockCyclerConfig(overrides: Partial<CyclerConfig> = {}): C
|
||||
repeat_count: 1,
|
||||
repeat_used: 0,
|
||||
is_paused: false,
|
||||
include_no_lora: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -54,7 +55,8 @@ export function createMockPoolConfig(overrides: Partial<LoraPoolConfig> = {}): L
|
||||
export function createMockLoraList(count: number = 5): CyclerLoraItem[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
file_name: `lora${i + 1}.safetensors`,
|
||||
model_name: `LoRA Model ${i + 1}`
|
||||
model_name: `LoRA Model ${i + 1}`,
|
||||
file_path: `/models/loras/lora${i + 1}.safetensors`
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { initDrag, createContextMenu, initHeaderDrag, initReorderDrag, handleKey
|
||||
import { forwardMiddleMouseToCanvas } from "./utils.js";
|
||||
import { PreviewTooltip } from "./preview_tooltip.js";
|
||||
import { ensureLmStyles } from "./lm_styles_loader.js";
|
||||
import { getStrengthStepPreference } from "./settings.js";
|
||||
|
||||
export function addLorasWidget(node, name, opts, callback) {
|
||||
ensureLmStyles();
|
||||
@@ -416,7 +417,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - 0.05).toFixed(2);
|
||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) - getStrengthStepPreference()).toFixed(2);
|
||||
// Sync clipStrength if collapsed
|
||||
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
||||
|
||||
@@ -488,7 +489,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + 0.05).toFixed(2);
|
||||
lorasData[loraIndex].strength = (parseFloat(lorasData[loraIndex].strength) + getStrengthStepPreference()).toFixed(2);
|
||||
// Sync clipStrength if collapsed
|
||||
syncClipStrengthIfCollapsed(lorasData[loraIndex]);
|
||||
|
||||
@@ -541,7 +542,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - 0.05).toFixed(2);
|
||||
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) - getStrengthStepPreference()).toFixed(2);
|
||||
|
||||
const newValue = formatLoraValue(lorasData);
|
||||
updateWidgetValue(newValue);
|
||||
@@ -611,7 +612,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
const loraIndex = lorasData.findIndex(l => l.name === name);
|
||||
|
||||
if (loraIndex >= 0) {
|
||||
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + 0.05).toFixed(2);
|
||||
lorasData[loraIndex].clipStrength = (parseFloat(lorasData[loraIndex].clipStrength) + getStrengthStepPreference()).toFixed(2);
|
||||
|
||||
const newValue = formatLoraValue(lorasData);
|
||||
updateWidgetValue(newValue);
|
||||
|
||||
@@ -24,6 +24,9 @@ const NEW_TAB_TEMPLATE_DEFAULT = "Default";
|
||||
|
||||
const NEW_TAB_ZOOM_LEVEL = 0.8;
|
||||
|
||||
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
|
||||
const STRENGTH_STEP_DEFAULT = 0.05;
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
@@ -232,6 +235,32 @@ const getNewTabTemplatePreference = (() => {
|
||||
};
|
||||
})();
|
||||
|
||||
const getStrengthStepPreference = (() => {
|
||||
let settingsUnavailableLogged = false;
|
||||
|
||||
return () => {
|
||||
const settingManager = app?.extensionManager?.setting;
|
||||
if (!settingManager || typeof settingManager.get !== "function") {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: settings API unavailable, using default strength step.");
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return STRENGTH_STEP_DEFAULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = settingManager.get(STRENGTH_STEP_SETTING_ID);
|
||||
return value ?? STRENGTH_STEP_DEFAULT;
|
||||
} catch (error) {
|
||||
if (!settingsUnavailableLogged) {
|
||||
console.warn("LoRA Manager: unable to read strength step setting, using default.", error);
|
||||
settingsUnavailableLogged = true;
|
||||
}
|
||||
return STRENGTH_STEP_DEFAULT;
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Register Extension with All Settings
|
||||
// ============================================================================
|
||||
@@ -293,6 +322,19 @@ app.registerExtension({
|
||||
tooltip: "Choose a template workflow to load when creating a new workflow tab. 'Default (Blank)' keeps ComfyUI's original blank workflow behavior.",
|
||||
category: ["LoRA Manager", "Workflow", "New Tab Template"],
|
||||
},
|
||||
{
|
||||
id: STRENGTH_STEP_SETTING_ID,
|
||||
name: "Strength Adjustment Step",
|
||||
type: "slider",
|
||||
attrs: {
|
||||
min: 0.01,
|
||||
max: 0.1,
|
||||
step: 0.01,
|
||||
},
|
||||
defaultValue: STRENGTH_STEP_DEFAULT,
|
||||
tooltip: "Step size for adjusting LoRA strength via arrow buttons or keyboard (default: 0.05)",
|
||||
category: ["LoRA Manager", "LoRA Widget", "Strength Step"],
|
||||
},
|
||||
],
|
||||
async setup() {
|
||||
await loadWorkflowOptions();
|
||||
@@ -375,4 +417,5 @@ export {
|
||||
getTagSpaceReplacementPreference,
|
||||
getUsageStatisticsPreference,
|
||||
getNewTabTemplatePreference,
|
||||
getStrengthStepPreference,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user