mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-26 04:41:16 -03:00
Compare commits
16 Commits
v1.0.9
...
df67bd396a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df67bd396a | ||
|
|
dd5d9cfcb2 | ||
|
|
d9fd60bec1 | ||
|
|
b633b22779 | ||
|
|
1ffa543160 | ||
|
|
cdc940586e | ||
|
|
ccf1c6f2ae | ||
|
|
bfe7b5e1c7 | ||
|
|
85c020cd12 | ||
|
|
1b202f8ec7 | ||
|
|
d02a0611d3 | ||
|
|
92166a161a | ||
|
|
b509f27cb7 | ||
|
|
5c2ef48917 | ||
|
|
ad2bd82c67 | ||
|
|
17ba350153 |
@@ -190,26 +190,41 @@ 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:
|
||||||
|
|||||||
@@ -461,7 +461,11 @@ class RecipeQueryHandler:
|
|||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
raise RuntimeError("Recipe scanner unavailable")
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
self._logger.info("Manually triggering recipe cache rebuild")
|
full_rebuild = request.query.get("full_rebuild", "true").lower() == "true"
|
||||||
|
self._logger.info(
|
||||||
|
"Manually triggering recipe cache %s",
|
||||||
|
"full rebuild" if full_rebuild else "refresh",
|
||||||
|
)
|
||||||
await recipe_scanner.get_cached_data(force_refresh=True)
|
await recipe_scanner.get_cached_data(force_refresh=True)
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": True, "message": "Recipe cache refreshed successfully"}
|
{"success": True, "message": "Recipe cache refreshed successfully"}
|
||||||
@@ -975,6 +979,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 +989,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 +1498,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 +1625,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 +1635,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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -186,6 +186,22 @@ class CivArchiveClient:
|
|||||||
if "metadata" in file_data:
|
if "metadata" in file_data:
|
||||||
transformed["metadata"] = file_data["metadata"]
|
transformed["metadata"] = file_data["metadata"]
|
||||||
|
|
||||||
|
# Infer metadata.format from filename extension
|
||||||
|
name = transformed.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
lower_name = name.lower()
|
||||||
|
if lower_name.endswith(".safetensors"):
|
||||||
|
inferred_format = "SafeTensor"
|
||||||
|
elif lower_name.endswith(".ckpt"):
|
||||||
|
inferred_format = "PickleTensor"
|
||||||
|
else:
|
||||||
|
inferred_format = None
|
||||||
|
if inferred_format:
|
||||||
|
if "metadata" not in transformed:
|
||||||
|
transformed["metadata"] = {}
|
||||||
|
if isinstance(transformed["metadata"], dict):
|
||||||
|
transformed["metadata"].setdefault("format", inferred_format)
|
||||||
|
|
||||||
if file_data.get("modelVersionId") is not None:
|
if file_data.get("modelVersionId") is not None:
|
||||||
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
transformed["modelVersionId"] = file_data.get("modelVersionId")
|
||||||
elif file_data.get("model_version_id") is not None:
|
elif file_data.get("model_version_id") is not None:
|
||||||
@@ -213,6 +229,20 @@ class CivArchiveClient:
|
|||||||
for file_data in candidates:
|
for file_data in candidates:
|
||||||
if isinstance(file_data, dict):
|
if isinstance(file_data, dict):
|
||||||
transformed_files.append(self._transform_file_entry(file_data))
|
transformed_files.append(self._transform_file_entry(file_data))
|
||||||
|
|
||||||
|
# Sort: .safetensors first, .ckpt second, others last
|
||||||
|
# so the backend fallback (no file_params) prefers safetensors
|
||||||
|
def _sort_key(f: Dict) -> int:
|
||||||
|
fname = f.get("name") or ""
|
||||||
|
if isinstance(fname, str):
|
||||||
|
lower = fname.lower()
|
||||||
|
if lower.endswith(".safetensors"):
|
||||||
|
return 0
|
||||||
|
elif lower.endswith(".ckpt"):
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
transformed_files.sort(key=_sort_key)
|
||||||
return transformed_files
|
return transformed_files
|
||||||
|
|
||||||
def _transform_version(
|
def _transform_version(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
|||||||
// Reset page counter
|
// Reset page counter
|
||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
|
|
||||||
// Fetch the first page
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
const result = await fetchPageFunction(1, pageSize);
|
||||||
|
|
||||||
// Update the virtual scroller
|
// Update the virtual scroller
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -251,8 +251,8 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
|||||||
pageState.currentPage = 1;
|
pageState.currentPage = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the first page of data
|
const pageSize = state.virtualScroller?.pageSize || pageState.pageSize || 100;
|
||||||
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
const result = await fetchPageFunction(pageState.currentPage, pageSize);
|
||||||
|
|
||||||
// Update virtual scroller with the new data
|
// Update virtual scroller with the new data
|
||||||
state.virtualScroller.refreshWithData(
|
state.virtualScroller.refreshWithData(
|
||||||
@@ -294,47 +294,41 @@ export async function resetAndReload(updateFolders = false, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync changes - quick refresh without rebuilding cache (similar to models page)
|
* Refreshes the recipe list by triggering a backend scan, then reloading.
|
||||||
|
* @param {boolean} fullRebuild - If true, fully rebuild the cache; if false, incremental scan
|
||||||
*/
|
*/
|
||||||
export async function syncChanges() {
|
export async function syncChanges() {
|
||||||
try {
|
return refreshRecipes(false);
|
||||||
state.loadingManager.showSimpleLoading('Syncing changes...');
|
|
||||||
|
|
||||||
// Simply reload the recipes without rebuilding cache
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
|
||||||
|
|
||||||
showToast('toast.recipes.syncComplete', {}, 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error syncing recipes:', error);
|
|
||||||
showToast('toast.recipes.syncFailed', { message: error.message }, 'error');
|
|
||||||
} finally {
|
|
||||||
state.loadingManager.hide();
|
|
||||||
state.loadingManager.restoreProgressBar();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function refreshRecipes(fullRebuild = true) {
|
||||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
|
||||||
*/
|
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
|
||||||
export async function refreshRecipes() {
|
|
||||||
try {
|
|
||||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
|
||||||
|
|
||||||
// Call the API endpoint to rebuild the recipe cache
|
try {
|
||||||
const response = await fetch(RECIPE_ENDPOINTS.scan);
|
state.loadingManager.show(`${actionLabel}...`, 0);
|
||||||
|
|
||||||
|
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
|
||||||
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
throw new Error(`Failed to refresh recipe cache: ${response.status} ${response.statusText}`);
|
||||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// After successful cache rebuild, reload the recipes
|
const data = await response.json();
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
if (data.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
await resetAndReload(false);
|
||||||
|
|
||||||
|
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing recipes:', error);
|
console.error('Error refreshing recipes:', error);
|
||||||
showToast('toast.recipes.refreshFailed', { message: error.message }, 'error');
|
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
|
|||||||
@@ -306,8 +306,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (result.repaired > 0) {
|
if (result.repaired > 0) {
|
||||||
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||||
// Refresh the current card or reload
|
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
this.resetAndReload();
|
if (detailResponse.ok) {
|
||||||
|
const updatedRecipe = await detailResponse.json();
|
||||||
|
const filePath = this.currentCard?.dataset?.filepath;
|
||||||
|
if (filePath && state.virtualScroller) {
|
||||||
|
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class RecipeCard {
|
|||||||
card.dataset.created = this.recipe.created_date;
|
card.dataset.created = this.recipe.created_date;
|
||||||
card.dataset.id = this.recipe.id || '';
|
card.dataset.id = this.recipe.id || '';
|
||||||
card.dataset.folder = this.recipe.folder || '';
|
card.dataset.folder = this.recipe.folder || '';
|
||||||
|
card.dataset.favorite = this.recipe.favorite ? 'true' : 'false';
|
||||||
|
|
||||||
// Get base model with fallback
|
// Get base model with fallback
|
||||||
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
const baseModelLabel = (this.recipe.base_model || '').trim() || 'Unknown';
|
||||||
@@ -161,6 +162,7 @@ class RecipeCard {
|
|||||||
|
|
||||||
// Update early to provide instant feedback and avoid race conditions with re-renders
|
// Update early to provide instant feedback and avoid race conditions with re-renders
|
||||||
this.recipe.favorite = newFavoriteState;
|
this.recipe.favorite = newFavoriteState;
|
||||||
|
card.dataset.favorite = newFavoriteState ? 'true' : 'false';
|
||||||
|
|
||||||
// Function to update icon state
|
// Function to update icon state
|
||||||
const updateIconUI = (icon, state) => {
|
const updateIconUI = (icon, state) => {
|
||||||
|
|||||||
@@ -432,7 +432,7 @@ export class BatchImportManager {
|
|||||||
|
|
||||||
// Refresh recipes list to show newly imported recipes
|
// Refresh recipes list to show newly imported recipes
|
||||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show results step
|
// Show results step
|
||||||
|
|||||||
@@ -309,9 +309,22 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
}, 'warning');
|
}, 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the recipes list to update LoRA status
|
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||||
if (window.recipeManager) {
|
if (state.virtualScroller) {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||||
|
for (const recipe of this.pendingRecipes) {
|
||||||
|
const recipeId = extractRecipeId(recipe.file_path);
|
||||||
|
if (!recipeId) continue;
|
||||||
|
try {
|
||||||
|
const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`);
|
||||||
|
if (detailRes.ok) {
|
||||||
|
const updated = await detailRes.json();
|
||||||
|
state.virtualScroller.updateSingleItem(recipe.file_path, updated);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to update recipe card after LoRA download:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -662,7 +662,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
@@ -746,7 +746,7 @@ export class FilterManager {
|
|||||||
|
|
||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class SearchManager {
|
|||||||
|
|
||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
|
|||||||
@@ -2876,7 +2876,7 @@ export class SettingsManager {
|
|||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
} else if (this.currentPage === 'recipes') {
|
} else if (this.currentPage === 'recipes') {
|
||||||
// Reload the recipes without updating folders
|
// Reload the recipes without updating folders
|
||||||
await window.recipeManager.loadRecipes({ preserveScroll: true });
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'checkpoints') {
|
||||||
// Reload the checkpoints without updating folders
|
// Reload the checkpoints without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class DownloadManager {
|
|||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
|
||||||
// Refresh the recipe
|
// Refresh the recipe
|
||||||
window.recipeManager.loadRecipes({ preserveScroll: true });
|
window.recipeManager.loadRecipes(true);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
|||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.js';
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
|
||||||
class RecipePageControls {
|
class RecipePageControls {
|
||||||
@@ -19,16 +19,13 @@ class RecipePageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async resetAndReload() {
|
async resetAndReload() {
|
||||||
await refreshVirtualScroll({ preserveScroll: true });
|
await refreshVirtualScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
if (fullRebuild) {
|
await refreshRecipes(fullRebuild);
|
||||||
await refreshRecipes();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncChanges();
|
await sidebarManager.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSidebarApiClient() {
|
getSidebarApiClient() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|||||||
const showToastMock = vi.hoisted(() => vi.fn());
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
const loadingManagerMock = vi.hoisted(() => ({
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
showSimpleLoading: vi.fn(),
|
showSimpleLoading: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
restoreProgressBar: vi.fn(),
|
restoreProgressBar: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -177,9 +178,7 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves scroll position for recipe reloads when requested', async () => {
|
it('reloads recipes without preserving scroll', async () => {
|
||||||
const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 };
|
|
||||||
captureScrollPositionMock.mockReturnValue(scrollSnapshot);
|
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -189,18 +188,18 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await resetAndReload(false, { preserveScroll: true });
|
await resetAndReload(false);
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith(
|
||||||
[{ id: 'recipe-1' }],
|
[{ id: 'recipe-1' }],
|
||||||
1,
|
1,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses scroll-preserving reloads for syncChanges', async () => {
|
it('uses scroll-free reloads for syncChanges', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -212,8 +211,8 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
|
|
||||||
await syncChanges();
|
await syncChanges();
|
||||||
|
|
||||||
expect(captureScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(captureScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1);
|
expect(restoreScrollPositionMock).not.toHaveBeenCalled();
|
||||||
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user