Compare commits

...

10 Commits

Author SHA1 Message Date
Will Miao
ccf1c6f2ae fix(recipe): resolve base_model from parser and prevent empty checkpoint save on CivitAI import
- Apply CivitaiApiMetadataParser's base_model result to metadata in
  _do_import_remote_recipe and _do_import_from_url (was previously discarded)
- Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
  so it's not lost when the type check rejects non-checkpoint model versions
- Only format and save checkpoint entry when it has real data (modelId, versionId,
  name, or version), preventing empty {'type': 'checkpoint'} stubs
2026-06-01 17:58:08 +08:00
Will Miao
bfe7b5e1c7 fix(constants): add missing diffusion model base models (Flux, DiT, video, etc.) 2026-05-31 17:12:09 +08:00
Will Miao
85c020cd12 fix(update): preserve wildcards, backups dirs during ZIP upgrade, add log rotation
- Add wildcards and backups to skip_files in all three ZIP upgrade
  skip locations: _clean_plugin_folder, copy loop, .tracking generation
- Remove logs from skip_files (logs are transient and rotate automatically)
- Add _prune_old_logs() to session_logging.py: keeps only the 3 newest
  session log files, deletes older ones on each standalone startup
2026-05-31 15:56:56 +08:00
Will Miao
1b202f8ec7 fix(autocomplete): escape parentheses in prompt tag insertion (#951) 2026-05-31 15:40:19 +08:00
Will Miao
d02a0611d3 fix(update): close SQLite connection and protect cache dir during ZIP update
On Windows, shutil.rmtree() fails when deleting a directory that contains
an open SQLite database file. The ZIP update path in _download_and_replace_zip()
calls _clean_plugin_folder() which tries to delete the cache/ directory,
but downloaded_versions.sqlite is held open by DownloadedVersionHistoryService.

Fix:
- Add close() method to DownloadedVersionHistoryService to release
  the persistent SQLite connection
- Call close() before _clean_plugin_folder() in the ZIP update flow
- Add 'cache' to the skip_files list so the runtime cache directory is
  never deleted during plugin updates
2026-05-31 15:06:15 +08:00
pixelpaws
92166a161a Update Portable Package link to version 1.0.10 2026-05-31 10:08:28 +08:00
Will Miao
b509f27cb7 chore(release): bump version to v1.0.10 2026-05-31 09:39:26 +08:00
Will Miao
5c2ef48917 fix(aria2): apply certifi CA bundle to aria2c via --ca-certificate
When certifi is available, pass its CA bundle path as --ca-certificate
to the aria2c subprocess so that aria2 downloads use the same
certificate store as Python aiohttp downloads. Graceful fallback when
certifi is not installed.
2026-05-30 21:47:13 +08:00
Will Miao
ad2bd82c67 fix(downloader): use certifi CA bundle as SSL fallback and log SSL error diagnostics
- Prefer certifi's CA bundle in aiohttp SSL context with graceful
  fallback to system default when certifi is unavailable
- Add is_ssl_cert_verify_error() helper for SSL cert failure detection
- Log actionable error message (pip install --upgrade certifi /
  pip install pip-system-certs) when SSL certificate verification fails
- Apply same diagnostic logging to aria2 redirect resolution path
2026-05-30 21:28:18 +08:00
willmiao
17ba350153 docs: auto-update supporters list in README 2026-05-28 13:47:09 +00:00
11 changed files with 238 additions and 48 deletions

File diff suppressed because one or more lines are too long

View File

@@ -190,27 +190,42 @@ class RecipeEnricher:
existing_cp = recipe.get("checkpoint") existing_cp = recipe.get("checkpoint")
if existing_cp is None: if existing_cp is None:
existing_cp = {} existing_cp = {}
# Extract baseModel from raw civitai_info before populate_checkpoint_from_civitai
# (populate may reject non-checkpoint types and lose this data)
base_model_from_civitai: str = ""
if isinstance(civitai_info, dict):
base_model_from_civitai = civitai_info.get("baseModel", "") or ""
elif isinstance(civitai_info, tuple) and len(civitai_info) > 0 and isinstance(civitai_info[0], dict):
base_model_from_civitai = civitai_info[0].get("baseModel", "") or ""
checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info) checkpoint_data = await RecipeMetadataParser.populate_checkpoint_from_civitai(existing_cp, civitai_info)
# 1. First, resolve base_model using full data before we format it away
# 1. Resolve base_model from checkpoint_data first, then fall back to raw civitai_info
current_base_model = recipe.get("base_model") current_base_model = recipe.get("base_model")
resolved_base_model = checkpoint_data.get("baseModel") resolved_base_model = checkpoint_data.get("baseModel") or base_model_from_civitai
if resolved_base_model: if resolved_base_model:
# Update if empty OR if it matches our generic prefix but is less specific
is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"] is_generic = not current_base_model or current_base_model.lower() in ["flux", "sdxl", "sd15"]
if is_generic and resolved_base_model != current_base_model: if is_generic and resolved_base_model != current_base_model:
recipe["base_model"] = resolved_base_model recipe["base_model"] = resolved_base_model
# 2. Format according to requirements: type, modelId, modelVersionId, modelName, modelVersionName # 2. Only format and save checkpoint if it has real data (not just type after type rejection)
formatted_checkpoint = { has_checkpoint_data = any([
"type": "checkpoint", checkpoint_data.get("modelId"),
"modelId": checkpoint_data.get("modelId"), checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"), checkpoint_data.get("name"),
"modelName": checkpoint_data.get("name"), # In base.py, 'name' is populated from civitai_data['model']['name'] checkpoint_data.get("version"),
"modelVersionName": checkpoint_data.get("version") # In base.py, 'version' is populated from civitai_data['name'] ])
} if has_checkpoint_data:
# Remove None values formatted_checkpoint = {
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None} "type": "checkpoint",
"modelId": checkpoint_data.get("modelId"),
"modelVersionId": checkpoint_data.get("id") or checkpoint_data.get("modelVersionId"),
"modelName": checkpoint_data.get("name"),
"modelVersionName": checkpoint_data.get("version"),
}
recipe["checkpoint"] = {k: v for k, v in formatted_checkpoint.items() if v is not None}
return True return True
else: else:
# Fallback to name extraction if we don't already have one # Fallback to name extraction if we don't already have one

View File

@@ -975,6 +975,9 @@ class RecipeManagementHandler:
civitai_model = civitai_parsed.get("model") civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"): if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model metadata["checkpoint"] = civitai_model
civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model
elif parsed_embedded: elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras") parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"): if parsed_loras and not metadata.get("loras"):
@@ -982,6 +985,8 @@ class RecipeManagementHandler:
parsed_model = parsed_embedded.get("model") parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"): if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model metadata["checkpoint"] = parsed_model
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(
@@ -1489,25 +1494,28 @@ class RecipeManagementHandler:
if not image_url: if not image_url:
raise RecipeValidationError("Missing required field: image_url") raise RecipeValidationError("Missing required field: image_url")
force = request.query.get("force", "false").lower() == "true"
image_id = extract_civitai_image_id(image_url) image_id = extract_civitai_image_id(image_url)
if not image_id: if not image_id:
raise RecipeValidationError( raise RecipeValidationError(
"Could not extract Civitai image ID from URL" "Could not extract Civitai image ID from URL"
) )
# Check for duplicate (fast, before acquiring semaphore) # Check for duplicate (fast, before acquiring semaphore), unless force
cache = await recipe_scanner.get_cached_data() if not force:
for recipe in getattr(cache, "raw_data", []): cache = await recipe_scanner.get_cached_data()
source = recipe.get("source_path") for recipe in getattr(cache, "raw_data", []):
if source: source = recipe.get("source_path")
existing_id = extract_civitai_image_id(source) if source:
if existing_id == image_id: existing_id = extract_civitai_image_id(source)
return web.json_response({ if existing_id == image_id:
"success": True, return web.json_response({
"recipe_id": recipe.get("id"), "success": True,
"name": recipe.get("title", ""), "recipe_id": recipe.get("id"),
"already_exists": True, "name": recipe.get("title", ""),
}) "already_exists": True,
})
async with self._import_semaphore: async with self._import_semaphore:
return await self._do_import_from_url(image_url, recipe_scanner) return await self._do_import_from_url(image_url, recipe_scanner)
@@ -1613,6 +1621,9 @@ class RecipeManagementHandler:
civitai_model = civitai_parsed.get("model") civitai_model = civitai_parsed.get("model")
if civitai_model and not metadata.get("checkpoint"): if civitai_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = civitai_model metadata["checkpoint"] = civitai_model
civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model
elif parsed_embedded: elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras") parsed_loras = parsed_embedded.get("loras")
if parsed_loras and not metadata.get("loras"): if parsed_loras and not metadata.get("loras"):
@@ -1620,6 +1631,8 @@ class RecipeManagementHandler:
parsed_model = parsed_embedded.get("model") parsed_model = parsed_embedded.get("model")
if parsed_model and not metadata.get("checkpoint"): if parsed_model and not metadata.get("checkpoint"):
metadata["checkpoint"] = parsed_model metadata["checkpoint"] = parsed_model
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
await RecipeEnricher.enrich_recipe( await RecipeEnricher.enrich_recipe(

View File

@@ -11,6 +11,7 @@ from typing import Dict, List
from ..utils.settings_paths import ensure_settings_file from ..utils.settings_paths import ensure_settings_file
from ..services.downloader import get_downloader from ..services.downloader import get_downloader
from ..services.service_registry import ServiceRegistry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -212,8 +213,19 @@ class UpdateRoutes:
zip_path = tmp_zip_path zip_path = tmp_zip_path
# Skip both settings.json, civitai and model cache folder # Close the downloaded-versions SQLite connection before cleaning,
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache']) # so that shutil.rmtree() does not fail on Windows (the process
# cannot delete a file with an outstanding open handle).
try:
history_svc = ServiceRegistry._services.get("downloaded_version_history_service")
if history_svc is not None:
history_svc.close()
logger.info("Closed downloaded-version history database connection")
except Exception:
logger.debug("Could not close downloaded-version history database", exc_info=True)
# Skip settings.json, civitai, model cache and runtime cache folders
UpdateRoutes._clean_plugin_folder(plugin_root, skip_files=['settings.json', 'civitai', 'model_cache', 'cache', 'wildcards', 'backups'])
# Extract ZIP to temp dir # Extract ZIP to temp dir
with tempfile.TemporaryDirectory() as tmp_dir: with tempfile.TemporaryDirectory() as tmp_dir:
@@ -222,16 +234,17 @@ class UpdateRoutes:
# Find extracted folder (GitHub ZIP contains a root folder) # Find extracted folder (GitHub ZIP contains a root folder)
extracted_root = next(os.scandir(tmp_dir)).path extracted_root = next(os.scandir(tmp_dir)).path
# Copy files, skipping settings.json and civitai folder # Copy files, skipping user data that should be preserved
skip_items = {'settings.json', 'civitai', 'wildcards', 'backups'}
for item in os.listdir(extracted_root): for item in os.listdir(extracted_root):
if item == 'settings.json' or item == 'civitai': if item in skip_items:
continue continue
src = os.path.join(extracted_root, item) src = os.path.join(extracted_root, item)
dst = os.path.join(plugin_root, item) dst = os.path.join(plugin_root, item)
if os.path.isdir(src): if os.path.isdir(src):
if os.path.exists(dst): if os.path.exists(dst):
shutil.rmtree(dst) shutil.rmtree(dst)
shutil.copytree(src, dst, ignore=shutil.ignore_patterns('settings.json', 'civitai')) shutil.copytree(src, dst, ignore=shutil.ignore_patterns(*skip_items))
else: else:
shutil.copy2(src, dst) shutil.copy2(src, dst)
@@ -239,15 +252,17 @@ class UpdateRoutes:
# for ComfyUI Manager to work properly # for ComfyUI Manager to work properly
tracking_info_file = os.path.join(plugin_root, '.tracking') tracking_info_file = os.path.join(plugin_root, '.tracking')
tracking_files = [] tracking_files = []
skip_tracked = {'civitai', 'wildcards', 'backups'}
for root, dirs, files in os.walk(extracted_root): for root, dirs, files in os.walk(extracted_root):
# Skip civitai folder and its contents # Skip user data directories and their contents
rel_root = os.path.relpath(root, extracted_root) rel_root = os.path.relpath(root, extracted_root)
if rel_root == 'civitai' or rel_root.startswith('civitai' + os.sep): top_dir = rel_root.split(os.sep)[0] if rel_root != '.' else ''
if top_dir in skip_tracked:
continue continue
for file in files: for file in files:
rel_path = os.path.relpath(os.path.join(root, file), extracted_root) rel_path = os.path.relpath(os.path.join(root, file), extracted_root)
# Skip settings.json and any file under civitai # Skip settings.json and any file under user data dirs
if rel_path == 'settings.json' or rel_path.startswith('civitai' + os.sep): if rel_path == 'settings.json' or rel_path.split(os.sep)[0] in skip_tracked:
continue continue
tracking_files.append(rel_path.replace("\\", "/")) tracking_files.append(rel_path.replace("\\", "/"))
with open(tracking_info_file, "w", encoding='utf-8') as file: with open(tracking_info_file, "w", encoding='utf-8') as file:

View File

@@ -14,12 +14,30 @@ from typing import Any, Dict, Optional, Tuple
import aiohttp import aiohttp
from .downloader import DownloadProgress, get_downloader from .downloader import DownloadProgress, get_downloader, is_ssl_cert_verify_error
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _try_certifi_ca_path() -> str | None:
"""Return the certifi CA bundle path if available, else None."""
try:
import certifi # type: ignore[import-untyped]
path = certifi.where()
if os.path.isfile(path):
logger.debug(
"aria2 --ca-certificate: using certifi CA bundle at %s", path
)
return path
except ImportError:
pass
logger.debug("aria2 --ca-certificate: certifi not available")
return None
CIVITAI_DOWNLOAD_URL_PREFIXES = ( CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/", "https://civitai.com/api/download/",
"https://civitai.red/api/download/", "https://civitai.red/api/download/",
@@ -391,6 +409,15 @@ class Aria2Downloader:
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}" f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
) )
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
if is_ssl_cert_verify_error(exc):
logger.error(
"SSL certificate verification failed during Civitai redirect "
"resolution for %s. This is usually caused by an outdated CA "
"certificate bundle. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
raise Aria2Error( raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: {exc}" f"Failed to resolve authenticated Civitai redirect: {exc}"
) from exc ) from exc
@@ -414,6 +441,11 @@ class Aria2Downloader:
f"--rpc-listen-port={self._rpc_port}", f"--rpc-listen-port={self._rpc_port}",
f"--rpc-secret={self._rpc_secret}", f"--rpc-secret={self._rpc_secret}",
"--check-certificate=true", "--check-certificate=true",
# Point aria2 at certifi's CA bundle when available so it uses
# the same certificate store as Python downloads.
*((
f"--ca-certificate={ca_cert}",
) if (ca_cert := _try_certifi_ca_path()) else ()),
"--allow-overwrite=true", "--allow-overwrite=true",
"--auto-file-renaming=false", "--auto-file-renaming=false",
"--file-allocation=none", "--file-allocation=none",

View File

@@ -96,6 +96,21 @@ class DownloadedVersionHistoryService:
def get_database_path(self) -> str: def get_database_path(self) -> str:
return self._db_path return self._db_path
def close(self) -> None:
"""Close the persistent SQLite connection, if open.
This is called before plugin update operations to release the
database file lock on Windows, allowing ``shutil.rmtree()`` to
succeed when the cache resides inside the plugin directory.
"""
if self._conn is not None:
try:
self._conn.close()
except Exception:
pass
finally:
self._conn = None
def _get_active_library_name(self) -> str | None: def _get_active_library_name(self) -> str | None:
try: try:
value = self._settings.get_active_library_name() value = self._settings.get_active_library_name()

View File

@@ -13,6 +13,7 @@ This module provides a centralized download service with:
import os import os
import logging import logging
import asyncio import asyncio
import ssl
import aiohttp import aiohttp
from collections import deque from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
@@ -31,6 +32,20 @@ from .errors import RateLimitError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def is_ssl_cert_verify_error(exc: BaseException) -> bool:
"""Check if an exception represents an SSL certificate verification failure.
Matches ``ssl.SSLCertVerificationError``, ``aiohttp.ClientConnectorCertificateError``
(which wraps the former), and falls back to the standard OpenSSL error text.
"""
if isinstance(exc, ssl.SSLCertVerificationError):
return True
cert_error = getattr(exc, "certificate_error", None)
if isinstance(cert_error, ssl.SSLCertVerificationError):
return True
return "CERTIFICATE_VERIFY_FAILED" in str(exc)
@dataclass(frozen=True) @dataclass(frozen=True)
class DownloadProgress: class DownloadProgress:
"""Snapshot of a download transfer at a moment in time.""" """Snapshot of a download transfer at a moment in time."""
@@ -265,9 +280,22 @@ class Downloader:
logger.debug( logger.debug(
"Proxy mode: system-level proxy (trust_env) will be used if configured in environment." "Proxy mode: system-level proxy (trust_env) will be used if configured in environment."
) )
# Build SSL context: prefer certifi's CA bundle for broader
# CA coverage across different Python environments (especially
# embedded/compatibility Python builds).
try:
import certifi # type: ignore[import-untyped]
ca_path = certifi.where()
ssl_context = ssl.create_default_context(cafile=ca_path)
logger.debug("SSL: using certifi CA bundle at %s", ca_path)
except (ImportError, FileNotFoundError, ValueError, OSError):
ssl_context = ssl.create_default_context()
logger.debug("SSL: certifi unavailable; using system default CA bundle")
# Optimize TCP connection parameters # Optimize TCP connection parameters
connector = aiohttp.TCPConnector( connector = aiohttp.TCPConnector(
ssl=True, ssl=ssl_context,
limit=8, # Concurrent connections limit=8, # Concurrent connections
ttl_dns_cache=300, # DNS cache timeout ttl_dns_cache=300, # DNS cache timeout
force_close=False, # Keep connections for reuse force_close=False, # Keep connections for reuse
@@ -736,6 +764,17 @@ class Downloader:
DownloadRestartRequested, DownloadRestartRequested,
) as e: ) as e:
retry_count += 1 retry_count += 1
if is_ssl_cert_verify_error(e):
logger.error(
"SSL certificate verification failed when connecting to %s. "
"This is usually caused by an outdated CA certificate bundle "
"in the Python environment. Recommended fixes:\n"
" 1. pip install --upgrade certifi\n"
" 2. pip install pip-system-certs",
url,
)
logger.warning( logger.warning(
f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}" f"Network error during download (attempt {retry_count}/{self.max_retries + 1}): {e}"
) )

View File

@@ -101,8 +101,34 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
DIFFUSION_MODEL_BASE_MODELS = frozenset( DIFFUSION_MODEL_BASE_MODELS = frozenset(
[ [
"Anima", "Anima",
"ZImageTurbo", # Flux series — DiT architecture, loaded via UNETLoader in ComfyUI
"ZImageBase", "Flux.1 D",
"Flux.1 S",
"Flux.1 Krea",
"Flux.1 Kontext",
"Flux.2 D",
"Flux.2 Klein 9B",
"Flux.2 Klein 9B-base",
"Flux.2 Klein 4B",
"Flux.2 Klein 4B-base",
# Non-UNet / DiT image diffusion models
"AuraFlow",
"Chroma",
"HiDream",
"Hunyuan 1",
"Kolors",
"Lumina",
"PixArt a",
"PixArt E",
# Video diffusion models
"CogVideoX",
"Hunyuan Video",
"LTXV",
"LTXV2",
"LTXV 2.3",
"Mochi",
"SVD",
"Wan Video",
"Wan Video 1.3B t2v", "Wan Video 1.3B t2v",
"Wan Video 14B t2v", "Wan Video 14B t2v",
"Wan Video 14B i2v 480p", "Wan Video 14B i2v 480p",
@@ -112,9 +138,13 @@ DIFFUSION_MODEL_BASE_MODELS = frozenset(
"Wan Video 2.2 T2V-A14B", "Wan Video 2.2 T2V-A14B",
"Wan Video 2.5 T2V", "Wan Video 2.5 T2V",
"Wan Video 2.5 I2V", "Wan Video 2.5 I2V",
"CogVideoX", # Other diffusion models
"Mochi", "Ernie",
"Ernie Turbo",
"Nucleus",
"Qwen", "Qwen",
"ZImageBase",
"ZImageTurbo",
] ]
) )

View File

@@ -64,6 +64,27 @@ def _build_log_file_path(settings_file: str | None, started_at: datetime) -> str
return os.path.join(log_dir, f"standalone-session-{timestamp}.log") return os.path.join(log_dir, f"standalone-session-{timestamp}.log")
_KEEP_LOG_COUNT = 3
def _prune_old_logs(log_dir: str) -> None:
"""Remove older session log files, keeping only the ``_KEEP_LOG_COUNT`` newest."""
try:
files = [
os.path.join(log_dir, name)
for name in os.listdir(log_dir)
if name.startswith("standalone-session-") and name.endswith(".log")
]
except OSError:
return
files.sort(key=os.path.getmtime, reverse=True)
for path in files[_KEEP_LOG_COUNT:]:
try:
os.remove(path)
except OSError:
pass
def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState: def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSessionLogState:
global _session_state global _session_state
@@ -90,6 +111,7 @@ def setup_standalone_session_logging(settings_file: str | None) -> StandaloneSes
file_handler.set_name(_FILE_HANDLER_NAME) file_handler.set_name(_FILE_HANDLER_NAME)
file_handler.setFormatter(formatter) file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
_prune_old_logs(os.path.dirname(log_file_path))
_session_state = StandaloneSessionLogState( _session_state = StandaloneSessionLogState(
started_at=started_at, started_at=started_at,

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.0.9" version = "1.0.10"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",

View File

@@ -183,6 +183,13 @@ function parseSearchTokens(term = '') {
return { include, exclude }; return { include, exclude };
} }
function escapePromptParentheses(text) {
// In ComfyUI's CLIP text encoder, bare parentheses are weight adjustment syntax.
// Tags containing literal parentheses must be escaped with backslash to prevent
// them from being interpreted as weight modifiers. e.g. "foo (bar)" → "foo \(bar\)"
return text.replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
function formatAutocompleteInsertion(text = '') { function formatAutocompleteInsertion(text = '') {
const trimmed = typeof text === 'string' ? text.trim() : ''; const trimmed = typeof text === 'string' ? text.trim() : '';
if (!trimmed) { if (!trimmed) {
@@ -253,7 +260,7 @@ function createDefaultBehavior(modelType) {
if (!trimmed) { if (!trimmed) {
return ''; return '';
} }
return formatAutocompleteInsertion(trimmed); return formatAutocompleteInsertion(escapePromptParentheses(trimmed));
}, },
}; };
} }
@@ -352,7 +359,7 @@ const MODEL_BEHAVIORS = {
custom_words: { custom_words: {
enablePreview: false, enablePreview: false,
async getInsertText(_instance, relativePath) { async getInsertText(_instance, relativePath) {
return formatAutocompleteInsertion(relativePath); return formatAutocompleteInsertion(escapePromptParentheses(relativePath));
}, },
}, },
prompt: { prompt: {
@@ -399,6 +406,8 @@ const MODEL_BEHAVIORS = {
tagText = tagText.replace(/_/g, ' '); tagText = tagText.replace(/_/g, ' ');
} }
tagText = escapePromptParentheses(tagText);
return formatAutocompleteInsertion(tagText); return formatAutocompleteInsertion(tagText);
} }
}, },