Compare commits

..
Author SHA1 Message Date
Will Miao 56acefbd6c feat(autocomplete): search loras within active filters of LoRA Manager page
Add /af and /noaf toggle commands (plus /activefilters aliases) to the
loras autocomplete widget. When enabled (default off), suggestions are
matched within the active filters (folder, base model, tags, auto-tags,
license, tag logic) persisted by the LoRA Manager page in localStorage,
keeping the match pool consistent with the list endpoint, including the
global show_only_sfw setting.

Backend: /lm/{prefix}/relative-paths accepts the filter query params and
pre-filters the scanner cache with ModelFilterSet. The presence of the
recursive param signals the filter pipeline to run even without concrete
filters so global settings stay in parity with the list endpoint.
2026-08-07 20:07:34 +08:00
Will Miao 5ab06c4aae docs: rename "Standalone Web UI" to "LoRA Manager Web UI" in AGENTS.md 2026-08-07 17:57:01 +08:00
Will Miao c11f4b5c68 feat(ui): widen filter panel and preset name limit 2026-08-07 16:53:14 +08:00
Will Miao 86376284f4 fix(ui): clamp filter panel height to viewport 2026-08-07 16:53:04 +08:00
Will Miao 2b8a2fc7d8 feat(filters): remove preset count limit 2026-08-07 16:52:53 +08:00
Will Miao f26e1b41c8 fix(i18n): translate zh-TW api key placeholder 2026-08-07 16:25:40 +08:00
Will Miao c1671af99f feat(downloads): translate batch download summary strings 2026-08-07 16:23:55 +08:00
Will Miao ac7707d0f6 fix(cards): clear model-card min-width on the item element itself 2026-08-07 16:09:51 +08:00
Will Miao 381cd710a2 feat(recipes): translate recipes layout setting strings 2026-08-07 15:36:30 +08:00
Will Miao ad0d18cb79 chore: ignore .playwright-mcp working directory 2026-08-07 15:33:43 +08:00
Will Miao 7980ee77d0 perf(recipes): batch preview dimension reads via asyncio.gather 2026-08-07 15:31:13 +08:00
Will Miao 916b8bb327 fix(recipes): skip stale scroller re-enable on deferred layout switch 2026-08-07 14:03:13 +08:00
Will Miao 87e3d4dea9 feat(recipes): wire recipes layout switch event and rebuild 2026-08-07 12:49:30 +08:00
Will Miao 76a913f5e0 feat(recipes): complete MasonryScroller public API parity with VirtualScroller 2026-08-07 12:40:28 +08:00
Will Miao d8c192e647 feat(recipes): branch masonry scroller instantiation for recipes page 2026-08-07 12:38:17 +08:00
Will Miao c453437620 feat(recipes): add MasonryScroller with column-based virtual scrolling 2026-08-07 12:31:13 +08:00
Will Miao 720fa6d909 feat(recipes): expose preview width/height in recipe listing API 2026-08-07 11:59:26 +08:00
Will Miao b4f71089f4 feat(recipes): add recipes_layout setting (grid|masonry) with i18n 2026-08-07 11:49:12 +08:00
Will Miao 83e6657ead feat(recipes): add get_image_dimensions helper with LRU cache 2026-08-07 11:47:28 +08:00
Will Miao 7ea6df4111 feat(downloads): default to latest version when URL lacks modelVersionId
Auto-select the first (newest) version for URLs without an explicit
modelVersionId, matching the existing batch flow, so users can proceed
to location/download without manually picking a version.
2026-08-07 11:24:45 +08:00
Will Miao d9ab92602a feat(downloads): show failure summary modal for single downloads too 2026-08-07 10:49:14 +08:00
pixelpaws 5ffadaed31 Merge pull request #1054 from willmiao/feat/gemini-provider
feat(llm): add Gemini as a preset AI provider
2026-08-07 10:30:45 +08:00
Will Miao 24f5f7df5d feat(llm): add Gemini as a preset AI provider 2026-08-07 10:27:51 +08:00
Will Miao daf01fb1d6 feat(downloads): show batch download summary with failure details and retry 2026-08-07 10:23:17 +08:00
Will Miao 0f11b6def9 fix(recipes): allow recipes storage path on a different drive (Windows)
os.path.commonpath raises ValueError for paths on different Windows
drives. Treat that as no common root so cross-drive recipes migrations
succeed instead of failing with 'Invalid recipes path change'.
2026-08-06 22:18:24 +08:00
Will Miao 7df83f44b8 feat(SaveImageLM): add add_loras_to_prompt toggle to restore legacy lora syntax line in metadata 2026-08-06 15:58:18 +08:00
Will Miao 169fa7bed6 fix(vue-widgets): resolve pre-existing typecheck errors 2026-08-06 15:33:02 +08:00
Will Miao 027b504fe8 refactor(autocomplete): remove unused custom_words and embeddings modelTypes 2026-08-06 15:28:58 +08:00
Will Miao 186ef4da78 refactor(ui): group example image download actions into a submenu
Move the 'Download Missing' / 'Re-process All' example image actions
under a single 'Download Example Images' submenu item in the single-model
and bulk context menus, matching the existing send-to-workflow submenu
pattern. Shorten the submenu labels and update all locale translations.
2026-08-03 21:18:05 +08:00
pixelpaws dc674098e7 Merge pull request #1050 from willmiao/fix/recipes-bulk-content-rating
fix(recipes): enable bulk content rating for selected recipes
2026-08-03 20:58:24 +08:00
Will Miao 9087b4b07c feat(example-images): add missing-only download path and skip existing files
Split the single-model and bulk context menu actions into 'Download
Missing Example Images' (regular endpoint, skips already-processed
models) and 'Re-process Example Images' (force endpoint, retries
failed models).

- start_download accepts model_hashes so a selected subset can be
  processed with the progress-aware skip logic; explicitly targeted
  models bypass the failed/processed model-level guards so per-image
  gaps are filled
- pre-download existence check in the processor skips network requests
  for image files already on disk across all download paths
- force download retries previously failed models and clears their
  failed status on success
- add i18n keys for the new menu items across all locales
2026-08-03 20:52:46 +08:00
73 changed files with 26056 additions and 20227 deletions
+1
View File
@@ -25,6 +25,7 @@ model_cache/
reasonix.toml
.reasonix/
.codegraph/
.playwright-mcp/
# Vue widgets development cache (but keep build output)
vue-widgets/node_modules/
+3 -3
View File
@@ -31,7 +31,7 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov-report=xml:coverage/backend/coverage.xml
```
### Frontend Development (Standalone Web UI)
### Frontend Development (LoRA Manager Web UI)
```bash
npm install
@@ -154,9 +154,9 @@ npm run test:coverage # Generate coverage report
## Frontend UI Architecture
### 1. Standalone Web UI
### 1. LoRA Manager Web UI
- Location: `./static/` and `./templates/`
- Tech: Vanilla JS + CSS, served by standalone server
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
- Tests via npm in root directory
### 2. ComfyUI Custom Node Widgets
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -449,6 +449,12 @@
"compact": "7 (1080p), 8 (2K), 10 (4K)"
},
"displayDensityWarning": "Warning: Higher densities may cause performance issues on systems with limited resources.",
"recipesLayout": "Recipes Layout",
"recipesLayoutHelp": "Choose how recipe cards are arranged: a uniform grid or a masonry (Pinterest-style) layout that preserves each image's aspect ratio.",
"recipesLayoutOptions": {
"grid": "Grid",
"masonry": "Masonry"
},
"showFolderSidebar": "Show Folder Sidebar",
"showFolderSidebarHelp": "Toggle the folder navigation sidebar on model pages. When disabled, the sidebar and hover area stay hidden.",
"cardInfoDisplay": "Card Info Display",
@@ -678,6 +684,7 @@
"deepseek": "DeepSeek",
"groq": "Groq",
"openrouter": "OpenRouter",
"google": "Gemini",
"opencode-go": "OpenCode Go",
"custom": "Custom (OpenAI-compatible)"
},
@@ -773,6 +780,8 @@
"deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs",
"downloadExamples": "Download Example Images",
"downloadMissingExamples": "Download Missing",
"reprocessExamples": "Re-process All",
"clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)",
"resumeMetadataRefreshCount": "Resume ({count} models)",
@@ -808,6 +817,8 @@
"sendToWorkflowReplace": "Send to Workflow (Replace)",
"openExamples": "Open Examples Folder",
"downloadExamples": "Download Example Images",
"downloadMissingExamples": "Download Missing",
"reprocessExamples": "Re-process All",
"replacePreview": "Replace Preview",
"setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder",
@@ -1577,6 +1588,21 @@
"downloadCsv": "Download CSV",
"columnModelName": "Model Name",
"columnError": "Error"
},
"downloadBatchSummary": {
"title": "Batch Download Summary",
"statSuccess": "Success",
"statFailed": "Failed",
"statTotal": "Total",
"successMessage": "All {count} models downloaded successfully",
"completedWithErrors": "Completed with errors",
"failed": "Download failed",
"failedItems": "Failed Items ({count})",
"columnName": "Model Name",
"columnError": "Error",
"close": "Close",
"copyReport": "Copy Report",
"retryFailed": "Retry Failed ({count})"
}
},
"modelTags": {
@@ -2033,7 +2059,6 @@
"presetNameTooLong": "Preset name must be {max} characters or less",
"presetNameInvalidChars": "Preset name contains invalid characters",
"presetNameExists": "A preset with this name already exists",
"maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
"presetNotFound": "Preset not found",
"invalidPreset": "Invalid preset data",
"deletePresetFailed": "Failed to delete preset",
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+2249 -2224
View File
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -252,6 +252,13 @@ class SaveImageLM:
"tooltip": "When enabled, embeds generation parameters into the saved image metadata. Disable to skip writing generation metadata.",
},
),
"add_loras_to_prompt": (
"BOOLEAN",
{
"default": False,
"tooltip": "When enabled, appends the LoRA syntax line (e.g. <lora:name:strength>) after the positive prompt in the saved metadata.",
},
),
"add_counter_to_filename": (
"BOOLEAN",
{
@@ -348,7 +355,7 @@ class SaveImageLM:
type_lower = model_type.lower() if model_type else "other"
return f"urn:air:{slug}:{type_lower}:civitai:{model_id}@{version_id}"
def format_metadata(self, metadata_dict: dict) -> str:
def format_metadata(self, metadata_dict: dict, add_loras_to_prompt: bool = False) -> str:
"""Format metadata as A1111-compatible parameters string with Hashes JSON and Civitai resources."""
if not metadata_dict: return ""
@@ -458,7 +465,10 @@ class SaveImageLM:
scheduler_name = scheduler_mapping.get(scheduler, scheduler) if scheduler else None
# Build output lines
lines = [prompt] if prompt else [""]
prompt_line = prompt if prompt else ""
if add_loras_to_prompt and loras_text:
prompt_line = f"{prompt_line}\n{loras_text}" if prompt_line else loras_text
lines = [prompt_line] if prompt_line else [""]
if negative_prompt:
lines.append(f"Negative prompt: {negative_prompt}")
@@ -793,6 +803,7 @@ class SaveImageLM:
save_with_metadata=True,
add_counter_to_filename=True,
save_as_recipe=False,
add_loras_to_prompt=False,
):
"""Save images with metadata"""
results = []
@@ -801,7 +812,7 @@ class SaveImageLM:
raw_metadata = get_metadata()
metadata_dict = MetadataProcessor.to_dict(raw_metadata, id)
metadata = self.format_metadata(metadata_dict)
metadata = self.format_metadata(metadata_dict, add_loras_to_prompt)
# Process filename_prefix with pattern substitution
filename_prefix = self.format_filename(filename_prefix, metadata_dict)
@@ -943,6 +954,7 @@ class SaveImageLM:
save_with_metadata=True,
add_counter_to_filename=True,
save_as_recipe=False,
add_loras_to_prompt=False,
):
"""Process and save image with metadata"""
# Make sure the output directory exists
@@ -974,6 +986,7 @@ class SaveImageLM:
save_with_metadata,
add_counter_to_filename,
save_as_recipe,
add_loras_to_prompt,
)
return {
+66 -1
View File
@@ -1488,8 +1488,73 @@ class ModelQueryHandler:
search = request.query.get("search", "").strip()
limit = min(int(request.query.get("limit", "15")), 100)
offset = max(0, int(request.query.get("offset", "0")))
folder = request.query.get("folder")
recursive = request.query.get("recursive", "true").lower() == "true"
base_models = list(request.query.getall("base_model", []))
model_types = list(request.query.getall("model_type", []))
tag_filters: Dict[str, str] = {}
for tag in request.query.getall("tag_include", []):
if tag:
tag_filters[tag] = "include"
for tag in request.query.getall("tag_exclude", []):
if tag:
tag_filters[tag] = "exclude"
auto_tag_filters: Dict[str, str] = {}
for tag in request.query.getall("auto_tag_include", []):
if tag:
auto_tag_filters[tag] = "include"
for tag in request.query.getall("auto_tag_exclude", []):
if tag:
auto_tag_filters[tag] = "exclude"
tag_logic = request.query.get("tag_logic", "any").lower()
if tag_logic not in ("any", "all"):
tag_logic = "any"
credit_required = request.query.get("credit_required")
if credit_required is not None:
credit_required = credit_required.lower() not in ("false", "0", "")
allow_selling_generated_content = request.query.get(
"allow_selling_generated_content"
)
if allow_selling_generated_content is not None:
allow_selling_generated_content = (
allow_selling_generated_content.lower() not in ("false", "0", "")
)
# The presence of the recursive param (always sent by the loras
# widget when filter mode is on) signals that the filter pipeline
# must run even when no concrete filter is set, so global settings
# like show_only_sfw stay consistent with the list endpoint.
apply_filters = (
"recursive" in request.query
or folder is not None
or bool(base_models)
or bool(model_types)
or bool(tag_filters)
or bool(auto_tag_filters)
or credit_required is not None
or allow_selling_generated_content is not None
)
matching_paths = await self._service.search_relative_paths(
search, limit, offset
search,
limit,
offset,
folder=folder,
recursive=recursive,
base_models=base_models,
model_types=model_types,
tags=tag_filters,
auto_tags=auto_tag_filters,
tag_logic=tag_logic,
credit_required=credit_required,
allow_selling_generated_content=allow_selling_generated_content,
apply_filters=apply_filters,
)
return web.json_response(
{"success": True, "relative_paths": matching_paths}
+39 -2
View File
@@ -10,7 +10,7 @@ import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
from aiohttp import web
@@ -44,6 +44,22 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
RecipeScannerGetter = Callable[[], Any]
CivitaiClientGetter = Callable[[], Any]
# Cap concurrent preview-dimension reads across requests. With a cold LRU
# cache one page can touch up to page_size image files; 16 balances SSD and
# HDD throughput without starving the event loop.
_DIMS_READ_SEMAPHORE = asyncio.Semaphore(16)
async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
"""Read preview dimensions off the event loop under the concurrency cap.
PIL I/O runs in a worker thread so it never blocks the event loop, and the
semaphore bounds how many files are opened at once even when many list
requests land together.
"""
async with _DIMS_READ_SEMAPHORE:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
@dataclass(frozen=True)
class RecipeHandlerSet:
@@ -246,7 +262,8 @@ class RecipeListingHandler:
recursive=recursive,
)
for item in result.get("items", []):
items = result.get("items", [])
for item in items:
file_path = item.get("file_path")
if file_path:
item["file_url"] = self.format_recipe_file_url(file_path)
@@ -255,6 +272,26 @@ class RecipeListingHandler:
item.setdefault("loras", [])
item.setdefault("base_model", "")
# Batch preview dimension reads with asyncio.gather. The previous
# loop awaited asyncio.to_thread once per item, so a page_size=100
# request submitted 100 sequential thread calls (50-300ms cold-page
# latency). gather runs them concurrently while the semaphore caps
# disk opens; dimensions stay omitted (not null) when a preview has
# no readable size (video, missing file).
to_read = [
(i, item.get("file_path"))
for i, item in enumerate(items)
if item.get("file_path")
]
if to_read:
dims_list = await asyncio.gather(
*(_read_preview_dims(path) for _, path in to_read)
)
for (idx, _), dims in zip(to_read, dims_list):
if dims:
item = items[idx]
item["width"], item["height"] = dims
return web.json_response(result)
except Exception as exc:
self._logger.error("Error retrieving recipes: %s", exc, exc_info=True)
+71 -3
View File
@@ -1259,19 +1259,87 @@ class BaseModelService(ABC):
)
async def search_relative_paths(
self, search_term: str, limit: int = 15, offset: int = 0
self,
search_term: str,
limit: int = 15,
offset: int = 0,
*,
folder: Optional[str] = None,
folder_include: Optional[list] = None,
folder_exclude: Optional[list] = None,
base_models: Optional[list] = None,
model_types: Optional[list] = None,
tags: Optional[dict] = None,
auto_tags: Optional[dict] = None,
tag_logic: str = "any",
credit_required: Optional[bool] = None,
allow_selling_generated_content: Optional[bool] = None,
recursive: bool = True,
apply_filters: bool = False,
) -> List[str]:
"""Search model relative file paths for autocomplete functionality"""
"""Search model relative file paths for autocomplete functionality.
Optional filter kwargs mirror the filters used by the list endpoint
(/api/lm/{prefix}/list). When no filter kwargs are provided the
behavior is identical to plain token-based path matching.
"""
cache = await self.scanner.get_cached_data()
include_terms, exclude_terms = self._parse_search_tokens(search_term)
data = cache.raw_data
has_filters = any(
[
apply_filters,
folder is not None,
folder_include,
folder_exclude,
base_models,
model_types,
tags,
auto_tags,
credit_required is not None,
allow_selling_generated_content is not None,
]
)
if has_filters:
# Auto-tags are not stored in the scanner cache — they are computed
# on the fly. Pre-compute them only when an auto-tag filter is
# active to avoid mutating cache entries unnecessarily.
if auto_tags:
from .auto_tag_service import extract_auto_tags
for item in data:
if not item.get("auto_tags"):
item["auto_tags"] = extract_auto_tags(item)
criteria = FilterCriteria(
folder=folder,
folder_include=folder_include,
folder_exclude=folder_exclude,
base_models=base_models,
model_types=model_types,
tags=tags,
auto_tags=auto_tags,
search_options={"recursive": recursive},
tag_logic=tag_logic,
)
data = self.filter_set.apply(data, criteria)
if credit_required is not None:
data = await self._apply_credit_required_filter(
data, credit_required
)
if allow_selling_generated_content is not None:
data = await self._apply_allow_selling_filter(
data, allow_selling_generated_content
)
matching_paths = []
# Get model roots for path calculation
model_roots = self.scanner.get_model_roots()
# Collect all matching paths first (needed for proper sorting and offset)
for model in cache.raw_data:
for model in data:
file_path = model.get("file_path", "")
if not file_path:
continue
+5
View File
@@ -201,6 +201,11 @@ PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
"api_base": "https://openrouter.ai/api/v1",
"requires_key": True,
},
"google": {
"name": "Gemini",
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai",
"requires_key": True,
},
"opencode-go": {
"name": "OpenCode Go",
"api_base": "https://opencode.ai/zen/go/v1",
+6 -3
View File
@@ -92,6 +92,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"mature_blur_level": "R",
"autoplay_on_hover": False,
"display_density": "default",
"recipes_layout": "grid",
"card_info_display": "always",
"include_trigger_words": False,
"compact_mode": False,
@@ -1473,10 +1474,12 @@ class SettingsManager:
try:
common_root = os.path.commonpath([source, target])
except ValueError as exc:
raise ValueError("Invalid recipes path change") from exc
except ValueError:
# Windows: paths on different drives share no common root.
# A cross-drive move is valid, so treat it as no common root.
common_root = None
if common_root == source:
if common_root is not None and common_root == source:
raise ValueError("Recipes path cannot be moved into a nested directory")
planned_recipe_updates: Dict[str, Dict[str, Any]] = {}
+49 -27
View File
@@ -172,6 +172,7 @@ class DownloadManager:
model_types = data.get("model_types", ["lora", "checkpoint"])
delay = float(data.get("delay", 0.2))
force = data.get("force", False)
model_hashes = data.get("model_hashes", [])
# Step 2: Validate configuration (fast lookup)
settings_manager = get_settings_manager()
@@ -241,6 +242,7 @@ class DownloadManager:
delay,
active_library,
force,
model_hashes,
)
)
@@ -577,8 +579,9 @@ class DownloadManager:
delay,
library_name,
force: bool = False,
model_hashes: list[str] | None = None,
):
"""Download example images for all models."""
"""Download example images for all models (or only the given hashes)."""
downloader = await get_downloader()
@@ -606,6 +609,18 @@ class DownloadManager:
if model.get("sha256"):
all_models.append((scanner_type, model, scanner))
# Restrict to the requested hashes when provided (empty = all models).
# Explicit targets are a directed user request, so previously failed
# models are retried instead of skipped.
explicit_targets = bool(model_hashes)
if model_hashes:
hash_set = {h.lower() for h in model_hashes}
all_models = [
(scanner_type, model, scanner)
for scanner_type, model, scanner in all_models
if model.get("sha256", "").lower() in hash_set
]
# Update total count
self._progress["total"] = len(all_models)
logger.debug(f"Found {self._progress['total']} models to process")
@@ -629,6 +644,7 @@ class DownloadManager:
downloader,
library_name,
force,
explicit_targets,
)
# Update progress
@@ -725,6 +741,7 @@ class DownloadManager:
downloader,
library_name,
force: bool = False,
explicit_targets: bool = False,
):
"""Process a single model download."""
@@ -747,8 +764,9 @@ class DownloadManager:
self._progress["current_model"] = f"{model_name} ({model_hash[:8]})"
await self._broadcast_progress(status="running")
# Skip if already in failed models (unless force mode is enabled)
if not force and model_hash in self._progress["failed_models"]:
# Skip if already in failed models (unless force mode is enabled or
# the model was explicitly targeted by hash)
if not force and not explicit_targets and model_hash in self._progress["failed_models"]:
logger.debug(f"Skipping known failed model: {model_name}")
return False
@@ -757,30 +775,34 @@ class DownloadManager:
)
existing_files = _model_directory_has_files(model_dir)
# Skip if already processed AND directory exists with files
if model_hash in self._progress["processed_models"]:
if existing_files:
logger.debug(f"Skipping already processed model: {model_name}")
# Model-level guard: a populated folder counts as done. Explicitly
# targeted models bypass it so the per-image existence pre-check can
# fill individual gaps without re-fetching existing files.
if not explicit_targets:
# Skip if already processed AND directory exists with files
if model_hash in self._progress["processed_models"]:
if existing_files:
logger.debug(f"Skipping already processed model: {model_name}")
return False
logger.debug(
"Model %s (%s) marked as processed but folder empty or missing, reprocessing triggered",
model_name,
model_hash,
)
# Track that we are reprocessing this model for summary logging
self._progress["reprocessed_models"].add(model_hash)
# Remove from processed models since we need to reprocess
self._progress["processed_models"].discard(model_hash)
if existing_files and model_hash not in self._progress["processed_models"]:
logger.debug(
"Model folder already populated for %s, marking as processed without download",
model_name,
)
self._progress["processed_models"].add(model_hash)
return False
logger.debug(
"Model %s (%s) marked as processed but folder empty or missing, reprocessing triggered",
model_name,
model_hash,
)
# Track that we are reprocessing this model for summary logging
self._progress["reprocessed_models"].add(model_hash)
# Remove from processed models since we need to reprocess
self._progress["processed_models"].discard(model_hash)
if existing_files and model_hash not in self._progress["processed_models"]:
logger.debug(
"Model folder already populated for %s, marking as processed without download",
model_name,
)
self._progress["processed_models"].add(model_hash)
return False
if not model_dir:
logger.warning(
"Unable to resolve example images folder for model %s (%s)",
@@ -884,7 +906,7 @@ class DownloadManager:
model_name,
)
# Clear failed_models so non-force runs can retry
if force and model_hash in self._progress["failed_models"]:
if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash)
logger.info(
f"Removed {model_name} from failed_models after force retry with rate-limited images"
@@ -904,7 +926,7 @@ class DownloadManager:
)
elif success:
self._progress["processed_models"].add(model_hash)
if force and model_hash in self._progress["failed_models"]:
if (force or explicit_targets) and model_hash in self._progress["failed_models"]:
self._progress["failed_models"].discard(model_hash)
logger.info(
f"Removed {model_name} from failed_models after successful force retry"
+31 -1
View File
@@ -113,6 +113,26 @@ class ExampleImagesProcessor:
message = str(error).lower()
return '404' in message or 'file not found' in message
@staticmethod
def _example_image_file_exists(model_dir: str, index: int, media_type_hint: str | None = None) -> bool:
"""Return True when the file that would be written for a media index already exists.
The final filename (``image_{index}{extension}``) depends on the downloaded
content, so the extension cannot be known ahead of time. The post-download
check skips the write when the exact target file exists; this pre-check
approximates that with the candidate extensions for the media type (videos
only when the metadata hints at a video) so the network request is avoided
for files that already exist on disk.
"""
if media_type_hint == "video":
extensions = SUPPORTED_MEDIA_EXTENSIONS['videos']
else:
extensions = SUPPORTED_MEDIA_EXTENSIONS['images']
return any(
os.path.exists(os.path.join(model_dir, f"image_{index}{ext}"))
for ext in extensions
)
@staticmethod
async def download_model_images(model_hash, model_name, model_images, model_dir, optimize, downloader):
"""Download images for a single model
@@ -139,7 +159,12 @@ class ExampleImagesProcessor:
original_url = image_url
if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
# Skip the download when the file already exists on disk
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
logger.debug("File already exists, skipping download for %s", image_url)
continue
# Download the file first to determine the actual file type
try:
logger.debug(f"Downloading media file {i} for {model_name}")
@@ -229,6 +254,11 @@ class ExampleImagesProcessor:
if optimize and 'civitai.com' in image_url:
image_url = ExampleImagesProcessor.get_civitai_optimized_url(image_url)
# Skip the download when the file already exists on disk
if ExampleImagesProcessor._example_image_file_exists(model_dir, i, image.get("type")):
logger.debug("File already exists, skipping download for %s", image_url)
continue
async def _attempt_download() -> tuple:
logger.debug("Downloading media file %s for %s", i, model_name)
return await downloader.download_to_memory(
+37 -1
View File
@@ -1,9 +1,10 @@
import functools
import json
import logging
import os
import struct
from io import BytesIO
from typing import Any, Optional
from typing import Any, Optional, Tuple
import piexif
from PIL import Image, PngImagePlugin
@@ -17,6 +18,22 @@ except ImportError:
logger = logging.getLogger(__name__)
@functools.lru_cache(maxsize=2048)
def _get_image_dimensions_cached(path: str, _mtime_ns: int, _size: int) -> Optional[Tuple[int, int]]:
"""Return ``(width, height)`` for ``path``, or ``None`` on any failure.
The ``_mtime_ns`` and ``_size`` arguments are part of the cache key only;
they invalidate the entry when the file is replaced with a new image, so a
stale preview never serves outdated dimensions.
"""
try:
with Image.open(path) as img:
return img.size
except Exception:
return None
class ExifUtils:
"""Utility functions for working with EXIF data in images"""
@@ -422,6 +439,25 @@ class ExifUtils:
# Metadata is in the middle of the string
return user_comment[:recipe_marker_index] + user_comment[next_line_index:]
@staticmethod
def get_image_dimensions(image_path: str) -> Optional[Tuple[int, int]]:
"""Return ``(width, height)`` for an image, or ``None`` if unavailable.
Video containers (``.mp4``/``.webm``/``.avi``) and formats PIL cannot
read (``.avif``/``.jxl``) return ``None`` before PIL is invoked.
Missing or corrupt files return ``None``. Never raises.
"""
try:
ext = os.path.splitext(image_path)[1].lower()
if ext in ('.mp4', '.webm', '.avi', '.avif', '.jxl'):
return None
stat = os.stat(image_path)
return _get_image_dimensions_cached(
image_path, stat.st_mtime_ns, stat.st_size
)
except Exception:
return None
@staticmethod
def optimize_image(image_data, target_width=250, format='webp', quality=85, preserve_metadata=False):
"""
+4
View File
@@ -31,6 +31,10 @@
overflow: hidden;
}
.card-grid.masonry-layout .model-card {
aspect-ratio: auto;
}
.model-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
@@ -0,0 +1,67 @@
/* Batch Download Summary Modal component styles only.
Stat cards and failure table styles are shared with the metadata refresh
result modal (metadata-refresh-result.css) and are not redefined here. */
.download-batch-summary-modal {
max-width: 700px;
}
.summary-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-2) 0;
}
.summary-header i {
font-size: 1.4em;
}
.summary-header.success i {
color: var(--color-success);
}
.summary-header.warning i {
color: var(--color-warning);
}
.summary-header.error i {
color: var(--color-error);
}
.summary-title {
font-weight: var(--weight-semibold);
color: var(--lora-text);
}
.summary-hint {
margin-left: auto;
font-size: var(--text-xs);
color: var(--text-secondary);
}
.btn-retry {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--lora-accent, #4f46e5);
color: #fff;
border: none;
border-radius: var(--border-radius-sm);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-weight: var(--weight-semibold);
}
.btn-retry:hover {
background: var(--lora-accent-hover, #4338ca);
}
.failure-link {
color: var(--lora-accent, #4f46e5);
text-decoration: none;
}
.failure-link:hover {
text-decoration: underline;
}
+3 -2
View File
@@ -145,13 +145,14 @@
position: fixed;
right: 20px;
top: 50px; /* Position below header */
width: 366px;
width: 420px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
box-shadow: var(--shadow-md);
z-index: var(--z-overlay);
padding: 16px;
box-sizing: border-box; /* Include padding in max-height calculation */
transition: transform 0.3s ease, opacity 0.3s ease;
transform-origin: top right;
max-height: calc(100vh - 70px); /* Adjusted for header height */
@@ -563,7 +564,7 @@
align-items: center;
gap: 6px;
white-space: nowrap;
max-width: 120px; /* Prevent long names from breaking layout */
max-width: 180px; /* Prevent long names from breaking layout */
overflow: hidden;
text-overflow: ellipsis;
}
+1
View File
@@ -41,6 +41,7 @@
@import 'components/sidebar.css'; /* Add sidebar component */
@import 'components/media-viewer.css';
@import 'components/metadata-refresh-result.css';
@import 'components/download-batch-summary.css';
.initialization-notice {
display: flex;
+2 -1
View File
@@ -184,7 +184,8 @@ export const DOWNLOAD_ENDPOINTS = {
downloadGet: '/api/lm/download-model-get',
cancelGet: '/api/lm/cancel-download-get',
progress: '/api/lm/download-progress',
exampleImages: '/api/lm/force-download-example-images' // New endpoint for downloading example images
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
};
// Hugging Face API endpoints
+8 -2
View File
@@ -1641,7 +1641,7 @@ export class BaseModelApiClient {
}
}
async downloadExampleImages(modelHashes, modelTypes = null) {
async downloadExampleImages(modelHashes, modelTypes = null, { force = true } = {}) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
@@ -1700,8 +1700,13 @@ export class BaseModelApiClient {
// Determine optimize setting
const optimize = state.global?.settings?.optimize_example_images ?? true;
// force=false routes to the regular endpoint, which skips already-processed models
const endpoint = force
? DOWNLOAD_ENDPOINTS.exampleImages
: DOWNLOAD_ENDPOINTS.exampleImagesMissing;
// Make the API request to start the download process
const response = await fetch(DOWNLOAD_ENDPOINTS.exampleImages, {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -1710,6 +1715,7 @@ export class BaseModelApiClient {
model_hashes: modelHashes,
output_dir: outputDir,
optimize: optimize,
force: force,
model_types: modelTypes || [this.apiConfig.config.singularName]
})
});
@@ -137,11 +137,10 @@ export class BulkContextMenu extends BaseContextMenu {
downloadMissingLorasItem.style.display = currentModelType === 'recipes' ? 'flex' : 'none';
}
const downloadExampleImagesItem = this.menu.querySelector('[data-action="download-example-images"]');
if (downloadExampleImagesItem) {
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
if (downloadExampleImagesSubmenu) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
const modelPages = ['loras', 'checkpoints', 'embeddings'];
downloadExampleImagesItem.style.display = modelPages.includes(currentModelType) ? 'flex' : 'none';
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
@@ -294,8 +293,11 @@ export class BulkContextMenu extends BaseContextMenu {
case 'download-missing-loras':
this.handleDownloadMissingLoras();
break;
case 'download-missing-example-images':
this.handleDownloadExampleImages({ force: false });
break;
case 'download-example-images':
this.handleDownloadExampleImages();
this.handleDownloadExampleImages({ force: true });
break;
case 'clear':
bulkManager.clearSelection();
@@ -340,7 +342,7 @@ export class BulkContextMenu extends BaseContextMenu {
await bulkMissingLoraDownloadManager.downloadMissingLoras(selectedRecipes);
}
async handleDownloadExampleImages() {
async handleDownloadExampleImages({ force = true } = {}) {
if (state.selectedModels.size === 0) {
return;
}
@@ -361,7 +363,7 @@ export class BulkContextMenu extends BaseContextMenu {
try {
const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([...hashes]);
await apiClient.downloadExampleImages([...hashes], null, { force });
} catch (error) {
console.error('Bulk download example images failed:', error);
}
@@ -347,7 +347,10 @@ export const ModelContextMenuMixin = {
openExampleImagesFolder(this.currentCard.dataset.sha256);
return true;
case 'download-examples':
this.downloadExampleImages();
this.downloadExampleImages(false);
return true;
case 'download-examples-force':
this.downloadExampleImages(true);
return true;
case 'civitai':
if (this.currentCard.dataset.from_civitai === 'true') {
@@ -378,7 +381,7 @@ export const ModelContextMenuMixin = {
},
// Download example images method
async downloadExampleImages() {
async downloadExampleImages(force = false) {
const modelHash = this.currentCard.dataset.sha256;
if (!modelHash) {
showToast('toast.contextMenu.missingHash', {}, 'error');
@@ -387,7 +390,7 @@ export const ModelContextMenuMixin = {
try {
const apiClient = getModelApiClient();
await apiClient.downloadExampleImages([modelHash]);
await apiClient.downloadExampleImages([modelHash], null, { force });
} catch (error) {
console.error('Error downloading example images:', error);
}
@@ -0,0 +1,340 @@
import { translate } from '../utils/i18nHelpers.js';
import { showToast, openHuggingFace } from '../utils/uiHelpers.js';
/**
* Escape HTML entities in a string to prevent injection when interpolating into innerHTML.
* Safe for both text content and attribute values (quotes are escaped too).
* @param {string} str - The string to escape
* @returns {string} - The escaped string
*/
function _escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Resolve the display name of a failed download entry.
* Prefers the resolved name carried on the entry, then known item fields,
* then derives a name from the item URL as a last resort.
* @param {Object} entry - The failed entry ({ item, error, name? })
* @returns {string} - The best available display name
*/
function _resolveItemName(entry) {
if (entry?.name) {
return entry.name;
}
const item = entry?.item ?? entry;
const direct = item?.displayName || item?.name || item?.file_name || item?.filename || item?.selectedVersion?.name;
if (direct) {
return direct;
}
if (item?.url) {
try {
const segments = new URL(item.url).pathname.split('/').filter(Boolean);
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1]);
}
} catch (e) {
// Unparseable URL — fall through to 'Unknown'
}
}
return 'Unknown';
}
/**
* Resolve the URL to open for a failed item always the original item URL.
* @param {Object} item - The failed item payload
* @returns {string|null} - A URL string, or null when nothing is available
*/
function _resolveItemUrl(item) {
return item?.url || null;
}
/**
* Format a raw failure error into a concise human-readable message.
* Unwraps JSON envelopes and extracts HTTP status/body details when present.
* @param {*} error - The raw error (usually a string)
* @returns {string} - The formatted error message
*/
function _formatError(error) {
if (!error) {
return 'Unknown error';
}
let base = typeof error === 'string' ? error : String(error);
// Unwrap JSON envelope: { "success": false, "error": "...", ... }
try {
const parsed = JSON.parse(base);
if (parsed && typeof parsed.error === 'string' && parsed.error) {
base = parsed.error;
}
} catch (e) {
// Not a JSON envelope — keep the raw string
}
// Extract HTTP status and JSON body details, e.g. "status=403 body={...}"
let result = base;
const statusMatch = base.match(/status=(\d{3})/);
const bodyMatch = base.match(/body=(\{.*\})/s);
if (bodyMatch) {
try {
const body = JSON.parse(bodyMatch[1]);
const detail = (typeof body?.message === 'string' && body.message)
|| (typeof body?.error === 'string' && body.error)
|| null;
if (detail) {
const status = statusMatch ? statusMatch[1] : null;
result = `${status ? `HTTP ${status}` : ''}${detail}`;
}
} catch (e) {
// Body is not valid JSON — keep the base string
}
}
// Truncate overly long messages
if (result.length > 220) {
result = result.slice(0, 220) + '…';
}
return result;
}
/**
* Build a plain-text report of the batch download results.
* @param {number} total - Total number of models attempted
* @param {number} completed - Number of models successfully downloaded
* @param {Array} failedItems - Array of failed items ({ item, error })
* @returns {string} - The report text
*/
function _buildReportText(total, completed, failedItems) {
const lines = [
'=== Batch Download Report ===',
`Date: ${new Date().toLocaleString()}`,
`Total: ${total}`,
`Successfully downloaded: ${completed}`,
`Failed: ${failedItems.length}`,
'',
];
if (failedItems.length > 0) {
lines.push('--- Failed Items ---');
failedItems.forEach((entry, i) => {
const name = _resolveItemName(entry);
const error = _formatError(entry?.error);
lines.push(`${i + 1}. ${name}${error}`);
const itemUrl = _resolveItemUrl(entry?.item ?? entry);
if (itemUrl) {
lines.push(` URL: ${itemUrl}`);
}
});
lines.push('');
}
lines.push('====================');
return lines.join('\n');
}
/**
* Handle a successful clipboard write: confirm via toast and briefly swap the
* trigger button to a "Copied!" state.
* @param {HTMLElement|null} btn - The button that triggered the copy action
*/
function _onCopyReportSuccess(btn) {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}
/**
* Fallback for environments without the async Clipboard API (e.g. insecure
* contexts over LAN http where `navigator.clipboard` is undefined): copy via a
* hidden textarea and `document.execCommand('copy')`.
* @param {string} text - The report text to copy
*/
function _copyReportWithExecCommand(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
}
/**
* Copy the batch download report to the clipboard.
* Uses the async Clipboard API when available, otherwise falls back to a hidden
* textarea + execCommand so the action still works in insecure contexts.
* @param {HTMLElement} btn - The button that triggered the copy action
* @param {number} total - Total number of models attempted
* @param {number} completed - Number of models successfully downloaded
* @param {Array} failedItems - Array of failed items
*/
function _copyReport(btn, total, completed, failedItems) {
const text = _buildReportText(total, completed, failedItems);
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text)
.then(() => _onCopyReportSuccess(btn))
.catch(() => _copyReportWithExecCommand(text));
} else {
_copyReportWithExecCommand(text);
}
}
/**
* Show the batch download summary modal after a batch download completes.
* Mirrors the Metadata Fetch Summary modal lifecycle: the modal element is
* appended directly to document.body and removed on close; it is not
* registered with ModalManager.
* @param {Object} options - Summary options
* @param {number} options.total - Total number of models attempted
* @param {number} options.completed - Number of models successfully downloaded
* @param {Array} options.failedItems - Array of failed items ({ item, error })
* @param {Function} options.onRetry - Callback invoked with failedItems to retry the failed subset
*/
export function showDownloadBatchSummary({ total, completed, failedItems, onRetry }) {
const failures = failedItems || [];
const failedCount = failures.length;
// 3-state summary header semantics (mirrors BatchImportManager results header)
let headerState;
let headerIcon;
let headerText;
if (completed === 0) {
headerState = 'error';
headerIcon = 'fa-times-circle';
headerText = translate('modals.downloadBatchSummary.failed', {}, 'Download failed');
} else if (failedCount > 0) {
headerState = 'warning';
headerIcon = 'fa-exclamation-circle';
headerText = translate('modals.downloadBatchSummary.completedWithErrors', {}, 'Completed with errors');
} else {
headerState = 'success';
headerIcon = 'fa-check-circle';
headerText = translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully');
}
// Build failure table rows
const failureRows = failures.map((entry, i) => {
const item = entry?.item ?? entry;
const name = _resolveItemName(entry);
const itemUrl = _resolveItemUrl(item);
const rawError = entry?.error ? String(entry.error) : '';
const error = _formatError(entry?.error);
const nameCell = itemUrl
? `<td class="failure-name"><a href="#" class="failure-link" data-action="open-model" data-index="${i}" title="${_escapeHtml(itemUrl)}">${_escapeHtml(name)}</a></td>`
: `<td class="failure-name" title="${_escapeHtml(name)}">${_escapeHtml(name)}</td>`;
return `<tr>
<td class="failure-index">${i + 1}</td>
${nameCell}
<td class="failure-error" title="${_escapeHtml(rawError)}">${_escapeHtml(error)}</td>
</tr>`;
}).join('');
const modalHtml = `
<div id="downloadBatchSummaryModal" class="modal" style="display: block;">
<div class="modal-content download-batch-summary-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.downloadBatchSummary.title', {}, 'Batch Download Summary')}</h2>
<div class="summary-header ${headerState}">
<i class="fas ${headerIcon}"></i>
<span class="summary-title">${headerText}</span>
<span class="summary-hint">${completed}/${total}</span>
</div>
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statSuccess', {}, 'Success')}</span>
<span class="stat-card-value">${completed}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statFailed', {}, 'Failed')}</span>
<span class="stat-card-value">${failedCount}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.downloadBatchSummary.statTotal', {}, 'Total')}</span>
<span class="stat-card-value">${total}</span>
</div>
</div>
</div>
${failedCount > 0 ? `
<div class="refresh-failures-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.downloadBatchSummary.failedItems', { count: failedCount }, 'Failed Items (' + failedCount + ')')}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.downloadBatchSummary.columnName', {}, 'Model Name')}</th>
<th>${translate('modals.downloadBatchSummary.columnError', {}, 'Error')}</th>
</tr>
</thead>
<tbody>${failureRows}</tbody>
</table>
</div>
</div>
` : `
<div class="refresh-success-message">
<i class="fas fa-check-circle"></i> ${translate('modals.downloadBatchSummary.successMessage', { count: completed }, 'All ' + completed + ' models downloaded successfully')}
</div>
`}
<div class="modal-actions">
${failedCount > 0 ? `
<button class="btn-retry" data-action="retry-failed"><i class="fas fa-redo"></i> ${translate('modals.downloadBatchSummary.retryFailed', { count: failedCount }, 'Retry Failed (' + failedCount + ')')}</button>
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.downloadBatchSummary.copyReport', {}, 'Copy Report')}</button>
` : ''}
<button class="cancel-btn" data-action="close-modal">${translate('modals.downloadBatchSummary.close', {}, 'Close')}</button>
</div>
</div>
</div>
`;
const existing = document.getElementById('downloadBatchSummaryModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => {
const actionEl = e.target.closest('[data-action]');
const action = actionEl?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'retry-failed':
modal.remove();
if (typeof onRetry === 'function') {
onRetry(failures);
}
break;
case 'copy-report':
_copyReport(actionEl, total, completed, failures);
break;
case 'open-model': {
// Keep the modal open; just open the item's original URL in a new tab
const entry = failures[Number(actionEl.dataset.index)];
const item = entry?.item;
if (!item?.url) break;
openHuggingFace(item.url);
break;
}
}
});
}
+12 -3
View File
@@ -2,6 +2,7 @@
import { showToast } from '../utils/uiHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { recreateVirtualScroll } from '../utils/infiniteScroll.js';
export class DuplicatesManager {
constructor(recipeManager) {
@@ -71,7 +72,7 @@ export class DuplicatesManager {
this.updateSelectedCount();
}
exitDuplicateMode() {
async exitDuplicateMode() {
this.inDuplicateMode = false;
this.selectedForDeletion.clear();
@@ -94,8 +95,16 @@ export class DuplicatesManager {
recipeGrid.innerHTML = '';
}
// Re-enable virtual scrolling
state.virtualScroller.enable();
// Re-enable virtual scrolling, or apply a layout switch deferred
// while duplicates mode was active (enabling the old scroller first
// would let its pending rAF render repopulate the grid after the
// new scroller is created, leaving orphaned/overlapping cards).
if (state.pendingLayoutRecreate) {
state.pendingLayoutRecreate = false;
await recreateVirtualScroll('recipes');
} else if (state.virtualScroller) {
state.virtualScroller.enable();
}
}
renderDuplicateGroups() {
+128 -11
View File
@@ -6,8 +6,9 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { extractCivitaiModelUrlParts } from '../utils/civitaiUtils.js';
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js';
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
export class DownloadManager {
constructor() {
@@ -234,6 +235,9 @@ export class DownloadManager {
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
} else {
// No explicit version id in the URL → default to the latest version (Civitai returns newest first)
this.currentVersion = this.versions[0];
}
this.showVersionStep();
@@ -427,6 +431,9 @@ export class DownloadManager {
await this.retrieveVersionsForModel(this.modelId, this.source);
if (this.modelVersionId) {
this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId);
} else {
// No explicit version id → default to the latest version (Civitai returns newest first)
this.currentVersion = this.versions[0];
}
this.showVersionStep();
} catch (error) {
@@ -878,6 +885,26 @@ export class DownloadManager {
this.updateTargetPath();
}
/**
* Synthesize a clickable URL for a single-download failure entry.
* Single downloads have no pasted URL, so the modal link is derived from
* the model/version ids (CivitAI) or the HF repo/file (HuggingFace).
*/
_buildSingleItemUrl({ modelId, versionId, source, repo = null, filename = null }) {
if (source === 'huggingface' && repo) {
const base = `https://huggingface.co/${encodeURI(repo)}`;
return filename ? `${base}/blob/${encodeURI('main')}/${encodeURI(filename)}` : base;
}
if (modelId) {
return buildCivitaiUrl({
modelId,
versionId,
host: normalizeCivitaiPageHost(state?.global?.settings?.civitai_host),
});
}
return null;
}
async executeDownloadWithProgress({
modelId,
versionId,
@@ -896,6 +923,7 @@ export class DownloadManager {
}
const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
let ws = null;
let updateProgress = () => { };
let cancelled = false;
@@ -984,6 +1012,26 @@ export class DownloadManager {
return true;
}
if (!response?.success) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId,
versionId,
source,
url: this._buildSingleItemUrl({ modelId, versionId, source }),
},
error: response?.error || 'Unknown error',
name: displayName,
}],
onRetry: () => this.executeDownloadWithProgress(retryParams),
});
return false;
}
showToast('toast.loras.downloadCompleted', {}, 'success');
if (closeModal) {
@@ -1018,7 +1066,21 @@ export class DownloadManager {
console.log('Download cancelled by user:', downloadId);
} else {
console.error('Failed to download model version:', error);
showToast('toast.downloads.downloadError', { message: error?.message }, 'error');
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId,
versionId,
source,
url: this._buildSingleItemUrl({ modelId, versionId, source }),
},
error: error?.message || 'Unknown error',
name: displayName,
}],
onRetry: () => this.executeDownloadWithProgress(retryParams),
});
}
return false;
} finally {
@@ -1033,14 +1095,16 @@ export class DownloadManager {
}
}
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths }) {
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar();
const totalFiles = this.hfSelectedFiles.length;
const filesToDownload = files || this.hfSelectedFiles;
const totalFiles = filesToDownload.length;
const updateProgress = this.loadingManager.showDownloadProgress(totalFiles);
let cancelled = false;
let currentDownloadId = null;
const failedFiles = [];
this.loadingManager.showCancelButton(async () => {
if (cancelled) return;
@@ -1059,7 +1123,7 @@ export class DownloadManager {
for (let i = 0; i < totalFiles; i++) {
if (cancelled) break;
const filename = this.hfSelectedFiles[i];
const filename = filesToDownload[i];
updateProgress(0, completedDownloads, filename);
this.loadingManager.setStatus(`Downloading ${filename}...`);
@@ -1105,6 +1169,31 @@ export class DownloadManager {
if (response?.success) {
completedDownloads++;
updateProgress(100, completedDownloads, filename);
} else {
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
},
error: response?.error || 'Unknown error',
name: filename,
});
}
} catch (err) {
if (!cancelled) {
console.error(`Failed to download HF file ${filename}:`, err);
failedFiles.push({
item: {
source: 'huggingface',
repo: this.hfRepoId,
filename,
url: this._buildSingleItemUrl({ source: 'huggingface', repo: this.hfRepoId, filename }),
},
error: err?.message || 'Unknown error',
name: filename,
});
}
} finally {
ws.close();
@@ -1114,11 +1203,27 @@ export class DownloadManager {
if (cancelled) {
showToast('toast.downloads.downloadStopped', {}, 'info',
`Download cancelled. ${completedDownloads} item(s) completed.`);
} else {
showToast('toast.loras.downloadCompleted', {}, 'success');
await resetAndReload(true);
return true;
}
if (failedFiles.length === 0) {
showToast('toast.loras.downloadCompleted', {}, 'success');
await resetAndReload(true);
return true;
}
showDownloadBatchSummary({
total: totalFiles,
completed: completedDownloads,
failedItems: failedFiles,
onRetry: () => this._downloadHfSingle({
modelRoot,
targetFolder,
useDefaultPaths,
files: failedFiles.map((f) => f.item.filename),
}),
});
await resetAndReload(true);
return true;
return false;
} catch (error) {
if (!cancelled) {
console.error('Failed to download HF model:', error);
@@ -1548,6 +1653,10 @@ export class DownloadManager {
modalManager.closeModal('downloadModal');
return this.executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths });
}
async executeBatchDownload(downloadItems, { modelRoot, targetFolder, useDefaultPaths }) {
const batchDownloadId = Date.now().toString();
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(`${wsProtocol}${window.location.host}/ws/download-progress?id=${batchDownloadId}`);
@@ -1558,6 +1667,7 @@ export class DownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let cancelled = false;
const failedItems = [];
loadingManager.showCancelButton(async () => {
if (cancelled) return;
@@ -1658,6 +1768,7 @@ export class DownloadManager {
if (!response.success) {
failedDownloads++;
failedItems.push({ item, error: response.error || 'Unknown error', name });
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
@@ -1666,6 +1777,7 @@ export class DownloadManager {
if (!cancelled) {
console.error(`Failed to download ${name}:`, err);
failedDownloads++;
failedItems.push({ item, error: err?.message || 'Unknown error', name });
}
}
}
@@ -1679,10 +1791,15 @@ export class DownloadManager {
} else if (failedDownloads === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showToast('toast.loras.downloadPartialSuccess', {
completed: completedDownloads,
showDownloadBatchSummary({
total: downloadItems.length,
}, 'warning');
completed: completedDownloads,
failedItems,
onRetry: (failed) => this.executeBatchDownload(
failed.map((f) => f.item),
{ modelRoot, targetFolder, useDefaultPaths }
),
});
}
await resetAndReload(true);
-21
View File
@@ -6,7 +6,6 @@ import { state } from '../state/index.js';
// Constants for preset management
const PRESETS_STORAGE_VERSION = 'v1';
const MAX_PRESET_NAME_LENGTH = 30;
const MAX_PRESETS_COUNT = 10;
// Marker for when wildcard patterns resolve to no matches
// This ensures we return empty results instead of all models
@@ -362,15 +361,6 @@ export class FilterPresetManager {
}
}
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return false;
}
const preset = {
name: trimmedName,
filters: this.filterManager.cloneFilters(),
@@ -613,17 +603,6 @@ export class FilterPresetManager {
return;
}
// Check max presets limit before showing input
const presets = this.loadPresets();
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return;
}
this.isInlineNamingActive = true;
const presetsContainer = document.getElementById('filterPresets');
+13
View File
@@ -1017,6 +1017,12 @@ export class SettingsManager {
displayDensitySelect.value = state.global.settings.display_density || 'default';
}
// Set recipes layout setting
const recipesLayoutSelect = document.getElementById('recipesLayout');
if (recipesLayoutSelect) {
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
}
// Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
if (cardInfoDisplaySelect) {
@@ -2288,6 +2294,13 @@ export class SettingsManager {
// Apply frontend settings immediately
this.applyFrontendSettings();
// Dispatch layout change event; the scroller instance is about to be rebuilt,
// so calculateLayout() must NOT run on the old instance here
if (settingKey === 'recipes_layout') {
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
return;
}
// Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
+15 -1
View File
@@ -7,7 +7,7 @@ import { state, getCurrentPageState } from './state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js';
@@ -272,6 +272,20 @@ class RecipeManager {
});
}
// Rebuild the scroller on layout switch; in duplicates mode defer until
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
// the old instance while initializeVirtualScroll skips duplicates mode)
window.addEventListener('lm:recipes-layout-changed', () => {
const pageState = getCurrentPageState();
if (pageState.duplicatesMode) {
state.pendingLayoutRecreate = true;
return;
}
if (typeof recreateVirtualScroll === 'function') {
recreateVirtualScroll('recipes');
}
});
// Initialize dropdown functionality for refresh button
this.initDropdowns();
}
+1
View File
@@ -38,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
card_blur_amount: 8,
autoplay_on_hover: false,
display_density: 'default',
recipes_layout: 'grid',
card_info_display: 'always',
model_name_display: 'model_name',
lora_syntax_format: 'legacy',
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -483,10 +483,18 @@ export class VirtualScroller {
element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`;
// Remove max-width constraint from model-card to allow dynamic sizing
const modelCard = element.querySelector('.model-card');
// Remove max-width/min-width constraints from the model-card to allow
// dynamic sizing. The card is either the item element itself (e.g.
// RecipeCard returns the .model-card root) or a descendant (ModelCard).
// Without this, the CSS min-width of 200px forces compact-density cards
// wider than their allocated column, and the last column gets clipped
// by the grid's overflow-x: hidden.
const modelCard = element.classList.contains('model-card')
? element
: element.querySelector('.model-card');
if (modelCard) {
modelCard.style.maxWidth = 'none';
modelCard.style.minWidth = '0';
}
return element;
+24 -1
View File
@@ -1,5 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js';
import { VirtualScroller } from './VirtualScroller.js';
import { MasonryScroller } from './MasonryScroller.js';
import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { showToast } from './uiHelpers.js';
@@ -141,8 +142,14 @@ async function initializeVirtualScroll(pageType) {
throw new Error(`Required components not available for ${pageType} page`);
}
// Masonry applies to the recipes page only; the read pattern mirrors
// VirtualScroller.js (display_density).
const useMasonry = pageType === 'recipes'
&& (state.global.settings?.recipes_layout ?? 'grid') === 'masonry';
const ScrollerClass = useMasonry ? MasonryScroller : VirtualScroller;
// Initialize virtual scroller with renamed container elements
state.virtualScroller = new VirtualScroller({
state.virtualScroller = new ScrollerClass({
gridElement: grid,
containerElement: gridContainer,
scrollContainer: scrollContainer,
@@ -250,3 +257,19 @@ export async function refreshVirtualScroll(options = {}) {
}
}
}
// Rebuild the virtual scroller from scratch (used for layout switching).
// refreshVirtualScroll only resets the existing instance, so it cannot swap
// the scroller class. Keyboard navigation must be cleaned up first:
// setupKeyboardNavigation appends a new document keydown listener on every
// initializeVirtualScroll call and never removes the previous one.
export async function recreateVirtualScroll(pageType) {
cleanupKeyboardNavigation();
if (state.virtualScroller) {
state.virtualScroller.dispose();
state.virtualScroller = null;
}
await initializeVirtualScroll(pageType);
}
+2
View File
@@ -356,6 +356,8 @@ export function updatePanelPositions() {
if (filterPanel) {
filterPanel.style.top = `${topPosition}px`;
// Clamp panel height to the viewport below the header
filterPanel.style.maxHeight = `calc(100vh - ${topPosition + 10}px)`;
}
// Adjust panel horizontal position based on the search container
+6 -1
View File
@@ -32,7 +32,12 @@
<div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
</div>
</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Attributes -->
+24 -4
View File
@@ -44,8 +44,18 @@
<div class="context-menu-item" data-action="preview">
<i class="fas fa-folder-open"></i> <span>{{ t('loras.contextMenu.openExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-examples">
<i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadExamples') }}</span>
<div class="context-menu-item has-submenu" data-has-submenu="download-examples">
<i class="fas fa-download"></i>
<span>{{ t('loras.contextMenu.downloadExamples') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples">
<i class="fas fa-download"></i> <span>{{ t('loras.contextMenu.downloadMissingExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-examples-force">
<i class="fas fa-redo-alt"></i> <span>{{ t('loras.contextMenu.reprocessExamples') }}</span>
</div>
</div>
</div>
<div class="context-menu-item" data-action="replace-preview">
<i class="fas fa-image"></i> <span>{{ t('loras.contextMenu.replacePreview') }}</span>
@@ -136,8 +146,18 @@
</div>
<div class="context-menu-section" data-section="download">
<div class="context-menu-section-header">{{ t('loras.bulkOperations.sections.download') }}</div>
<div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
<div class="context-menu-item has-submenu" data-has-submenu="download-example-images">
<i class="fas fa-download"></i>
<span>{{ t('loras.bulkOperations.downloadExamples') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-missing-example-images">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingExamples') }}</span>
</div>
<div class="context-menu-item" data-action="download-example-images">
<i class="fas fa-redo-alt"></i> <span>{{ t('loras.bulkOperations.reprocessExamples') }}</span>
</div>
</div>
</div>
<div class="context-menu-item" data-action="download-missing-loras">
<i class="fas fa-download"></i> <span>{{ t('loras.bulkOperations.downloadMissingLoras') }}</span>
@@ -202,6 +202,7 @@
<option value="deepseek">{{ t('settings.aiProvider.providerOptions.deepseek') }}</option>
<option value="groq">{{ t('settings.aiProvider.providerOptions.groq') }}</option>
<option value="openrouter">{{ t('settings.aiProvider.providerOptions.openrouter') }}</option>
<option value="google">{{ t('settings.aiProvider.providerOptions.google') }}</option>
<option value="opencode-go">{{ t('settings.aiProvider.providerOptions.opencode-go') }}</option>
<option value="custom">{{ t('settings.aiProvider.providerOptions.custom') }}</option>
</select>
@@ -624,7 +625,24 @@
<span class="warning-text">{{ t('settings.layoutSettings.displayDensityWarning') }}</span>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="recipesLayout">
{{ t('settings.layoutSettings.recipesLayout') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
+6 -1
View File
@@ -32,7 +32,12 @@
<div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
</div>
</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Attributes -->
@@ -38,6 +38,7 @@ vi.mock('../../../static/js/state/index.js', () => {
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: captureScrollPositionMock,
restoreScrollPosition: restoreScrollPositionMock,
recreateVirtualScroll: vi.fn(),
}));
import {
@@ -1666,4 +1666,374 @@ describe('AutoComplete widget interactions', () => {
// Entire phrase should be replaced with selected tag
expect(input.value).toBe('looking_to_the_side,');
});
it('shows /af command for loras when active-filters autocomplete is off (default)', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(commandNames).not.toContain('/noaf');
expect(commandNames).toContain('/activefilters');
expect(commandNames).not.toContain('/noactivefilters');
});
it('does not trigger preview for command items when selecting the loras command list', async () => {
// Regression: with showPreview enabled (the default for loras widgets), the
// auto-selected first command item was passed to showPreviewForItem() as a
// relative path, crashing on relativePath.split.
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: true, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
// Allow the async preview tooltip import to resolve
await Promise.resolve();
await Promise.resolve();
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(previewTooltipMock.show).not.toHaveBeenCalled();
});
it('shows /noaf command for loras when active-filters autocomplete is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/noaf');
expect(commandNames).not.toContain('/af');
expect(commandNames).toContain('/noactivefilters');
expect(commandNames).not.toContain('/activefilters');
});
it('toggles the active-filters setting when /activefilters alias is used', async () => {
const input = document.createElement('textarea');
input.value = '/activefilters';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
const commandResult = autoComplete._parseCommandInput('/activefilters');
expect(commandResult.command).toBeDefined();
expect(commandResult.command.type).toBe('toggle_setting');
expect(commandResult.command.value).toBe(true);
await autoComplete._handleToggleSettingCommand(commandResult.command);
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('toggles the active-filters setting when /af is accepted', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
const afItem = autoComplete.items.find((item) => item.command === '/af');
expect(afItem).toBeDefined();
// Simulate the input being cleared after the command is accepted so the
// cleared-token input event does not re-trigger command parsing.
caretHelperInstance.getBeforeCursor.mockReturnValue('');
await autoComplete._handleToggleSettingCommand(afItem);
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('appends active filter params to loras autocomplete requests when enabled', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
autoTags: { I2V: 'include' },
modelTypes: ['standard'],
tagLogic: 'all',
license: { noCredit: 'include', allowSelling: 'exclude' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
localStorage.setItem('lora_manager_loras_recursiveSearch', 'true');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
expect(calledUrl).toContain('folder=MyLoras');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('tag_include=anime');
expect(calledUrl).toContain('tag_exclude=nsfw');
expect(calledUrl).toContain('tag_exclude=__no_tags__');
expect(calledUrl).toContain('auto_tag_include=I2V');
expect(calledUrl).toContain('tag_logic=all');
expect(calledUrl).toContain('credit_required=false');
expect(calledUrl).toContain('allow_selling_generated_content=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
expect(parsed.searchParams.get('model_type')).toBe('standard');
});
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
});
it('sends the filter-pipeline signal even when no filters are stored', async () => {
// Regression: with filter mode on but no folder/filters stored, the request
// carried no params, so the backend skipped the filter pipeline and global
// settings like show_only_sfw diverged from the list endpoint.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.removeItem('lora_manager_loras_filters');
localStorage.removeItem('lora_manager_loras_activeFolder');
localStorage.removeItem('lora_manager_loras_recursiveSearch');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('recursive=true');
});
it('omits folder param when active folder is root and recursion is enabled', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', '');
localStorage.removeItem('lora_manager_loras_recursiveSearch');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('recursive=true');
});
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', '');
localStorage.setItem('lora_manager_loras_recursiveSearch', 'false');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=');
expect(calledUrl).toContain('recursive=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('folder')).toBe('');
});
it('applies the active folder even when no filter-panel filters are set', async () => {
// Regression: folder was skipped when lora_manager_loras_filters was
// missing because the filters key gate returned early.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
localStorage.removeItem('lora_manager_loras_filters');
localStorage.setItem('lora_manager_loras_activeFolder', 'Flux.1 D/style');
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: ['Flux.1 D/style/3D_Fairytales.safetensors'] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('3D');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
new AutoComplete(input, 'loras', { debounceDelay: 0, showPreview: false, minChars: 1 });
input.value = '3D';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
expect(calledUrl).toContain('recursive=true');
});
});
@@ -2155,4 +2155,35 @@ describe('Interaction-level regression coverage', () => {
excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
});
it('routes single-model example downloads to missing-only and force paths', async () => {
document.body.innerHTML = `
<div id="loraContextMenu" class="context-menu">
<div class="context-menu-item has-submenu" data-has-submenu="download-examples">
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"></div>
<div class="context-menu-item" data-action="download-examples-force"></div>
</div>
</div>
</div>
`;
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
const contextMenu = new LoraContextMenu();
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/test.safetensors';
card.dataset.sha256 = 'abc123hash';
document.body.appendChild(card);
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="download-examples"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: false });
contextMenu.showMenu(100, 100, card);
document.querySelector('[data-action="download-examples-force"]').dispatchEvent(new Event('click', { bubbles: true }));
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
});
});
@@ -0,0 +1,438 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
SUMMARY_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
} = vi.hoisted(() => ({
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
}));
const showToastMock = vi.hoisted(() => vi.fn());
const openHuggingFaceMock = vi.hoisted(() => vi.fn());
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_key, _params, fallback) => fallback ?? ''),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
openHuggingFace: openHuggingFaceMock,
}));
// A realistic failure payload from the backend: a JSON envelope whose `error`
// field embeds an HTTP status and a nested JSON body (Civitai Early Access).
const REAL_ERROR = '{"success": false, "error": "Failed to resolve authenticated Civitai redirect: status=403 body={\\"error\\":\\"Early Access\\",\\"deadline\\":\\"2026-08-12T08:18:36.063Z\\",\\"message\\":\\"This asset is in Early Access. You can use Buzz access it now!\\"}", "download_id": "1786065633067"}';
// The human-readable error the component should derive from REAL_ERROR.
const FORMATTED_REAL_ERROR = 'HTTP 403 — This asset is in Early Access. You can use Buzz access it now!';
describe('DownloadBatchSummaryModal', () => {
let showDownloadBatchSummary;
beforeEach(async () => {
document.body.innerHTML = '';
showToastMock.mockClear();
openHuggingFaceMock.mockClear();
({ showDownloadBatchSummary } = await import(SUMMARY_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
delete navigator.clipboard;
delete document.execCommand;
vi.restoreAllMocks();
vi.useRealTimers();
});
it('renders a warning summary with stat cards and a failure table on partial success', () => {
showDownloadBatchSummary({
total: 3,
completed: 2,
failedItems: [
{ item: { displayName: 'LoraA' }, error: 'timeout' },
{ item: { name: 'LoraB' }, error: '404' },
],
onRetry: vi.fn(),
});
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal).not.toBeNull();
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
// Success / Failed / Total stat cards.
const statValues = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
expect(statValues).toEqual(['2', '2', '3']);
const rows = modal.querySelectorAll('.failure-table tbody tr');
expect(rows).toHaveLength(2);
expect(rows[0].querySelector('.failure-name').textContent).toBe('LoraA');
expect(rows[0].querySelector('.failure-error').textContent).toBe('timeout');
expect(rows[1].querySelector('.failure-name').textContent).toBe('LoraB');
expect(rows[1].querySelector('.failure-error').textContent).toBe('404');
expect(modal.querySelector('[data-action="retry-failed"]').textContent).toContain('Retry Failed (2)');
expect(modal.querySelector('[data-action="copy-report"]')).not.toBeNull();
});
it('renders an error header when every download failed', () => {
showDownloadBatchSummary({
total: 2,
completed: 0,
failedItems: [
{ item: { displayName: 'LoraA' }, error: 'timeout' },
{ item: { displayName: 'LoraB' }, error: '404' },
],
onRetry: vi.fn(),
});
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
expect(modal.querySelector('.summary-title').textContent).toBe('Download failed');
});
it('renders a success summary without a failure table or retry button', () => {
showDownloadBatchSummary({ total: 2, completed: 2, failedItems: [], onRetry: vi.fn() });
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
expect(modal.querySelector('.failure-table')).toBeNull();
expect(modal.querySelector('[data-action="retry-failed"]')).toBeNull();
expect(modal.querySelector('.refresh-success-message')).not.toBeNull();
});
it('escapes HTML in failed item names and errors', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [
{ item: { name: '<img src=x onerror=alert(1)>', url: 'https://example.com/xss-model' }, error: '<script>bad()</script>' },
],
onRetry: vi.fn(),
});
const nameCell = document.querySelector('.failure-name');
const errorCell = document.querySelector('.failure-error');
// The URL resolves, so the name renders inside the failure link; the
// escaped entities must render back to the literal payload as text...
expect(nameCell.querySelector('a.failure-link')).not.toBeNull();
expect(nameCell.textContent).toContain('<img src=x onerror=alert(1)>');
expect(errorCell.textContent).toContain('<script>bad()</script>');
// ...and never as live DOM nodes.
expect(document.querySelector('.failure-table img')).toBeNull();
expect(document.querySelector('.failure-table script')).toBeNull();
expect(nameCell.innerHTML).toContain('&lt;img');
});
it('removes the modal and invokes onRetry with the original failed items', () => {
const onRetry = vi.fn();
const failedItems = [{ item: { displayName: 'LoraA' }, error: 'timeout' }];
showDownloadBatchSummary({ total: 3, completed: 2, failedItems, onRetry });
document.querySelector('[data-action="retry-failed"]').click();
expect(document.getElementById('downloadBatchSummaryModal')).toBeNull();
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(failedItems);
// Same object references, not copies.
expect(onRetry.mock.calls[0][0][0]).toBe(failedItems[0]);
});
it('closes the modal via the close action without retrying', () => {
const onRetry = vi.fn();
showDownloadBatchSummary({
total: 2,
completed: 1,
failedItems: [{ item: { name: 'LoraA' }, error: 'timeout' }],
onRetry,
});
document.querySelector('.cancel-btn[data-action="close-modal"]').click();
expect(document.getElementById('downloadBatchSummaryModal')).toBeNull();
expect(onRetry).not.toHaveBeenCalled();
});
it('copies a plain-text batch report to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
showDownloadBatchSummary({
total: 3,
completed: 2,
failedItems: [
{ item: { displayName: 'LoraA', url: 'https://civitai.red/models/111/lora-a?modelVersionId=222' }, error: 'timeout' },
{ item: { name: 'LoraB', url: 'https://example.com/lora-b' }, error: '404' },
],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
// writeText is invoked synchronously by the click handler.
expect(writeText).toHaveBeenCalledTimes(1);
const text = writeText.mock.calls[0][0];
expect(text).toContain('Batch Download Report');
expect(text).toContain('Total: 3');
expect(text).toContain('LoraA — timeout');
expect(text).toContain('LoraB — 404');
// Each failed item with a URL gets an indented URL line right after it.
expect(text).toContain(' URL: https://civitai.red/models/111/lora-a?modelVersionId=222');
expect(text).toContain(' URL: https://example.com/lora-b');
// Exactly the two URLs from the failed items — nothing more, no undefined.
expect(text.match(/^\s+URL:/gm)).toHaveLength(2);
expect(text).not.toContain('URL: undefined');
// The toast fires after the mocked clipboard promise settles.
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success');
});
it('omits the URL line for failed items without a resolvable url', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
showDownloadBatchSummary({
total: 2,
completed: 0,
failedItems: [
{ item: { name: 'WithUrl', url: 'https://example.com/with-url' }, error: 'boom' },
{ item: { name: 'NoUrl' }, error: 'boom' },
],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
const text = writeText.mock.calls[0][0];
expect(text).toContain(' URL: https://example.com/with-url');
// Only the one URL line exists — the URL-less item contributes none.
expect(text.match(/^\s+URL:/gm)).toHaveLength(1);
expect(text).not.toContain(' URL: undefined');
expect(text).not.toContain(' URL: null');
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
});
it('falls back to execCommand when navigator.clipboard is unavailable', async () => {
// afterEach deletes navigator.clipboard, but be explicit so this test is
// robust even if a previous test failed before its cleanup ran.
delete navigator.clipboard;
// jsdom does not implement document.execCommand, so install a mock for the
// fallback path (removed by the afterEach cleanup above).
const execCommandMock = vi.fn(() => true);
document.execCommand = execCommandMock;
showDownloadBatchSummary({
total: 3,
completed: 2,
failedItems: [
{ item: { displayName: 'LoraA' }, error: 'timeout' },
{ item: { name: 'LoraB' }, error: '404' },
],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
// Without the async Clipboard API the fallback must run synchronously.
expect(execCommandMock).toHaveBeenCalledWith('copy');
await Promise.resolve();
await Promise.resolve();
expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success');
});
it('keeps only a single modal instance across repeated calls', () => {
showDownloadBatchSummary({
total: 2,
completed: 1,
failedItems: [{ item: { name: 'A' }, error: 'e' }],
onRetry: vi.fn(),
});
showDownloadBatchSummary({ total: 3, completed: 3, failedItems: [], onRetry: vi.fn() });
expect(document.querySelectorAll('#downloadBatchSummaryModal')).toHaveLength(1);
const modal = document.getElementById('downloadBatchSummaryModal');
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
});
it('resolves failure names from entry.name, item fields, URL paths, or Unknown', () => {
showDownloadBatchSummary({
total: 4,
completed: 0,
failedItems: [
{ name: 'entryName', item: { displayName: 'ItemName' }, error: 'e1' },
{ item: { selectedVersion: { name: 'v1.0' } }, error: 'e2' },
{ item: { url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960' }, error: 'e3' },
{ item: {}, error: 'e4' },
],
onRetry: vi.fn(),
});
const names = Array.from(document.querySelectorAll('.failure-name')).map(el => el.textContent);
expect(names).toEqual(['entryName', 'v1.0', 'midjourney-artful-nsfw', 'Unknown']);
});
it('formats the real JSON failure payload into a concise HTTP error and truncates long ones', () => {
showDownloadBatchSummary({
total: 2,
completed: 0,
failedItems: [
{ item: { name: 'EarlyAccess' }, error: REAL_ERROR },
{ item: { name: 'LongError' }, error: 'x'.repeat(300) },
],
onRetry: vi.fn(),
});
const errorCells = document.querySelectorAll('.failure-error');
expect(errorCells[0].textContent).toBe(FORMATTED_REAL_ERROR);
expect(errorCells[1].textContent).toBe('x'.repeat(220) + '…');
});
it('keeps the raw error string in the error cell title for debugging', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{ item: { name: 'EarlyAccess' }, error: REAL_ERROR }],
onRetry: vi.fn(),
});
const errorCell = document.querySelector('.failure-error');
expect(errorCell.getAttribute('title')).toBe(REAL_ERROR);
expect(errorCell.getAttribute('title')).not.toBe(FORMATTED_REAL_ERROR);
});
it('opens the original item url in a new tab when a failure link is clicked', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
url: 'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960',
modelId: '837884',
selectedVersion: { id: '3153960' },
},
error: 'rate limited',
}],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=3153960');
// The modal stays open so the user can keep inspecting the failures.
expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
});
it('opens the item url directly when selectedVersion is absent', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
modelId: '837884',
modelVersionId: '3153960',
url: 'https://civitai.red/models/837884/midjourney-artful-nsfw',
},
error: 'rate limited',
}],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://civitai.red/models/837884/midjourney-artful-nsfw');
expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
});
it('opens the original huggingface url directly when a huggingface failure link is clicked', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
url: 'https://huggingface.co/user/repo',
source: 'huggingface',
repo: 'user/repo',
filename: 'model.safetensors',
revision: 'main',
},
error: 'download failed',
}],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://huggingface.co/user/repo');
expect(document.getElementById('downloadBatchSummaryModal')).not.toBeNull();
});
it('opens an arbitrary URL via openHuggingFace for fallback items', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{ item: { url: 'https://example.com/model' }, error: 'boom' }],
onRetry: vi.fn(),
});
document.querySelector('.failure-link').click();
expect(openHuggingFaceMock).toHaveBeenCalledTimes(1);
expect(openHuggingFaceMock).toHaveBeenCalledWith('https://example.com/model');
});
it('renders the failure name as plain text when no URL can be resolved', () => {
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{ item: { modelId: null }, error: 'boom' }],
onRetry: vi.fn(),
});
expect(document.querySelector('a.failure-link')).toBeNull();
expect(document.querySelector('.failure-name').textContent).toBe('Unknown');
// Without a link there is nothing to open: clicking the cell is inert.
document.querySelector('.failure-name').click();
expect(openHuggingFaceMock).not.toHaveBeenCalled();
});
it('copies formatted errors (not raw JSON) into the report text', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
showDownloadBatchSummary({
total: 1,
completed: 0,
failedItems: [{
item: {
name: 'EarlyAccess',
url: 'https://civitai.red/models/123/early-access?modelVersionId=456',
},
error: REAL_ERROR,
}],
onRetry: vi.fn(),
});
document.querySelector('[data-action="copy-report"]').click();
expect(writeText).toHaveBeenCalledTimes(1);
const text = writeText.mock.calls[0][0];
expect(text).toContain(FORMATTED_REAL_ERROR);
expect(text).toContain(' URL: https://civitai.red/models/123/early-access?modelVersionId=456');
expect(text).not.toContain('download_id');
expect(text).not.toContain('Failed to resolve authenticated Civitai redirect');
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledTimes(1));
});
});
@@ -0,0 +1,87 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const recreateVirtualScrollMock = vi.fn();
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: class {},
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock,
}));
const { DuplicatesManager } = await import('../../../static/js/components/DuplicatesManager.js');
const { state, getCurrentPageState, setCurrentPageType } = await import('../../../static/js/state/index.js');
function setupDom() {
document.body.innerHTML = `
<div id="duplicatesBanner" style="display: block;"></div>
<div id="recipeGrid"><div class="model-card">stale</div></div>
`;
document.body.classList.add('duplicate-mode');
}
describe('DuplicatesManager exitDuplicateMode', () => {
beforeEach(() => {
vi.clearAllMocks();
setCurrentPageType('recipes');
setupDom();
state.pendingLayoutRecreate = false;
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
});
afterEach(() => {
state.pendingLayoutRecreate = false;
state.virtualScroller = null;
});
it('skips enable() on the old scroller when a layout recreate was deferred', async () => {
state.pendingLayoutRecreate = true;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(state.virtualScroller.enable).not.toHaveBeenCalled();
expect(recreateVirtualScrollMock).toHaveBeenCalledWith('recipes');
expect(state.pendingLayoutRecreate).toBe(false);
});
it('re-enables the existing scroller when no layout recreate is pending', async () => {
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(state.virtualScroller.enable).toHaveBeenCalledTimes(1);
expect(recreateVirtualScrollMock).not.toHaveBeenCalled();
});
it('tolerates a missing scroller on the plain re-enable path', async () => {
state.virtualScroller = null;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await expect(manager.exitDuplicateMode()).resolves.toBeUndefined();
expect(recreateVirtualScrollMock).not.toHaveBeenCalled();
});
it('clears duplicates-mode state and the grid regardless of path', async () => {
state.pendingLayoutRecreate = true;
const manager = new DuplicatesManager({});
manager.inDuplicateMode = true;
await manager.exitDuplicateMode();
expect(manager.inDuplicateMode).toBe(false);
expect(getCurrentPageState().duplicatesMode).toBe(false);
expect(document.body.classList.contains('duplicate-mode')).toBe(false);
expect(document.getElementById('recipeGrid').innerHTML).toBe('');
expect(document.getElementById('duplicatesBanner').style.display).toBe('none');
});
});
+1
View File
@@ -92,6 +92,7 @@ vi.mock('../../../static/js/utils/eventManagementInit.js', () => ({
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
initializeInfiniteScroll: vi.fn(),
recreateVirtualScroll: vi.fn(),
}));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
@@ -0,0 +1,434 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
showToastMock,
showDownloadBatchSummaryMock,
resetAndReloadMock,
} = vi.hoisted(() => {
// Shared API client returned by the mocked getModelApiClient factory.
const mockApiClient = {
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
downloadModel: vi.fn(),
downloadHfModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
// Shared loading manager served both via state.loadingManager and the
// LoadingManager constructor mock.
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
setStatus: vi.fn(),
showCancelButton: vi.fn(),
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
showToastMock: vi.fn(),
showDownloadBatchSummaryMock: vi.fn(),
resetAndReloadMock: vi.fn(),
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
loadingManager: mockLoadingManager,
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: resetAndReloadMock,
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));
/**
* Fake WebSocket used by executeBatchDownload. Resolves `onopen` on the
* microtask queue right after construction (which happens after the real
* code has assigned `onopen`), so the open promise resolves deterministically
* without real timers.
*/
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
queueMicrotask(() => {
if (this.onopen) this.onopen();
});
}
static get lastInstance() {
return FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
}
}
describe('DownloadManager batch download summary flow', () => {
let DownloadManager;
let manager;
const options = { modelRoot: '/models/loras', targetFolder: '', useDefaultPaths: true };
const makeItem = (modelId, versionId, name) => ({
modelId,
displayName: name,
selectedVersion: { id: versionId, name, existsLocally: false },
});
const item0 = makeItem('111', 'v1', 'Model A');
const item1 = makeItem('222', 'v2', 'Model B');
beforeEach(async () => {
document.body.innerHTML = '';
FakeWebSocket.instances = [];
// Reset the shared mocks so mockResolvedValueOnce queues and call
// history never leak between tests.
mockApiClient.downloadModel.mockReset();
mockApiClient.downloadHfModel.mockReset();
mockApiClient.cancelDownload.mockReset();
showToastMock.mockClear();
showDownloadBatchSummaryMock.mockClear();
resetAndReloadMock.mockClear();
mockLoadingManager.hide.mockClear();
mockLoadingManager.setStatus.mockClear();
mockLoadingManager.showCancelButton.mockClear();
mockLoadingManager.showDownloadProgress.mockClear();
vi.stubGlobal('WebSocket', FakeWebSocket);
vi.resetModules();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
manager = new DownloadManager();
// The constructor leaves apiClient null; executeBatchDownload reads it
// directly, so point it at the shared mocked client.
manager.apiClient = mockApiClient;
});
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('shows the success toast when every item downloads successfully', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: true });
await manager.executeBatchDownload([item0, item1], options);
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
// Each item is downloaded with its own modelId + versionId.
expect(mockApiClient.downloadModel.mock.calls[0][0]).toBe('111');
expect(mockApiClient.downloadModel.mock.calls[0][1]).toBe('v1');
expect(mockApiClient.downloadModel.mock.calls[1][0]).toBe('222');
expect(mockApiClient.downloadModel.mock.calls[1][1]).toBe('v2');
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', { count: 2 }, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('shows a partial-failure summary when some items fail', async () => {
// The failing item has no displayName/filename, so the resolved entry
// name falls back to the selected version name.
const unnamedItem = { modelId: '333', selectedVersion: { id: 'v3', name: 'V3', existsLocally: false } };
mockApiClient.downloadModel
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
.mockResolvedValueOnce({ success: true });
await manager.executeBatchDownload([unnamedItem, item1], options);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(2);
expect(summary.completed).toBe(1);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].item).toBe(unnamedItem);
expect(summary.failedItems[0].error).toBe('rate limited');
// The resolved display name is carried on the failed entry.
expect(summary.failedItems[0].name).toBe('V3');
expect(summary.onRetry).toEqual(expect.any(Function));
// No success toast and no downloadPartialSuccess toast for this path.
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), 'success');
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadPartialSuccess', expect.anything(), expect.anything());
});
it('shows an all-failed summary when every item fails', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'x' });
await manager.executeBatchDownload([item0, item1], options);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(2);
expect(summary.completed).toBe(0);
expect(summary.failedItems).toHaveLength(2);
expect(summary.failedItems[0].item).toBe(item0);
expect(summary.failedItems[1].item).toBe(item1);
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.allDownloadSuccessful', expect.anything(), expect.anything());
});
it('records the error message when downloadModel rejects', async () => {
// The item carries a filename but no displayName, so the resolved entry
// name comes from the filename.
const filenameItem = { modelId: '444', filename: 'model.safetensors', selectedVersion: { id: 'v4' } };
mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));
await manager.executeBatchDownload([filenameItem], options);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(1);
expect(summary.completed).toBe(0);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].item).toBe(filenameItem);
expect(summary.failedItems[0].error).toBe('network down');
expect(summary.failedItems[0].name).toBe('model.safetensors');
});
it('retries the failed subset through onRetry with unwrapped items', async () => {
mockApiClient.downloadModel
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
.mockResolvedValueOnce({ success: true });
await manager.executeBatchDownload([item0, item1], options);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.failedItems).toHaveLength(1);
// Retry the exact failed subset returned by the summary. The onRetry
// callback unwraps the { item, error } entries back into raw model items
// before re-running executeBatchDownload. Make the retried item fail
// again so a second summary is produced.
mockApiClient.downloadModel.mockResolvedValueOnce({ success: false, error: 'still rate limited' });
await summary.onRetry(summary.failedItems);
// downloadModel is called a third time — only for the failed item (item0),
// NOT for the item that already succeeded (item1).
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(3);
const retryCall = mockApiClient.downloadModel.mock.calls[2];
expect(retryCall[0]).toBe(item0.modelId);
expect(retryCall[1]).toBe(item0.selectedVersion.id);
// A fresh summary is produced for the retry run (call count 1 -> 2).
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(2);
const retrySummary = showDownloadBatchSummaryMock.mock.calls[1][0];
expect(retrySummary.total).toBe(1);
expect(retrySummary.completed).toBe(0);
expect(retrySummary.failedItems).toHaveLength(1);
expect(retrySummary.failedItems[0].item).toBe(item0);
expect(retrySummary.failedItems[0].error).toBe('still rate limited');
});
it('stops the batch without showing a summary when cancelled before downloads start', async () => {
const downloadPromise = manager.executeBatchDownload([item0, item1], options);
// showCancelButton captured the cancel callback synchronously. Invoking it
// sets `cancelled = true` before the download loop runs (the loop only
// starts after the WebSocket open promise resolves on the microtask queue).
const cancelCallback = mockLoadingManager.showCancelButton.mock.calls[0][0];
const cancelPromise = cancelCallback();
await Promise.all([downloadPromise, cancelPromise]);
expect(mockApiClient.downloadModel).not.toHaveBeenCalled();
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.downloads.downloadStopped',
expect.anything(),
'info',
expect.stringContaining('Download cancelled')
);
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('shows the batch summary for a single CivitAI download resolved as a failure', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'rate limited' });
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(1);
expect(summary.completed).toBe(0);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].item.modelId).toBe('111');
expect(summary.failedItems[0].item.versionId).toBe('v1');
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
expect(summary.failedItems[0].error).toBe('rate limited');
expect(summary.failedItems[0].name).toBe('V1');
expect(summary.onRetry).toEqual(expect.any(Function));
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
});
it('shows the batch summary when a single download throws', async () => {
mockApiClient.downloadModel.mockRejectedValue(new Error('network down'));
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(1);
expect(summary.completed).toBe(0);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].error).toBe('network down');
expect(summary.failedItems[0].item.url).toEqual(expect.stringContaining('civitai.com/models/111'));
expect(showToastMock).not.toHaveBeenCalledWith('toast.loras.downloadCompleted', expect.anything(), 'success');
});
it('keeps the success toast and skips the summary for a successful single download', async () => {
mockApiClient.downloadModel.mockResolvedValue({ success: true });
const result = await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(result).toBe(true);
expect(showDownloadBatchSummaryMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
});
it('retries a failed single download through onRetry with the same params', async () => {
mockApiClient.downloadModel
.mockResolvedValueOnce({ success: false, error: 'rate limited' })
.mockResolvedValueOnce({ success: true });
await manager.executeDownloadWithProgress({
modelId: '111',
versionId: 'v1',
versionName: 'V1',
modelRoot: '/m',
useDefaultPaths: true,
});
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.failedItems).toHaveLength(1);
await summary.onRetry();
expect(mockApiClient.downloadModel).toHaveBeenCalledTimes(2);
const retryCall = mockApiClient.downloadModel.mock.calls[1];
expect(retryCall[0]).toBe('111');
expect(retryCall[1]).toBe('v1');
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
expect(showToastMock).toHaveBeenCalledWith('toast.loras.downloadCompleted', {}, 'success');
});
it('shows a summary for HF partial failure and retries only the failed files', async () => {
manager.hfRepoId = 'user/repo';
manager.hfSelectedFiles = ['a.safetensors', 'b.safetensors'];
mockApiClient.downloadHfModel
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'denied' });
const result = await manager._downloadHfSingle({ modelRoot: '/m', useDefaultPaths: true });
expect(result).toBe(false);
expect(showDownloadBatchSummaryMock).toHaveBeenCalledTimes(1);
const summary = showDownloadBatchSummaryMock.mock.calls[0][0];
expect(summary.total).toBe(2);
expect(summary.completed).toBe(1);
expect(summary.failedItems).toHaveLength(1);
expect(summary.failedItems[0].name).toBe('b.safetensors');
expect(summary.failedItems[0].item.url).toEqual(
expect.stringContaining('huggingface.co/user/repo/blob/main/b.safetensors')
);
await summary.onRetry();
expect(mockApiClient.downloadHfModel).toHaveBeenCalledTimes(3);
expect(mockApiClient.downloadHfModel.mock.calls[2][0].filename).toBe('b.safetensors');
});
});
@@ -0,0 +1,264 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
showToastMock,
showDownloadBatchSummaryMock,
resetAndReloadMock,
} = vi.hoisted(() => {
// Shared API client returned by the mocked getModelApiClient factory.
const mockApiClient = {
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
fetchCivitaiVersions: vi.fn(),
downloadModel: vi.fn(),
downloadHfModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
// Shared loading manager served both via state.loadingManager and the
// LoadingManager constructor mock.
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
setStatus: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
showCancelButton: vi.fn(),
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
showToastMock: vi.fn(),
showDownloadBatchSummaryMock: vi.fn(),
resetAndReloadMock: vi.fn(),
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
loadingManager: mockLoadingManager,
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: resetAndReloadMock,
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));
/** Minimal DOM used by validateAndFetchVersions / showVersionStep / batch preview. */
function setupDownloadDom() {
document.body.innerHTML = `
<div id="downloadModal">
<div class="download-step" id="urlStep"></div>
<div class="download-step" id="versionStep"></div>
<div class="download-step" id="fileSelectionStep"></div>
<div class="download-step" id="locationStep"></div>
<div id="batchPreviewStep"></div>
<textarea id="modelUrl"></textarea>
<div id="urlError"></div>
<div id="versionList"></div>
<div id="fileSelectionList"></div>
<div id="fileSelectionVersionName"></div>
<button id="nextFromVersion"></button>
<button id="nextFromBatchBtn"></button>
<div id="downloadModalTitle"></div>
<div id="batchPreviewList"></div>
<div id="modelRoot"></div>
<div id="folderPath"></div>
<div id="targetPathDisplay"></div>
<input id="useDefaultPath" />
<div id="manualPathSelection"></div>
</div>
`;
}
function makeVersion(id, name) {
return {
id,
name,
baseModel: 'SDXL',
createdAt: '2025-01-01T00:00:00Z',
availability: 'Public',
images: [{ url: 'https://image.civitai.com/preview.jpg' }],
files: [{ id: 1, type: 'Model', name: 'model.safetensors', sizeKB: 1000 }],
modelSizeKB: 1000,
existsLocally: false,
hasBeenDownloaded: false,
};
}
// Newest-first, matching the Civitai API response order. The default selection
// takes the first version, so the newest (largest id) must come first.
const versions = [makeVersion(250, 'v3'), makeVersion(100, 'v1'), makeVersion(30, 'v0')];
describe('DownloadManager latest-version default', () => {
let DownloadManager;
let manager;
beforeEach(async () => {
document.body.innerHTML = '';
setupDownloadDom();
mockApiClient.fetchCivitaiVersions.mockReset();
mockLoadingManager.showSimpleLoading.mockClear();
mockLoadingManager.hide.mockClear();
vi.resetModules();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
manager = new DownloadManager();
manager.apiClient = mockApiClient;
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('single URL without modelVersionId', () => {
it('auto-selects the latest version so no manual version click is needed', async () => {
document.getElementById('modelUrl').value = 'https://civitai.red/models/837884/midjourney-artful-nsfw';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.validateAndFetchVersions();
expect(manager.modelId).toBe('837884');
expect(manager.currentVersion.id).toBe(250);
// The version step is shown with the latest version pre-selected.
expect(document.getElementById('versionStep').style.display).toBe('block');
const selected = document.querySelector('.version-item.selected');
expect(selected.dataset.versionId).toBe('250');
expect(document.getElementById('nextFromVersion').disabled).toBe(false);
});
it('still honours an explicit modelVersionId from the URL', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/837884/midjourney-artful-nsfw?modelVersionId=30';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.validateAndFetchVersions();
expect(manager.currentVersion.id).toBe(30);
});
});
describe('fetchVersionsForCurrentModel without modelVersionId', () => {
it('auto-selects the latest version', async () => {
manager.modelId = '837884';
manager.modelVersionId = null;
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.fetchVersionsForCurrentModel();
expect(manager.currentVersion.id).toBe(250);
});
it('still honours an explicit modelVersionId', async () => {
manager.modelId = '837884';
manager.modelVersionId = '100';
mockApiClient.fetchCivitaiVersions.mockResolvedValue(versions);
await manager.fetchVersionsForCurrentModel();
expect(manager.currentVersion.id).toBe(100);
});
});
describe('multi-URL batch without modelVersionId', () => {
it('defaults each item to its latest version (first in API response)', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/111/foo\nhttps://civitai.red/models/222/bar';
const versionsA = [makeVersion(40, 'a2'), makeVersion(10, 'a1')];
const versionsB = [makeVersion(500, 'b1'), makeVersion(7, 'b2')];
mockApiClient.fetchCivitaiVersions.mockImplementation(async modelId =>
modelId === '111' ? versionsA : versionsB
);
await manager.validateAndFetchVersions();
expect(manager.isBatchMode).toBe(true);
expect(manager.batchModels).toHaveLength(2);
expect(manager.batchModels[0].selectedVersion.id).toBe(40);
expect(manager.batchModels[1].selectedVersion.id).toBe(500);
});
it('still honours an explicit modelVersionId per URL', async () => {
document.getElementById('modelUrl').value =
'https://civitai.red/models/111/foo?modelVersionId=10\nhttps://civitai.red/models/222/bar';
const versionsA = [makeVersion(40, 'a2'), makeVersion(10, 'a1')];
const versionsB = [makeVersion(500, 'b1'), makeVersion(7, 'b2')];
mockApiClient.fetchCivitaiVersions.mockImplementation(async modelId =>
modelId === '111' ? versionsA : versionsB
);
await manager.validateAndFetchVersions();
expect(manager.batchModels[0].selectedVersion.id).toBe(10);
expect(manager.batchModels[1].selectedVersion.id).toBe(500);
});
});
});
@@ -501,3 +501,33 @@ describe('SettingsManager library controls', () => {
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
});
});
describe('SettingsManager recipes layout switch', () => {
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
const manager = createManager();
const select = document.createElement('select');
select.id = 'recipesLayout';
const option = document.createElement('option');
option.value = 'masonry';
select.appendChild(option);
select.value = 'masonry';
document.body.appendChild(select);
const calculateLayout = vi.fn();
state.virtualScroller = { calculateLayout };
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
await manager.saveSelectSetting('recipesLayout', 'recipes_layout');
const layoutEvent = dispatchSpy.mock.calls
.map(([event]) => event)
.find(event => event.type === 'lm:recipes-layout-changed');
expect(layoutEvent).toBeInstanceOf(CustomEvent);
expect(calculateLayout).not.toHaveBeenCalled();
expect(showToast).not.toHaveBeenCalled();
dispatchSpy.mockRestore();
delete state.virtualScroller;
});
});
+1
View File
@@ -69,6 +69,7 @@ vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
refreshVirtualScroll: refreshVirtualScrollMock,
recreateVirtualScroll: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
+2 -1
View File
@@ -17,7 +17,8 @@ describe('state module', () => {
civitai_host: 'civitai.com',
language: 'en',
blur_mature_content: true,
mature_blur_level: 'R'
mature_blur_level: 'R',
recipes_layout: 'grid'
});
expect(defaultSettings.download_path_templates).toEqual(DEFAULT_PATH_TEMPLATES);
+198
View File
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
VirtualScrollerMock,
MasonryScrollerMock,
RecipeCardMock,
fetchRecipesPageMock,
getModelApiClientMock,
} = vi.hoisted(() => {
const makeScrollerClass = () => vi.fn(function (options) {
this.options = options;
this.initialize = vi.fn(async () => {});
this.dispose = vi.fn();
this.reset = vi.fn();
this.handlePageUpDown = vi.fn();
});
return {
VirtualScrollerMock: makeScrollerClass(),
MasonryScrollerMock: makeScrollerClass(),
RecipeCardMock: vi.fn(function () {
this.element = document.createElement('div');
}),
fetchRecipesPageMock: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
getModelApiClientMock: vi.fn(() => ({
fetchModelsPage: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })),
})),
};
});
vi.mock('../../../static/js/utils/VirtualScroller.js', () => ({
VirtualScroller: VirtualScrollerMock,
}));
vi.mock('../../../static/js/utils/MasonryScroller.js', () => ({
MasonryScroller: MasonryScrollerMock,
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: RecipeCardMock,
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipesPage: fetchRecipesPageMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: getModelApiClientMock,
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
createModelCard: vi.fn(() => document.createElement('div')),
setupModelCardEventDelegation: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
import {
initializeInfiniteScroll,
recreateVirtualScroll,
} from '../../../static/js/utils/infiniteScroll.js';
import { state } from '../../../static/js/state/index.js';
function setupPageDom() {
const pageContent = document.createElement('div');
pageContent.className = 'page-content';
const container = document.createElement('div');
container.className = 'container';
pageContent.appendChild(container);
document.body.appendChild(pageContent);
const grid = document.createElement('div');
vi.spyOn(document, 'getElementById').mockImplementation((id) =>
id === 'recipeGrid' || id === 'modelGrid' ? grid : null);
return { grid };
}
describe('infiniteScroll scroller class branching', () => {
let originalSettings;
beforeEach(() => {
originalSettings = state.global.settings;
state.global.settings = { ...originalSettings };
if (state.pages.recipes) {
state.pages.recipes.duplicatesMode = false;
}
state.virtualScroller = null;
state.keyboardNavHandler = null;
setupPageDom();
});
afterEach(() => {
state.global.settings = originalSettings;
state.virtualScroller = null;
state.keyboardNavHandler = null;
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('constructs MasonryScroller for the recipes page when recipes_layout is masonry', async () => {
state.global.settings.recipes_layout = 'masonry';
await initializeInfiniteScroll('recipes');
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(VirtualScrollerMock).not.toHaveBeenCalled();
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
});
it('constructs VirtualScroller for the recipes page when recipes_layout is grid', async () => {
state.global.settings.recipes_layout = 'grid';
await initializeInfiniteScroll('recipes');
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
expect(state.virtualScroller).toBeInstanceOf(VirtualScrollerMock);
});
it('falls back to the grid branch when recipes_layout is missing', async () => {
delete state.global.settings.recipes_layout;
await expect(initializeInfiniteScroll('recipes')).resolves.toBeUndefined();
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
});
it('always constructs VirtualScroller for the loras page regardless of recipes_layout', async () => {
state.global.settings.recipes_layout = 'masonry';
await initializeInfiniteScroll('loras');
expect(VirtualScrollerMock).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).not.toHaveBeenCalled();
});
});
describe('recreateVirtualScroll', () => {
let originalSettings;
beforeEach(() => {
originalSettings = state.global.settings;
state.global.settings = { ...originalSettings };
if (state.pages.recipes) {
state.pages.recipes.duplicatesMode = false;
}
state.virtualScroller = null;
state.keyboardNavHandler = null;
setupPageDom();
});
afterEach(() => {
state.global.settings = originalSettings;
state.virtualScroller = null;
state.keyboardNavHandler = null;
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('cleans up keyboard navigation, disposes the old scroller, and rebuilds with the new layout', async () => {
state.global.settings.recipes_layout = 'grid';
await initializeInfiniteScroll('recipes');
const oldScroller = state.virtualScroller;
const oldKeyboardHandler = state.keyboardNavHandler;
expect(oldKeyboardHandler).toBeTypeOf('function');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
state.global.settings.recipes_layout = 'masonry';
await recreateVirtualScroll('recipes');
// cleanupKeyboardNavigation removed the previous document keydown listener
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', oldKeyboardHandler);
// Old scroller disposed and replaced by a new masonry instance
expect(oldScroller.dispose).toHaveBeenCalledTimes(1);
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
expect(state.virtualScroller).not.toBe(oldScroller);
// A fresh keyboard navigation listener was registered for the new instance
expect(state.keyboardNavHandler).toBeTypeOf('function');
expect(state.keyboardNavHandler).not.toBe(oldKeyboardHandler);
});
it('works when there is no existing virtual scroller', async () => {
state.global.settings.recipes_layout = 'masonry';
await expect(recreateVirtualScroll('recipes')).resolves.toBeUndefined();
expect(MasonryScrollerMock).toHaveBeenCalledTimes(1);
expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock);
});
});
@@ -0,0 +1,627 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MasonryScroller } from '../../../static/js/utils/MasonryScroller.js';
import { VirtualScroller } from '../../../static/js/utils/VirtualScroller.js';
import { getCurrentPageState, setCurrentPageType } from '../../../static/js/state/index.js';
// jsdom does not always provide requestAnimationFrame; polyfill when missing
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame !== 'function') {
window.requestAnimationFrame = (cb) => setTimeout(cb, 0);
window.cancelAnimationFrame = (id) => clearTimeout(id);
}
const CONTAINER_WIDTH = 768; // yields 3 columns at default density: floor((768+12)/(240+12)) = 3
const COLUMN_GAP = 12;
const ROW_GAP = 20;
const PAD_TOP = 4;
const PAD_BOTTOM = 4;
const ITEM_WIDTH = (CONTAINER_WIDTH - 2 * COLUMN_GAP) / 3; // 248
const FALLBACK_HEIGHT = ITEM_WIDTH / (896 / 1152);
function createItemFn() {
const el = document.createElement('div');
const card = document.createElement('div');
card.className = 'model-card';
const preview = document.createElement('div');
preview.className = 'card-preview';
card.appendChild(preview);
el.appendChild(card);
return el;
}
function makeItems(dimensions) {
return dimensions.map((dims, i) => ({
file_path: `/recipes/item-${i}.png`,
...dims,
}));
}
/**
* Build a scroller attached to a stubbed container. clientWidth/clientHeight
* are 0 in jsdom, so they are defined explicitly for deterministic layout.
*/
function createScroller({ items = [], fetchItemsFn, overscan, viewportHeight = 600, createItemFn: customCreateItemFn } = {}) {
const wrapper = document.createElement('div');
Object.defineProperty(wrapper, 'clientWidth', { value: CONTAINER_WIDTH, configurable: true });
Object.defineProperty(wrapper, 'clientHeight', { value: viewportHeight, configurable: true });
const grid = document.createElement('div');
wrapper.appendChild(grid);
document.body.appendChild(wrapper);
const fetchMock = fetchItemsFn || vi.fn(async () => ({ items, totalItems: items.length, hasMore: false }));
const scroller = new MasonryScroller({
gridElement: grid,
containerElement: wrapper,
scrollContainer: wrapper,
createItemFn: customCreateItemFn || createItemFn,
fetchItemsFn: fetchMock,
overscan,
});
return { scroller, wrapper, grid, fetchMock };
}
describe('MasonryScroller', () => {
const liveScrollers = [];
beforeEach(() => {
setCurrentPageType('recipes');
getCurrentPageState().duplicatesMode = false;
});
afterEach(() => {
while (liveScrollers.length > 0) {
liveScrollers.pop().dispose();
}
getCurrentPageState().duplicatesMode = false;
});
function track(setup) {
liveScrollers.push(setup.scroller);
return setup;
}
it('adds virtual-scroll and masonry-layout classes and creates the spacer', () => {
const { scroller, grid } = track(createScroller());
expect(grid.classList.contains('virtual-scroll')).toBe(true);
expect(grid.classList.contains('masonry-layout')).toBe(true);
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
expect(grid.style.position).toBe('relative');
expect(grid.contains(scroller.spacerElement)).toBe(true);
});
it('computes density-based column count and item width', () => {
const { scroller } = track(createScroller());
expect(scroller.columnsCount).toBe(3);
expect(scroller.itemWidth).toBeCloseTo(ITEM_WIDTH);
});
it('places each item into the shortest column', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const cols = scroller.positions.map((p) => p.col);
expect(cols).toEqual([0, 1, 2, 2, 1, 2]);
// Tops follow the accumulated shortest-column heights
expect(scroller.positions[0].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[1].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[2].top).toBeCloseTo(PAD_TOP);
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 124 + ROW_GAP); // 148
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
expect(scroller.positions[5].top).toBeCloseTo(148 + 248 + ROW_GAP); // 416
// Left offsets are column index * (itemWidth + columnGap)
expect(scroller.positions[1].left).toBeCloseTo(ITEM_WIDTH + COLUMN_GAP);
expect(scroller.positions[2].left).toBeCloseTo(2 * (ITEM_WIDTH + COLUMN_GAP));
});
it('sets spacer height to the tallest column minus trailing gap plus padding', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
// Column heights after placement: [520, 540, 684] (each starts at padTop)
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
});
it('falls back to the 896/1152 ratio for items without dimensions', () => {
const items = makeItems([{}, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
expect(scroller.positions[0].height).toBeCloseTo(FALLBACK_HEIGHT);
expect(scroller.positions[1].height).toBeCloseTo(ITEM_WIDTH);
});
it('visible range respects the overscan band', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller, wrapper } = track(createScroller({ items, viewportHeight: 300 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
// Without overscan, item 5 (top=416) is below the 300px viewport
scroller.overscan = 0;
let visible = scroller.getVisibleRange();
expect([...visible].sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4]);
// overscan=1 extends the band by one itemWidth (248px), pulling item 5 in
scroller.overscan = 1;
visible = scroller.getVisibleRange();
expect(visible.has(5)).toBe(true);
});
it('renders visible items with inline masonry styles and clears model-card max-width', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const rendered = grid.querySelectorAll('.virtual-scroll-item');
expect(rendered.length).toBe(3);
rendered.forEach((el, i) => {
expect(el.style.position).toBe('absolute');
expect(el.style.width).toBe(`${scroller.positions[i].width}px`);
expect(el.style.height).toBe(`${scroller.positions[i].height}px`);
expect(el.style.left).toBe(`${scroller.positions[i].left}px`);
expect(el.style.top).toBe(`${scroller.positions[i].top}px`);
expect(el.querySelector('.model-card').style.maxWidth).toBe('none');
expect(el.querySelector('.model-card').style.minWidth).toBe('0');
});
});
it('clears model-card max-width/min-width when the item element is the card root', () => {
// Production shape for recipe cards: RecipeCard returns the .model-card
// element itself, so the scroller must clear constraints on the element
// rather than a descendant (querySelector would find nothing).
const items = makeItems([{ width: 100, height: 200 }]);
const cardRoot = document.createElement('div');
cardRoot.className = 'model-card';
const { scroller, grid, wrapper } = track(createScroller({
items,
viewportHeight: 3000,
createItemFn: () => cardRoot.cloneNode(true),
}));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const rendered = grid.querySelectorAll('.virtual-scroll-item');
expect(rendered.length).toBe(1);
expect(rendered[0].style.maxWidth).toBe('none');
expect(rendered[0].style.minWidth).toBe('0');
});
it('triggers loadMoreItems when scrolled to the bottom', async () => {
const firstPage = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const fetchMock = vi.fn(async () => ({ items: makeItems([{ width: 100, height: 100 }]), totalItems: 7, hasMore: false }));
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
scroller.refreshWithData(firstPage, 100, true);
const pageState = getCurrentPageState();
const expectedPage = pageState.currentPage;
// contentBottom = 664; scrollBottom = 100 + 600 = 700 >= 664 - threshold
wrapper.scrollTop = 100;
scroller.handleScroll();
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
});
});
it('does not trigger loadMoreItems when far from the bottom', async () => {
const fetchMock = vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false }));
const manyItems = makeItems(Array.from({ length: 30 }, () => ({ width: 100, height: 200 })));
const { scroller, wrapper } = track(createScroller({ fetchItemsFn: fetchMock, viewportHeight: 600 }));
scroller.refreshWithData(manyItems, 1000, true);
wrapper.scrollTop = 0;
scroller.handleScroll();
await new Promise((resolve) => setTimeout(resolve, 20));
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns false from calculateLayout in duplicates mode', () => {
const { scroller } = track(createScroller());
getCurrentPageState().duplicatesMode = true;
expect(scroller.calculateLayout()).toBe(false);
});
it('computes placement and spacer synchronously during initialize (before rAF)', async () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
await scroller.initialize();
// Assert immediately after initialize resolves, without waiting for rAF
expect(scroller.positions.length).toBe(items.length);
const expected = 684 - ROW_GAP + PAD_TOP + PAD_BOTTOM; // 672
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
expect(getCurrentPageState().currentPage).toBe(2);
});
it('shows the error placeholder and resets isLoading when the initial fetch fails', async () => {
const fetchMock = vi.fn(async () => {
throw new Error('network down');
});
const { scroller, grid } = track(createScroller({ fetchItemsFn: fetchMock }));
await expect(scroller.initialize()).resolves.toBeUndefined();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('Failed to load items');
expect(scroller.isLoading).toBe(false);
});
it('shows the recipes empty placeholder when no items are returned', async () => {
const { scroller, grid } = track(createScroller({ items: [] }));
await scroller.initialize();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('No recipes found');
});
it('dispose removes classes, spacer and event listeners', () => {
const { scroller, grid } = track(createScroller());
scroller.dispose();
expect(grid.classList.contains('virtual-scroll')).toBe(false);
expect(grid.classList.contains('masonry-layout')).toBe(false);
expect(grid.querySelector('.virtual-scroll-spacer')).toBeNull();
});
it('exposes every VirtualScroller prototype method (API parity)', () => {
const virtualMethods = Object.getOwnPropertyNames(VirtualScroller.prototype);
const masonryMethods = new Set(Object.getOwnPropertyNames(MasonryScroller.prototype));
const missing = virtualMethods.filter((name) => !masonryMethods.has(name));
expect(missing).toEqual([]);
});
it('exposes the VirtualScroller property surface after construction', () => {
const { scroller } = track(createScroller());
const expectedProperties = [
'items',
'renderedItems',
'totalItems',
'hasMore',
'isLoading',
'gridElement',
'containerElement',
'scrollContainer',
'columnsCount',
'itemWidth',
'disabled',
'spacerElement',
'pageSize',
];
for (const prop of expectedProperties) {
expect(scroller[prop]).not.toBeUndefined();
}
});
it('updateSingleItem re-places items and shows the updated indicator on rendered cards', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
// Item 2 (height 124) sits in column 2 with items 3 and 5 stacked below it
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
const spacerBefore = scroller.spacerElement.style.height;
const colsBefore = scroller.positions.map((p) => p.col);
const result = scroller.updateSingleItem('/recipes/item-2.png', { width: 100, height: 150 });
expect(result).toBe(true);
// Item 2 height grew 124 -> 372, so the full synchronous re-placement
// re-flows every later item (item 3 moves from column 2 to column 1)
expect(scroller.positions[2].height).toBeCloseTo(ITEM_WIDTH * 1.5);
expect(scroller.positions.map((p) => p.col)).not.toEqual(colsBefore);
expect(scroller.spacerElement.style.height).not.toBe(spacerBefore);
// The re-placement equals a fresh full layout of the same items
const { scroller: reference } = track(createScroller({ items }));
reference.refreshWithData(scroller.items.slice(), items.length, false);
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
for (let i = 0; i < items.length; i++) {
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
}
// The rendered card was recreated in place with the update indicator
const updatedCard = grid.querySelector('.virtual-scroll-item.updated');
expect(updatedCard).not.toBeNull();
const indicator = updatedCard.querySelector('.update-indicator');
expect(indicator).not.toBeNull();
expect(indicator.textContent).toBe('Updated');
expect(updatedCard.querySelector('.card-preview').contains(indicator)).toBe(true);
expect(updatedCard.style.height).toBe(`${scroller.positions[2].height}px`);
expect(updatedCard.style.top).toBe(`${scroller.positions[2].top}px`);
});
it('updateSingleItem returns false for an unknown file path without throwing', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
let result;
expect(() => {
result = scroller.updateSingleItem('/recipes/does-not-exist.png', { title: 'x' });
}).not.toThrow();
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('removeItemByFilePath re-places the remaining items and decrements the total', () => {
// Heights at ITEM_WIDTH=248: 496, 248, 124, 248, 248, 248
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 60, false);
const result = scroller.removeItemByFilePath('/recipes/item-2.png');
expect(result).toBe(true);
expect(scroller.items.length).toBe(5);
expect(scroller.totalItems).toBe(59);
expect(scroller.positions.length).toBe(5);
// Remaining heights: 496, 248, 248, 248, 248 -> shortest-column placement
expect(scroller.positions.map((p) => p.col)).toEqual([0, 1, 2, 1, 2]);
expect(scroller.positions[3].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
expect(scroller.positions[4].top).toBeCloseTo(PAD_TOP + 248 + ROW_GAP); // 272
// Spacer reflects the tallest remaining column
const maxColumnHeight = Math.max(...scroller.columnHeights);
const expected = maxColumnHeight - ROW_GAP + PAD_TOP + PAD_BOTTOM;
expect(scroller.spacerElement.style.height).toBe(`${expected}px`);
});
it('removeItemByFilePath returns false for an unknown file path', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(scroller.removeItemByFilePath('/recipes/missing.png')).toBe(false);
warnSpy.mockRestore();
});
it('removeMultipleItemsByFilePath re-places items with no layout gaps', () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
{ width: 100, height: 100 },
]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 60, false);
const result = scroller.removeMultipleItemsByFilePath([
'/recipes/item-1.png',
'/recipes/item-3.png',
]);
expect(result).toBe(true);
expect(scroller.items.map((i) => i.file_path)).toEqual([
'/recipes/item-0.png',
'/recipes/item-2.png',
'/recipes/item-4.png',
'/recipes/item-5.png',
]);
expect(scroller.totalItems).toBe(58);
// The remaining items are laid out exactly as a fresh full placement:
// compare against a second scroller fed the same remaining items
const remaining = scroller.items.slice();
const { scroller: reference } = track(createScroller({ items: remaining }));
reference.refreshWithData(remaining, remaining.length, false);
expect(scroller.positions.map((p) => p.col)).toEqual(reference.positions.map((p) => p.col));
for (let i = 0; i < remaining.length; i++) {
expect(scroller.positions[i].top).toBeCloseTo(reference.positions[i].top);
expect(scroller.positions[i].left).toBeCloseTo(reference.positions[i].left);
}
});
it('removeMultipleItemsByFilePath returns false when nothing matches', () => {
const items = makeItems([{ width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
expect(scroller.removeMultipleItemsByFilePath(['/recipes/missing.png'])).toBe(false);
expect(scroller.removeMultipleItemsByFilePath([])).toBe(false);
});
it('disable stops rendering and enable recreates the spacer after innerHTML is cleared', async () => {
const items = makeItems([
{ width: 100, height: 200 },
{ width: 100, height: 100 },
{ width: 100, height: 50 },
]);
const { scroller, grid, wrapper } = track(createScroller({ items, viewportHeight: 3000 }));
scroller.refreshWithData(items, items.length, false);
wrapper.scrollTop = 0;
scroller.overscan = 5;
scroller.renderItems();
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
scroller.disable();
expect(scroller.disabled).toBe(true);
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(0);
expect(scroller.spacerElement.style.display).toBe('none');
// Duplicates mode wipes the grid contents, destroying the spacer
grid.innerHTML = '';
expect(grid.contains(scroller.spacerElement)).toBe(false);
scroller.enable();
expect(scroller.disabled).toBe(false);
expect(grid.contains(scroller.spacerElement)).toBe(true);
expect(scroller.spacerElement.className).toBe('virtual-scroll-spacer');
// Full re-placement ran synchronously on re-enable
expect(scroller.positions.length).toBe(items.length);
// Rendering resumes after the scheduled rAF
await new Promise((resolve) => setTimeout(resolve, 20));
expect(grid.querySelectorAll('.virtual-scroll-item').length).toBe(3);
});
it('getAdjacentItemByFilePath loads more pages when the target is beyond loaded items', async () => {
const page1 = [0, 1, 2].map((i) => ({
file_path: `/recipes/page1-${i}.png`,
width: 100,
height: 100,
}));
const page2 = [0, 1].map((i) => ({
file_path: `/recipes/page2-${i}.png`,
width: 100,
height: 100,
}));
const fetchMock = vi.fn(async () => ({ items: page2, totalItems: 5, hasMore: false }));
const { scroller } = track(createScroller({ fetchItemsFn: fetchMock }));
scroller.refreshWithData(page1, 5, true);
const pageState = getCurrentPageState();
const expectedPage = pageState.currentPage;
const result = await scroller.getAdjacentItemByFilePath('/recipes/page1-2.png', 'next');
expect(fetchMock).toHaveBeenCalledWith(expectedPage, scroller.pageSize);
expect(result).not.toBeNull();
expect(result.index).toBe(3);
expect(result.item.file_path).toBe('/recipes/page2-0.png');
});
it('getAdjacentItemByFilePath returns null at boundaries and for unknown paths', async () => {
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, items.length, false);
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-0.png', 'prev')).resolves.toBeNull();
await expect(scroller.getAdjacentItemByFilePath('/recipes/item-1.png', 'next')).resolves.toBeNull();
await expect(scroller.getAdjacentItemByFilePath('/recipes/missing.png', 'next')).resolves.toBeNull();
});
it('getNavigationState reports index, prev/next availability and totals', () => {
const items = makeItems([{ width: 100, height: 100 }, { width: 100, height: 100 }]);
const { scroller } = track(createScroller({ items }));
scroller.refreshWithData(items, 10, true);
expect(scroller.getNavigationState('/recipes/item-0.png')).toEqual({
index: 0,
hasPrev: false,
hasNext: true,
loadedItems: 2,
totalItems: 10,
});
const last = scroller.getNavigationState('/recipes/item-1.png');
expect(last.index).toBe(1);
expect(last.hasPrev).toBe(true);
// hasMore keeps forward navigation available past the loaded window
expect(last.hasNext).toBe(true);
expect(scroller.getNavigationState('/recipes/missing.png').index).toBe(-1);
expect(scroller.findIndexByFilePath('/recipes/item-1.png')).toBe(1);
expect(scroller.findIndexByFilePath('')).toBe(-1);
});
});
+43
View File
@@ -86,6 +86,41 @@ def test_save_image_skips_png_parameters_when_metadata_disabled_and_keeps_workfl
assert img.info["workflow"] == json.dumps(workflow)
def test_save_image_does_not_append_loras_to_prompt_by_default(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(
monkeypatch,
{"prompt": "prompt text", "seed": 123, "loras": "<lora:foo:0.7>"},
)
node = SaveImageLM()
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
image_path = tmp_path / "sample_00001_.png"
with Image.open(image_path) as img:
assert "<lora:" not in img.info["parameters"]
assert img.info["parameters"] == "prompt text\nSeed: 123, Version: ComfyUI"
def test_save_image_appends_loras_to_prompt_when_enabled(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(
monkeypatch,
{"prompt": "prompt text", "seed": 123, "loras": "<lora:foo:0.7>"},
)
node = SaveImageLM()
node.save_images(
[_make_image()], "ComfyUI", "png", id="node-1", add_loras_to_prompt=True
)
image_path = tmp_path / "sample_00001_.png"
with Image.open(image_path) as img:
assert img.info["parameters"] == (
"prompt text\n<lora:foo:0.7>\nSeed: 123, Version: ComfyUI"
)
def test_save_image_skips_jpeg_metadata_when_disabled(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
@@ -451,6 +486,14 @@ class TestParameterDefaultConsistency:
assert SaveImageLM.save_images.__defaults__[5] == 0
assert SaveImageLM.process_image.__defaults__[7] == 0
def test_add_loras_to_prompt_defaults_are_consistent(self):
input_types = SaveImageLM.INPUT_TYPES()
optional = input_types["optional"]
assert optional["add_loras_to_prompt"][1]["default"] is False
assert SaveImageLM.save_images.__defaults__[-1] is False
assert SaveImageLM.process_image.__defaults__[-1] is False
def test_png_does_not_pass_webp_method_or_jpeg_subsampling(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
+159 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from dataclasses import dataclass
@@ -11,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional
from aiohttp import FormData, web
from aiohttp.test_utils import TestClient, TestServer
from PIL import Image
from py.config import config
from py.routes import base_recipe_routes
@@ -368,6 +370,163 @@ async def test_list_recipes_provides_file_urls(monkeypatch, tmp_path: Path) -> N
assert payload["items"][0]["loras"] == []
async def test_list_recipes_exposes_preview_dimensions(
monkeypatch, tmp_path: Path
) -> None:
"""(a) Image recipe items carry integer width/height from the on-disk file."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_path = harness.tmp_dir / "recipes" / "real.png"
recipe_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (64, 32), color="red").save(recipe_path)
harness.scanner.listing_items = [
{
"id": "recipe-1",
"file_path": str(recipe_path),
"title": "Image Recipe",
"loras": [],
}
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
item = payload["items"][0]
assert item["width"] == 64
assert item["height"] == 32
assert isinstance(item["width"], int)
assert isinstance(item["height"], int)
async def test_list_recipes_omits_dimensions_for_video_and_missing(
monkeypatch, tmp_path: Path
) -> None:
"""(b) Video/missing-image recipes omit width/height yet still return 200."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.listing_items = [
{
"id": "recipe-video",
"file_path": str(harness.tmp_dir / "recipes" / "preview.mp4"),
"title": "Video Recipe",
"loras": [],
},
{
"id": "recipe-missing",
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
"title": "Missing Recipe",
"loras": [],
},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
for item in payload["items"]:
assert "width" not in item
assert "height" not in item
async def test_list_recipes_offloads_dimensions_to_thread(
monkeypatch, tmp_path: Path
) -> None:
"""(c) Dimension reads run through asyncio.to_thread for every item."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_path = harness.tmp_dir / "recipes" / "real.png"
recipe_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (16, 48), color="blue").save(recipe_path)
harness.scanner.listing_items = [
{
"id": "recipe-1",
"file_path": str(recipe_path),
"title": "Image Recipe",
"loras": [],
},
{
"id": "recipe-2",
"file_path": str(harness.tmp_dir / "recipes" / "gone.png"),
"title": "Missing Recipe",
"loras": [],
},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
real_to_thread = asyncio.to_thread
to_thread_calls: list[tuple] = []
async def counting_to_thread(fn, *args, **kwargs):
to_thread_calls.append((fn, args, kwargs))
return await real_to_thread(fn, *args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", counting_to_thread)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
assert len(to_thread_calls) >= len(harness.scanner.listing_items)
assert payload["items"][0]["width"] == 16
assert payload["items"][0]["height"] == 48
assert "width" not in payload["items"][1]
assert "height" not in payload["items"][1]
async def test_list_recipes_batches_dimensions_for_mixed_items(
monkeypatch, tmp_path: Path
) -> None:
"""(d) Mixed file_path presence: dims align per-item with their own files."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
wide_path = harness.tmp_dir / "recipes" / "wide.png"
tall_path = harness.tmp_dir / "recipes" / "tall.png"
wide_path.parent.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (120, 40), color="green").save(wide_path)
Image.new("RGB", (30, 90), color="blue").save(tall_path)
harness.scanner.listing_items = [
{
"id": "recipe-wide",
"file_path": str(wide_path),
"title": "Wide",
"loras": [],
},
{"id": "recipe-none", "title": "No Preview", "loras": []},
{
"id": "recipe-tall",
"file_path": str(tall_path),
"title": "Tall",
"loras": [],
},
{"id": "recipe-none-2", "title": "No Preview 2", "loras": []},
]
harness.scanner.cached_raw = list(harness.scanner.listing_items)
response = await harness.client.get("/api/lm/recipes")
payload = await response.json()
assert response.status == 200
items = payload["items"]
# Items with a file_path carry integer dims read from their own file;
# the two images differ in both dimensions so a shifted pair would
# fail these assertions.
assert items[0]["width"] == 120
assert items[0]["height"] == 40
assert isinstance(items[0]["width"], int)
assert isinstance(items[0]["height"], int)
assert items[2]["width"] == 30
assert items[2]["height"] == 90
# Items without a file_path get the no-preview fallback and omit dims.
for item in (items[1], items[3]):
assert "width" not in item
assert "height" not in item
assert item["file_url"] == "/loras_static/images/no-preview.png"
async def test_list_recipes_passes_checkpoint_hash_filter(
monkeypatch, tmp_path: Path
) -> None:
@@ -909,8 +1068,6 @@ async def test_batch_import_start_missing_source(monkeypatch, tmp_path: Path) ->
async def test_batch_import_start_already_running(monkeypatch, tmp_path: Path) -> None:
import asyncio
async with recipe_harness(monkeypatch, tmp_path) as harness:
original_analyze = harness.analysis.analyze_remote_image
@@ -529,7 +529,8 @@ async def test_not_found_example_images_are_cleaned(
model_dir = images_root / model_hash
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / "image_0.png").write_bytes(b"first")
# Pre-existing file collides with the valid image index (1) so the
# pre-download existence check must skip it without a network request
(model_dir / "image_1.png").write_bytes(b"second")
async def fake_process_local_examples(*_args, **_kwargs):
@@ -608,11 +609,188 @@ async def test_not_found_example_images_are_cleaned(
]
files = sorted(p.name for p in model_dir.iterdir())
assert files == ["image_0.png", "image_1.png"]
assert (model_dir / "image_0.png").read_bytes() == b"first"
assert files == ["image_1.png"]
assert (model_dir / "image_1.png").read_bytes() == b"second"
async def test_failed_models_retried_when_explicitly_targeted(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
images_root = tmp_path / "examples"
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
model_hash = "a" * 64
model_path = tmp_path / "model.safetensors"
model_path.write_text("data", encoding="utf-8")
model_metadata = {
"sha256": model_hash,
"model_name": "Failed Example",
"file_path": str(model_path),
"file_name": "model.safetensors",
"civitai": {"images": [{"url": "https://example.com/valid.png"}]},
}
scanner = StubScanner([model_metadata.copy()])
_patch_scanner(monkeypatch, scanner)
# Persist a previous failure so the skip path is exercised
images_root.mkdir(parents=True, exist_ok=True)
(images_root / ".download_progress.json").write_text(
json.dumps(
{
"failed_models": [model_hash],
"processed_models": [],
"rate_limited_models": [],
}
),
encoding="utf-8",
)
async def fake_process_local_examples(*_args, **_kwargs):
return False
async def fake_get_updated_model(model_hash_arg, _scanner):
return model_metadata
class DownloaderStub:
def __init__(self):
self.calls: list[str] = []
async def download_to_memory(self, url, *_args, **_kwargs):
self.calls.append(url)
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
downloader = DownloaderStub()
async def fake_get_downloader():
return downloader
monkeypatch.setattr(
download_module.ExampleImagesProcessor,
"process_local_examples",
staticmethod(fake_process_local_examples),
)
monkeypatch.setattr(
download_module.MetadataUpdater,
"get_updated_model",
staticmethod(fake_get_updated_model),
)
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
# Without explicit hashes the previously failed model is skipped
skipped_manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
result = await skipped_manager.start_download({"model_types": ["lora"], "delay": 0})
assert result["success"] is True
if skipped_manager._download_task is not None:
await asyncio.wait_for(skipped_manager._download_task, timeout=1)
assert downloader.calls == []
# With explicit hashes the previously failed model is retried and cleared
result = await manager.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
)
assert result["success"] is True
if manager._download_task is not None:
await asyncio.wait_for(manager._download_task, timeout=1)
assert downloader.calls == ["https://example.com/valid.png"]
assert manager._progress["failed_models"] == set()
assert model_hash in manager._progress["processed_models"]
async def test_explicit_targets_fill_partial_example_gaps(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
images_root = tmp_path / "examples"
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(images_root))
model_hash = "b" * 64
model_path = tmp_path / "model.safetensors"
model_path.write_text("data", encoding="utf-8")
model_metadata = {
"sha256": model_hash,
"model_name": "Partial Example",
"file_path": str(model_path),
"file_name": "model.safetensors",
"civitai": {
"images": [
{"url": "https://example.com/first.png"},
{"url": "https://example.com/second.png"},
]
},
}
scanner = StubScanner([model_metadata.copy()])
_patch_scanner(monkeypatch, scanner)
# Simulate a partially populated folder: index 0 already downloaded
model_dir = images_root / model_hash
model_dir.mkdir(parents=True, exist_ok=True)
(model_dir / "image_0.png").write_bytes(b"existing")
async def fake_process_local_examples(*_args, **_kwargs):
return False
async def fake_get_updated_model(model_hash_arg, _scanner):
return model_metadata
class DownloaderStub:
def __init__(self):
self.calls: list[str] = []
async def download_to_memory(self, url, *_args, **_kwargs):
self.calls.append(url)
return True, b"\x89PNG\r\n\x1a\n", {"content-type": "image/png"}
downloader = DownloaderStub()
async def fake_get_downloader():
return downloader
monkeypatch.setattr(
download_module.ExampleImagesProcessor,
"process_local_examples",
staticmethod(fake_process_local_examples),
)
monkeypatch.setattr(
download_module.MetadataUpdater,
"get_updated_model",
staticmethod(fake_get_updated_model),
)
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
# Untargeted run treats the populated folder as done
untargeted = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
result = await untargeted.start_download({"model_types": ["lora"], "delay": 0})
assert result["success"] is True
if untargeted._download_task is not None:
await asyncio.wait_for(untargeted._download_task, timeout=1)
assert downloader.calls == []
# Explicitly targeted run fills only the missing index, skipping the
# existing file without a network request
targeted = download_module.DownloadManager(ws_manager=ws_manager)
result = await targeted.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": [model_hash]}
)
assert result["success"] is True
if targeted._download_task is not None:
await asyncio.wait_for(targeted._download_task, timeout=1)
assert downloader.calls == ["https://example.com/second.png"]
assert (model_dir / "image_1.png").exists()
assert (model_dir / "image_0.png").read_bytes() == b"existing"
@pytest.fixture
def settings_manager():
return get_settings_manager()
+278
View File
@@ -27,6 +27,13 @@ class FakeScanner:
return list(self._roots)
class StubSettings:
"""Settings stub that returns defaults, avoiding the real settings singleton."""
def get(self, key, default=None):
return default
@pytest.mark.asyncio
async def test_search_relative_paths_supports_multiple_tokens():
scanner = FakeScanner(
@@ -101,3 +108,274 @@ async def test_search_safe_does_not_match_all_files():
matching = await service.search_relative_paths("safe")
assert len(matching) == 0
class SfwStubSettings(StubSettings):
"""Settings stub with the global SFW filter enabled."""
def get(self, key, default=None):
if key == "show_only_sfw":
return True
return default
@pytest.mark.asyncio
async def test_search_relative_paths_respects_global_sfw_setting():
"""Filtered search applies show_only_sfw like the list endpoint (parity)."""
scanner = FakeScanner(
[
{"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0},
{"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings()
)
matching = await service.search_relative_paths("model", apply_filters=True)
assert matching == ["sfw-model.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_sfw_only_applied_when_filter_mode_is_on():
"""Global settings (show_only_sfw) apply only when the filter pipeline runs."""
scanner = FakeScanner(
[
{"file_path": "/models/sfw-model.safetensors", "preview_nsfw_level": 0},
{"file_path": "/models/nsfw-model.safetensors", "preview_nsfw_level": 4},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=SfwStubSettings()
)
default_matching = await service.search_relative_paths("model")
assert default_matching == [
"sfw-model.safetensors",
"nsfw-model.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_paths_folder_filter_recursive():
"""folder filter with recursive=True (default) matches subfolders."""
scanner = FakeScanner(
[
{"file_path": "/models/anime/model-a.safetensors", "folder": "anime"},
{
"file_path": "/models/anime/nsfw/model-b.safetensors",
"folder": "anime/nsfw",
},
{"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", folder="anime")
assert matching == [
f"anime{os.sep}model-a.safetensors",
f"anime{os.sep}nsfw{os.sep}model-b.safetensors",
]
@pytest.mark.asyncio
async def test_search_relative_paths_folder_filter_exact():
"""folder filter with recursive=False matches only the exact folder."""
scanner = FakeScanner(
[
{"file_path": "/models/anime/model-a.safetensors", "folder": "anime"},
{
"file_path": "/models/anime/nsfw/model-b.safetensors",
"folder": "anime/nsfw",
},
{"file_path": "/models/realistic/model-c.safetensors", "folder": "realistic"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", folder="anime", recursive=False
)
assert matching == [f"anime{os.sep}model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_base_model_filter():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "base_model": "SD 1.5"},
{"file_path": "/models/model-b.safetensors", "base_model": "SDXL"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", base_models=["SD 1.5"])
assert matching == ["model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_tag_include():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "tags": ["anime"]},
{"file_path": "/models/model-b.safetensors", "tags": ["realistic"]},
{"file_path": "/models/model-c.safetensors", "tags": ["anime", "realistic"]},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", tags={"anime": "include"})
assert set(matching) == {"model-a.safetensors", "model-c.safetensors"}
@pytest.mark.asyncio
async def test_search_relative_paths_tag_exclude():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "tags": ["anime"]},
{"file_path": "/models/model-b.safetensors", "tags": ["realistic"]},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", tags={"anime": "exclude"})
assert matching == ["model-b.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_auto_tag_include():
scanner = FakeScanner(
[
{
"file_path": "/models/model-i2v.safetensors",
"file_name": "model-i2v.safetensors",
},
{
"file_path": "/models/model-t2v.safetensors",
"file_name": "model-t2v.safetensors",
},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", auto_tags={"I2V": "include"}
)
assert matching == ["model-i2v.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_tag_logic_all():
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "tags": ["anime", "style"]},
{"file_path": "/models/model-b.safetensors", "tags": ["anime"]},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", tags={"anime": "include", "style": "include"}, tag_logic="all"
)
assert matching == ["model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_credit_required_filter():
# license_flags bit0: 1 = no credit required, 0 = credit required
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "license_flags": 127},
{"file_path": "/models/model-b.safetensors", "license_flags": 0},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("model", credit_required=True)
assert matching == ["model-b.safetensors"]
matching = await service.search_relative_paths("model", credit_required=False)
assert matching == ["model-a.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_allow_selling_filter():
# license_flags bit1: 1 = commercial image use allowed, 0 = not allowed
scanner = FakeScanner(
[
{"file_path": "/models/model-a.safetensors", "license_flags": 2},
{"file_path": "/models/model-b.safetensors", "license_flags": 1},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths(
"model", allow_selling_generated_content=True
)
assert matching == ["model-a.safetensors"]
matching = await service.search_relative_paths(
"model", allow_selling_generated_content=False
)
assert matching == ["model-b.safetensors"]
@pytest.mark.asyncio
async def test_search_relative_paths_no_filters_regression():
"""No filter kwargs -> behavior is byte-identical to plain token matching."""
scanner = FakeScanner(
[
{"file_path": "/models/flux/detail-model.safetensors"},
{"file_path": "/models/flux/only-flux.safetensors"},
],
["/models"],
)
service = DummyService(
"stub", scanner, BaseModelMetadata, settings_provider=StubSettings()
)
matching = await service.search_relative_paths("flux")
assert matching == [
f"flux{os.sep}only-flux.safetensors",
f"flux{os.sep}detail-model.safetensors",
]
+53
View File
@@ -860,6 +860,59 @@ def test_set_recipes_path_rewrites_symlinked_recipe_metadata(manager, tmp_path):
assert not old_json_path.exists()
def test_set_recipes_path_allows_cross_drive_migration(manager, tmp_path, monkeypatch):
# Windows regression: os.path.commonpath raises ValueError for paths on
# different drives (ntpath semantics). Cross-drive moves must succeed.
lora_root = tmp_path / "loras"
old_recipes_dir = lora_root / "recipes" / "nested"
old_recipes_dir.mkdir(parents=True)
manager.set("folder_paths", {"loras": [str(lora_root)]})
recipe_id = "recipe-cross-drive"
old_image_path = old_recipes_dir / f"{recipe_id}.webp"
old_json_path = old_recipes_dir / f"{recipe_id}.recipe.json"
old_image_path.write_bytes(b"image-bytes")
old_json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": str(old_image_path),
"title": "Recipe Cross Drive",
}
),
encoding="utf-8",
)
new_recipes_dir = tmp_path / "N_drive" / "AI" / "Library" / "Recipes"
# The effective current recipes dir (source of the migration) is
# lora_root/recipes — the nested subdirectory holds the recipe files.
source = str(lora_root / "recipes")
target = str(new_recipes_dir)
real_commonpath = os.path.commonpath
def fake_commonpath(paths):
# Simulate ntpath on Windows: a source/target pair on different
# drives shares no common root and raises ValueError.
if {source, target} <= set(paths):
raise ValueError("Paths don't have the same drive")
return real_commonpath(paths)
monkeypatch.setattr(os.path, "commonpath", fake_commonpath)
manager.set("recipes_path", str(new_recipes_dir))
migrated_image_path = new_recipes_dir / "nested" / f"{recipe_id}.webp"
migrated_json_path = new_recipes_dir / "nested" / f"{recipe_id}.recipe.json"
assert manager.get("recipes_path") == str(new_recipes_dir.resolve())
assert migrated_image_path.read_bytes() == b"image-bytes"
migrated_payload = json.loads(migrated_json_path.read_text(encoding="utf-8"))
assert migrated_payload["file_path"] == str(migrated_image_path)
assert not old_image_path.exists()
assert not old_json_path.exists()
def test_set_recipes_path_rejects_file_target(manager, tmp_path):
lora_root = tmp_path / "loras"
lora_root.mkdir()
@@ -63,7 +63,7 @@ async def test_start_download_bootstraps_progress_and_task(
release = asyncio.Event()
async def fake_download(
self, output_dir, optimize, model_types, delay, library_name, force=False
self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
):
started.set()
await release.wait()
@@ -93,6 +93,44 @@ async def test_start_download_bootstraps_progress_and_task(
assert manager._progress["status"] == "completed"
async def test_start_download_forwards_model_hashes(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["libraries"] = {"default": {}}
settings_manager.settings["active_library"] = "default"
manager = download_module.DownloadManager(ws_manager=RecordingWebSocketManager())
received: Dict[str, Any] = {}
async def fake_download(
self, output_dir, optimize, model_types, delay, library_name, force=False, model_hashes=None
):
received["model_hashes"] = model_hashes
async with self._state_lock:
self._is_downloading = False
self._download_task = None
self._progress["status"] = "completed"
monkeypatch.setattr(
download_module.DownloadManager,
"_download_all_example_images",
fake_download,
)
result = await manager.start_download(
{"model_types": ["lora"], "delay": 0, "model_hashes": ["abc123", "def456"]}
)
assert result["success"] is True
task = manager._download_task
assert task is not None
await asyncio.wait_for(task, timeout=1)
assert received["model_hashes"] == ["abc123", "def456"]
async def test_pause_and_resume_flow(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
@@ -100,6 +100,54 @@ def test_get_file_extension_media_type_hint_low_priority() -> None:
assert ext == ".mp4"
def test_example_image_file_exists_checks_plausible_extensions(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
assert proc._example_image_file_exists(str(tmp_path), 0) is False
Path(tmp_path, "image_0.webp").write_bytes(b"x")
assert proc._example_image_file_exists(str(tmp_path), 0) is True
assert proc._example_image_file_exists(str(tmp_path), 1) is False
def test_example_image_file_exists_video_hint_only_checks_video_extensions(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
Path(tmp_path, "image_2.jpg").write_bytes(b"x")
# An existing image file must not satisfy a video-hinted lookup
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is False
Path(tmp_path, "image_2.mp4").write_bytes(b"x")
assert proc._example_image_file_exists(str(tmp_path), 2, "video") is True
async def test_download_model_images_with_tracking_skips_existing_files(tmp_path) -> None:
proc = processor_module.ExampleImagesProcessor
images = [
{"url": "https://image.civitai.com/a/b", "type": "image"},
{"url": "https://image.civitai.com/c/d", "type": "image"},
]
Path(tmp_path, "image_0.jpg").write_bytes(b"existing")
class RecordingDownloader:
def __init__(self) -> None:
self.calls: list[str] = []
async def download_to_memory(self, url, use_auth=False, return_headers=False):
self.calls.append(url)
return True, b"\xff\xd8\xff" + b"data", {}
downloader = RecordingDownloader()
success, is_stale, failed, rate_limited = await proc.download_model_images_with_tracking(
"hash", "model", images, str(tmp_path), False, downloader
)
assert success is True
assert is_stale is False
assert failed == []
assert rate_limited == []
# Only the missing image is requested; the existing one is skipped without a network call
assert len(downloader.calls) == 1
assert "c/d" in downloader.calls[0]
assert Path(tmp_path, "image_1.jpg").exists()
class StubScanner:
def __init__(self, models: list[Dict[str, Any]]) -> None:
self._cache = SimpleNamespace(raw_data=models)
+55
View File
@@ -288,3 +288,58 @@ class TestIsobmffBrotliExtraction:
# Direct extraction should return None because decompressed size exceeds limit
result = ExifUtils._extract_isobmff_brotli(str(path))
assert result is None
# --- get_image_dimensions tests ---
def test_get_image_dimensions_returns_actual_size(tmp_path):
"""(a) A valid image returns its real (width, height)."""
image_path = tmp_path / "preview.png"
Image.new("RGB", (64, 32), color="red").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (64, 32)
def test_get_image_dimensions_missing_path_returns_none(tmp_path):
"""(b) A nonexistent path returns None without raising."""
assert ExifUtils.get_image_dimensions(str(tmp_path / "missing.png")) is None
def test_get_image_dimensions_skips_video_extension_without_pil(tmp_path, monkeypatch):
"""(c) A .mp4 path returns None and never invokes PIL."""
video_path = tmp_path / "preview.mp4"
video_path.write_bytes(b"not really a video")
def fail_if_called(*args, **kwargs):
raise AssertionError("PIL Image.open must not be called for video paths")
monkeypatch.setattr("py.utils.exif_utils.Image.open", fail_if_called)
assert ExifUtils.get_image_dimensions(str(video_path)) is None
def test_get_image_dimensions_corrupt_file_returns_none(tmp_path):
"""(d) A corrupt file returns None without raising."""
image_path = tmp_path / "corrupt.png"
image_path.write_bytes(b"\x00\x01\x02\x03 not a real image")
assert ExifUtils.get_image_dimensions(str(image_path)) is None
def test_get_image_dimensions_cache_key_includes_mtime(tmp_path):
"""(e) Replacing a path with a different-size image returns the new size."""
image_path = tmp_path / "replaced.png"
Image.new("RGB", (64, 32), color="red").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (64, 32)
Image.new("RGB", (100, 50), color="blue").save(image_path)
assert ExifUtils.get_image_dimensions(str(image_path)) == (100, 50)
def test_get_image_dimensions_skips_unreadable_formats(tmp_path):
"""(f) .avif/.jxl paths return None without raising."""
for ext in (".avif", ".jxl"):
image_path = tmp_path / f"preview{ext}"
image_path.write_bytes(b"fake container data")
assert ExifUtils.get_image_dimensions(str(image_path)) is None
@@ -45,7 +45,7 @@ export interface AutocompleteTextWidgetInterface {
const props = defineProps<{
widget: AutocompleteTextWidgetInterface
node: { id: number }
modelType?: 'loras' | 'embeddings' | 'custom_words' | 'prompt'
modelType?: 'loras' | 'prompt'
placeholder?: string
showPreview?: boolean
spellcheck?: boolean
@@ -98,7 +98,7 @@ interface LoraInfoWidget {
onSetValue?: (v: unknown) => void
callback?: unknown
options?: {
getValue?: () => LoraInfoWidgetValue
getValue?: () => unknown
setValue?: (v: unknown) => void
}
node?: { widgets?: Array<{ id?: string }>; widgets_values?: Array<unknown> }
@@ -299,8 +299,12 @@ onMounted(() => {
// ComponentWidgetImpl.value getter/setter delegates to options.getValue/options.setValue.
// These must be set for workflow JSON persistence (LGraphNode.serialize/configure) to work.
props.widget.options.getValue = buildValue
props.widget.options.setValue = applyValue
if (props.widget.options) {
props.widget.options.getValue = buildValue
props.widget.options.setValue = applyValue
} else {
console.warn('[LoraInfoWidget] widget.options missing, value persistence disabled')
}
// Also set serializeValue for prompt/API serialization path (executionUtil.ts)
props.widget.serializeValue = async () => buildValue()
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, type Ref } from 'vue'
// Dynamic import type for AutoComplete class
type AutoCompleteClass = new (
inputElement: HTMLTextAreaElement,
modelType: 'loras' | 'embeddings' | 'custom_words' | 'prompt',
modelType: 'loras' | 'prompt',
options?: AutocompleteOptions
) => AutoCompleteInstance
@@ -29,7 +29,7 @@ export interface UseAutocompleteOptions {
export function useAutocomplete(
textareaRef: Ref<HTMLTextAreaElement | null>,
modelType: 'loras' | 'embeddings' | 'custom_words' | 'prompt' = 'loras',
modelType: 'loras' | 'prompt' = 'loras',
options: UseAutocompleteOptions = {}
) {
const autocompleteInstance = ref<AutoCompleteInstance | null>(null)
+6 -9
View File
@@ -36,6 +36,9 @@ const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300
const AUTOCOMPLETE_METADATA_VERSION = 1
const LORA_MANAGER_WIDGET_IDS_PROPERTY = '__lm_widget_ids'
// Access LiteGraph global for Vue DOM mode detection (matches AutocompleteTextWidget.vue)
declare const LiteGraph: { vueNodesMode?: boolean } | undefined
// @ts-ignore - ComfyUI external module
import { app } from '../../../scripts/app.js'
// @ts-ignore - ComfyUI external module
@@ -718,7 +721,7 @@ function createLoraInfoWidget(node: any) {
function createAutocompleteTextWidgetFactory(
node: any,
widgetName: string,
modelType: 'loras' | 'embeddings' | 'prompt',
modelType: 'loras' | 'prompt',
inputOptions: { placeholder?: string } = {}
) {
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
@@ -835,7 +838,7 @@ function createAutocompleteTextWidgetFactory(
applyAutocompleteTextLayoutFix(
widget,
container,
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode
typeof LiteGraph !== 'undefined' && LiteGraph.vueNodesMode === true
)
}
@@ -964,13 +967,7 @@ app.registerExtension({
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
return createAutocompleteTextWidgetFactory(node, 'text', 'loras', options)
},
// Autocomplete text widget for embeddings (used by Prompt node)
// @ts-ignore
AUTOCOMPLETE_TEXT_EMBEDDINGS(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
return createAutocompleteTextWidgetFactory(node, 'text', 'embeddings', options)
},
// Autocomplete text widget for prompt (supports both embeddings and custom words)
// Autocomplete text widget for prompt (used by Prompt and Text nodes)
// @ts-ignore
AUTOCOMPLETE_TEXT_PROMPT(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {}
+202 -16
View File
@@ -14,6 +14,7 @@ import {
getAutocompleteAppendCommaPreference,
getAutocompleteAutoFormatPreference,
getAutocompleteAcceptKeyPreference,
getLoraActiveFiltersAutocompletePreference,
getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference,
} from "./settings.js";
@@ -48,6 +49,47 @@ const TAG_COMMANDS = {
},
};
// Command definitions for LoRA active-filters search
// Aliases (/activefilters, /noactivefilters) mirror /emb ↔ /embedding
const LORAS_COMMANDS = {
'/af': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
label: 'Active Filters: ON',
feedbackSummary: 'Active Filters Search: ON',
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
condition: () => !getLoraActiveFiltersAutocompletePreference()
},
'/noaf': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: false,
label: 'Active Filters: OFF',
feedbackSummary: 'Active Filters Search: OFF',
feedbackDetail: 'LoRA autocomplete searches the full library again.',
condition: () => getLoraActiveFiltersAutocompletePreference()
},
'/activefilters': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
label: 'Active Filters: ON',
feedbackSummary: 'Active Filters Search: ON',
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
condition: () => !getLoraActiveFiltersAutocompletePreference()
},
'/noactivefilters': {
type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete',
value: false,
label: 'Active Filters: OFF',
feedbackSummary: 'Active Filters Search: OFF',
feedbackDetail: 'LoRA autocomplete searches the full library again.',
condition: () => getLoraActiveFiltersAutocompletePreference()
},
};
// Category display information
const CATEGORY_INFO = {
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
@@ -719,6 +761,36 @@ class AutoComplete {
searchTerm = (match[1] || '').trim();
}
// For loras model type, check if we're in command mode (/af, /noaf)
if (this.modelType === 'loras') {
const commandResult = this._parseCommandInput(rawSearchTerm);
if (commandResult.showCommands) {
// Show command list dropdown
this.showingCommands = true;
this.activeCommand = null;
this.searchType = 'commands';
this._showCommandList(commandResult.commandFilter);
return;
} else if (commandResult.command?.type === 'toggle_setting') {
// Handle toggle setting command (/af, /noaf)
this._handleToggleSettingCommand(commandResult.command);
return;
} else if (commandResult.command) {
// Command is active, use filtered search
this.showingCommands = false;
this.activeCommand = null;
this.searchType = null;
searchTerm = commandResult.searchTerm || rawSearchTerm;
} else {
// No command - regular lora search
this.showingCommands = false;
this.activeCommand = null;
this.searchType = null;
searchTerm = rawSearchTerm;
}
}
// For prompt model type, check if we're searching embeddings, commands, or tags
if (this.modelType === 'prompt') {
const match = rawSearchTerm.match(/^emb:(.*)$/i);
@@ -1095,7 +1167,11 @@ class AutoComplete {
}
_isSelectableInfoItem(item) {
return isWildcardInfoItem(item);
if (isWildcardInfoItem(item)) {
return true;
}
// Command items are not model paths — never show preview for them
return item && typeof item === 'object' && 'command' in item;
}
/**
@@ -1159,6 +1235,11 @@ class AutoComplete {
return (match?.[1] || '').trim();
}
if (this.modelType === 'loras') {
const commandResult = this._parseCommandInput(rawSearchTerm);
return commandResult.searchTerm ?? rawSearchTerm;
}
if (this.modelType === 'prompt') {
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
if (embeddingMatch) {
@@ -1245,6 +1326,91 @@ class AutoComplete {
return this._getPreferredSelectedIndex(searchTerm);
}
/**
* Build a URL-encoded query string from the LoRA Manager page's active
* filters in localStorage, or null when not applicable.
*/
_getActiveLoraFilters() {
if (this.modelType !== 'loras' || !getLoraActiveFiltersAutocompletePreference()) {
return null;
}
try {
const params = new URLSearchParams();
const folder = localStorage.getItem('lora_manager_loras_activeFolder');
const recursiveRaw = localStorage.getItem('lora_manager_loras_recursiveSearch');
const recursive = recursiveRaw === null ? true : recursiveRaw.toLowerCase() === 'true';
if (folder && folder !== 'null') {
params.append('folder', folder);
} else if (!recursive) {
// Root folder with recursion disabled mirrors the page list,
// which matches only root-level files via folder=''.
params.append('folder', '');
}
const raw = localStorage.getItem('lora_manager_loras_filters');
if (raw) {
const filters = JSON.parse(raw);
if (Array.isArray(filters.baseModel)) {
filters.baseModel.forEach((m) => m && params.append('base_model', m));
}
if (filters.tags && typeof filters.tags === 'object') {
Object.entries(filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('tag_include', tag);
} else if (state === 'exclude') {
params.append('tag_exclude', tag);
}
});
}
if (filters.autoTags && typeof filters.autoTags === 'object') {
Object.entries(filters.autoTags).forEach(([tag, state]) => {
if (state === 'include') {
params.append('auto_tag_include', tag);
} else if (state === 'exclude') {
params.append('auto_tag_exclude', tag);
}
});
}
if (Array.isArray(filters.modelTypes)) {
filters.modelTypes.forEach((t) => t && params.append('model_type', t));
}
if (filters.tagLogic) {
params.append('tag_logic', filters.tagLogic);
}
if (filters.license) {
if (filters.license.noCredit === 'include') {
params.append('credit_required', 'false');
} else if (filters.license.noCredit === 'exclude') {
params.append('credit_required', 'true');
}
if (filters.license.allowSelling === 'include') {
params.append('allow_selling_generated_content', 'true');
} else if (filters.license.allowSelling === 'exclude') {
params.append('allow_selling_generated_content', 'false');
}
}
}
// Always send recursive in filter mode — its presence also signals
// the backend to run the filter pipeline (e.g. show_only_sfw) even
// when no concrete filter is set, matching the list endpoint.
params.append('recursive', String(recursive));
return params.toString();
} catch (error) {
console.warn('[Lora Manager] Failed to read active filters for autocomplete:', error);
return null;
}
}
async search(term = '', endpoint = null) {
try {
this.currentSearchTerm = term;
@@ -1262,6 +1428,10 @@ class AutoComplete {
endpoint = `/lm/${this.modelType}/relative-paths`;
}
// Active-filter query params for loras (null when setting off or
// model type is not loras, so appending is safe for all types)
const activeFiltersQuery = this._getActiveLoraFilters();
// Generate multiple query variations for better matching, but avoid
// sending duplicate-equivalent requests that normalize to the same
// backend search term.
@@ -1281,9 +1451,10 @@ class AutoComplete {
const url = endpoint.includes('?')
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.maxItems}`;
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
try {
const response = await api.fetchApi(url);
const response = await api.fetchApi(finalUrl);
const data = await response.json();
return {
items: data.success ? (data.relative_paths || data.words || []) : [],
@@ -1358,6 +1529,15 @@ class AutoComplete {
}
}
/**
* Return the command map for the current model type.
* Lora model types get the active-filters toggle commands, all others
* keep the prompt tag commands.
*/
_getCommands() {
return this.modelType === 'loras' ? LORAS_COMMANDS : TAG_COMMANDS;
}
/**
* Parse command input to detect command mode
* @param {string} rawInput - Raw input text
@@ -1379,8 +1559,8 @@ class AutoComplete {
const partialCommand = trimmed.toLowerCase();
// Check for exact command match
if (TAG_COMMANDS[partialCommand]) {
const cmd = TAG_COMMANDS[partialCommand];
if (this._getCommands()[partialCommand]) {
const cmd = this._getCommands()[partialCommand];
// Filter out toggle commands that don't meet their condition
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
return { showCommands: false, command: null, searchTerm: '' };
@@ -1405,8 +1585,8 @@ class AutoComplete {
const commandPart = trimmed.slice(0, spaceIndex).toLowerCase();
const searchPart = trimmed.slice(spaceIndex + 1).trim();
if (TAG_COMMANDS[commandPart]) {
const cmd = TAG_COMMANDS[commandPart];
if (this._getCommands()[commandPart]) {
const cmd = this._getCommands()[commandPart];
// Filter out toggle commands that don't meet their condition
if (cmd.type === 'toggle_setting' && cmd.condition && !cmd.condition()) {
return { showCommands: false, command: null, searchTerm: trimmed };
@@ -1437,7 +1617,7 @@ class AutoComplete {
const commands = [];
for (const [cmd, info] of Object.entries(TAG_COMMANDS)) {
for (const [cmd, info] of Object.entries(this._getCommands())) {
// Filter out toggle commands that don't meet their condition
if (info.type === 'toggle_setting' && info.condition) {
if (!info.condition()) continue;
@@ -1902,7 +2082,8 @@ class AutoComplete {
showPreviewForItem(relativePath, itemElement) {
if (!this.options.showPreview || !this.previewTooltip) return;
if (typeof relativePath !== 'string' || !relativePath) return;
// Extract filename without extension for preview
const fileName = relativePath.split(/[/\\]/).pop();
const loraName = fileName.replace(/\.(safetensors|ckpt|pt|bin)$/i, '');
@@ -1984,14 +2165,18 @@ class AutoComplete {
const queriesToExecute = this._getQueriesToExecute(this.currentSearchTerm);
const offset = this.items.length;
// Active-filter query params for loras (null when setting off)
const activeFiltersQuery = this._getActiveLoraFilters();
// Execute all queries in parallel with offset
const searchPromises = queriesToExecute.map(async (query) => {
const url = endpoint.includes('?')
? `${endpoint}&search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`
: `${endpoint}?search=${encodeURIComponent(query)}&limit=${this.options.pageSize}&offset=${offset}`;
const finalUrl = activeFiltersQuery ? `${url}&${activeFiltersQuery}` : url;
try {
const response = await api.fetchApi(url);
const response = await api.fetchApi(finalUrl);
const data = await response.json();
return data.success ? (data.relative_paths || data.words || []) : [];
} catch (error) {
@@ -2692,14 +2877,14 @@ class AutoComplete {
const settingManager = app?.extensionManager?.setting;
if (settingManager && typeof settingManager.set === 'function') {
await settingManager.set(settingId, value);
this._showToggleFeedback(value);
this._showToggleFeedback(command, value);
this._clearCurrentToken();
} else {
// Fallback: use legacy settings API
const setting = app.ui.settings.settingsById?.[settingId];
if (setting) {
app.ui.settings.setSettingValue(settingId, value);
this._showToggleFeedback(value);
this._showToggleFeedback(command, value);
this._clearCurrentToken();
}
}
@@ -2718,15 +2903,16 @@ class AutoComplete {
/**
* Show visual feedback for toggle action using toast
* @param {Object} command - The toggle command that was executed
* @param {boolean} enabled - New autocomplete state
*/
_showToggleFeedback(enabled) {
_showToggleFeedback(command, enabled) {
showToast({
severity: enabled ? 'success' : 'secondary',
summary: enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled',
detail: enabled
? 'Tag autocomplete is now ON. Type to see suggestions.'
: 'Tag autocomplete is now OFF. Use /ac to re-enable.',
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
detail: command.feedbackDetail || (enabled
? 'Tag autocomplete is now ON. Type to see suggestions.'
: 'Tag autocomplete is now OFF. Use /ac to re-enable.'),
life: 3000
});
}
+38
View File
@@ -39,6 +39,9 @@ const NEW_TAB_ZOOM_LEVEL = 0.8;
const STRENGTH_STEP_SETTING_ID = "loramanager.strength_step";
const STRENGTH_STEP_DEFAULT = 0.05;
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID = "loramanager.lora_active_filters_autocomplete";
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT = false;
// ============================================================================
// Helper Functions
// ============================================================================
@@ -360,6 +363,32 @@ const getStrengthStepPreference = (() => {
};
})();
const getLoraActiveFiltersAutocompletePreference = (() => {
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 lora active filters autocomplete setting.");
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
try {
const value = settingManager.get(LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID);
return value ?? LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read lora active filters autocomplete setting, using default.", error);
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
};
})();
// ============================================================================
// Register Extension with All Settings
// ============================================================================
@@ -396,6 +425,14 @@ app.registerExtension({
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
category: ["LoRA Manager", "Autocomplete", "Prompt"],
},
{
id: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
name: "Search LoRA autocomplete within active filters",
type: "boolean",
defaultValue: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT,
tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. Commands /af and /noaf toggle this mode.",
category: ["LoRA Manager", "Autocomplete", "LoRA Active Filters"],
},
{
id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID,
name: "Append comma after autocomplete",
@@ -549,4 +586,5 @@ export {
getUsageStatisticsPreference,
getNewTabTemplatePreference,
getStrengthStepPreference,
getLoraActiveFiltersAutocompletePreference,
};
+64 -66
View File
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0;
}
.autocomplete-text-widget[data-v-3f3d7a1a] {
.autocomplete-text-widget[data-v-55e3316e] {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.input-wrapper[data-v-3f3d7a1a] {
.input-wrapper[data-v-55e3316e] {
position: relative;
flex: 1;
display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
}
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-3f3d7a1a] {
.text-input[data-v-55e3316e] {
flex: 1;
width: 100%;
background-color: var(--comfy-input-bg, #222);
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
}
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-3f3d7a1a] {
.text-input.vue-dom-mode[data-v-55e3316e] {
background-color: var(--color-charcoal-400, #313235);
color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2161,12 +2161,12 @@ to { transform: rotate(360deg);
font-size: 12px;
font-family: inherit;
}
.text-input[data-v-3f3d7a1a]:focus {
.text-input[data-v-55e3316e]:focus {
outline: none;
}
/* Clear button styles */
.clear-button[data-v-3f3d7a1a] {
.clear-button[data-v-55e3316e] {
position: absolute;
right: 6px;
bottom: 6px; /* Changed from top to bottom */
@@ -2189,31 +2189,31 @@ to { transform: rotate(360deg);
}
/* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-3f3d7a1a] {
.input-wrapper:hover .clear-button[data-v-55e3316e] {
opacity: 0.7;
pointer-events: auto;
}
.clear-button[data-v-3f3d7a1a]:hover {
.clear-button[data-v-55e3316e]:hover {
opacity: 1;
background: rgba(255, 100, 100, 0.8);
}
.clear-button svg[data-v-3f3d7a1a] {
.clear-button svg[data-v-55e3316e] {
width: 12px;
height: 12px;
}
/* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a] {
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e] {
right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px;
height: 20px;
background: rgba(107, 114, 128, 0.6);
}
.text-input.vue-dom-mode ~ .clear-button[data-v-3f3d7a1a]:hover {
.text-input.vue-dom-mode ~ .clear-button[data-v-55e3316e]:hover {
background: oklch(62% 0.18 25);
}
.text-input.vue-dom-mode ~ .clear-button svg[data-v-3f3d7a1a] {
.text-input.vue-dom-mode ~ .clear-button svg[data-v-55e3316e] {
width: 14px;
height: 14px;
}
@@ -2224,7 +2224,7 @@ to { transform: rotate(360deg);
resize: vertical !important;
}
.lora-info-widget[data-v-a99cc1ab] {
.lora-info-widget[data-v-d7692b6f] {
padding: 12px;
background: rgba(40, 44, 52, 0.6);
border-radius: 4px;
@@ -2240,45 +2240,45 @@ to { transform: rotate(360deg);
determined solely by CSS not by descendant content. This breaks the
feedback loop where content grows ResizeObserver resizes content
reflows repeat. Same technique used by tags_widget.js + lm_styles.css. */
.lora-info-widget.lm-vue-node[data-v-a99cc1ab] {
.lora-info-widget.lm-vue-node[data-v-d7692b6f] {
contain: layout size;
}
/* ── Tab bar ── */
.lora-info-tabs[data-v-a99cc1ab] {
.lora-info-tabs[data-v-d7692b6f] {
display: flex;
gap: 0;
margin-bottom: 10px;
border-bottom: 1px solid var(--border-color, #444);
flex-shrink: 0;
}
.lora-info-tab[data-v-a99cc1ab] {
.lora-info-tab[data-v-d7692b6f] {
flex: 1;
text-align: center;
cursor: pointer;
padding: 6px 0;
position: relative;
}
.lora-info-tab-input[data-v-a99cc1ab] {
.lora-info-tab-input[data-v-d7692b6f] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.lora-info-tab-label[data-v-a99cc1ab] {
.lora-info-tab-label[data-v-d7692b6f] {
font-size: 12px;
font-weight: 500;
color: var(--fg-color, #fff);
opacity: 0.5;
transition: opacity 0.15s;
}
.lora-info-tab:hover .lora-info-tab-label[data-v-a99cc1ab] {
.lora-info-tab:hover .lora-info-tab-label[data-v-d7692b6f] {
opacity: 0.75;
}
.lora-info-tab.active .lora-info-tab-label[data-v-a99cc1ab] {
.lora-info-tab.active .lora-info-tab-label[data-v-d7692b6f] {
opacity: 1;
}
.lora-info-tab.active[data-v-a99cc1ab]::after {
.lora-info-tab.active[data-v-d7692b6f]::after {
content: '';
position: absolute;
bottom: -1px;
@@ -2290,16 +2290,16 @@ to { transform: rotate(360deg);
}
/* ── Tab content ── */
.tab-content[data-v-a99cc1ab] {
.tab-content[data-v-d7692b6f] {
flex: 1;
min-height: 0;
overflow: hidden;
}
.notes-tab[data-v-a99cc1ab] {
.notes-tab[data-v-d7692b6f] {
display: flex;
flex-direction: column;
}
.description-tab[data-v-a99cc1ab] {
.description-tab[data-v-d7692b6f] {
display: flex;
flex-direction: column;
overflow-y: auto;
@@ -2307,12 +2307,12 @@ to { transform: rotate(360deg);
}
/* ── Info fields (shared) ── */
.info-field[data-v-a99cc1ab] {
.info-field[data-v-d7692b6f] {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label[data-v-a99cc1ab] {
.info-label[data-v-d7692b6f] {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
@@ -2320,7 +2320,7 @@ to { transform: rotate(360deg);
color: var(--fg-color, #fff);
opacity: 0.6;
}
.lora-filename[data-v-a99cc1ab] {
.lora-filename[data-v-d7692b6f] {
font-size: 13px;
font-weight: 500;
color: var(--fg-color, #fff);
@@ -2331,11 +2331,11 @@ to { transform: rotate(360deg);
user-select: text;
-webkit-user-select: text;
}
.notes-field[data-v-a99cc1ab] {
.notes-field[data-v-d7692b6f] {
flex: 1;
min-height: 0;
}
.lora-notes[data-v-a99cc1ab] {
.lora-notes[data-v-d7692b6f] {
width: 100%;
flex: 1;
min-height: 60px;
@@ -2350,14 +2350,14 @@ to { transform: rotate(360deg);
font-family: inherit;
outline: none;
}
.lora-notes[data-v-a99cc1ab]:focus {
.lora-notes[data-v-d7692b6f]:focus {
border-color: var(--comfy-input-border, #444);
}
.lora-notes[data-v-a99cc1ab]:disabled {
.lora-notes[data-v-d7692b6f]:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.save-btn[data-v-a99cc1ab] {
.save-btn[data-v-d7692b6f] {
width: 100%;
margin-top: 8px;
padding: 6px 12px;
@@ -2371,11 +2371,11 @@ to { transform: rotate(360deg);
box-sizing: border-box;
flex-shrink: 0;
}
.save-btn[data-v-a99cc1ab]:hover:not(:disabled) {
.save-btn[data-v-d7692b6f]:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.25);
border-color: rgba(66, 153, 225, 0.6);
}
.save-btn[data-v-a99cc1ab]:disabled {
.save-btn[data-v-d7692b6f]:disabled {
opacity: 0.4;
cursor: not-allowed;
background: rgba(66, 153, 225, 0.05);
@@ -2383,7 +2383,7 @@ to { transform: rotate(360deg);
}
/* ── Description states ── */
.description-state[data-v-a99cc1ab] {
.description-state[data-v-d7692b6f] {
display: flex;
align-items: center;
justify-content: center;
@@ -2395,22 +2395,22 @@ to { transform: rotate(360deg);
min-height: 0;
flex-shrink: 0;
}
.description-state.error[data-v-a99cc1ab] {
.description-state.error[data-v-d7692b6f] {
opacity: 0.7;
color: #f87171;
}
/* ── Description content ── */
.description-content[data-v-a99cc1ab] {
.description-content[data-v-d7692b6f] {
min-height: 0;
}
.description-section[data-v-a99cc1ab] {
.description-section[data-v-d7692b6f] {
margin-bottom: 14px;
}
.description-section[data-v-a99cc1ab]:last-child {
.description-section[data-v-d7692b6f]:last-child {
margin-bottom: 0;
}
.description-text[data-v-a99cc1ab] {
.description-text[data-v-d7692b6f] {
padding: 8px 0;
font-size: 12px;
line-height: 1.5;
@@ -2422,41 +2422,41 @@ to { transform: rotate(360deg);
user-select: text;
-webkit-user-select: text;
}
.description-text[data-v-a99cc1ab] p {
.description-text[data-v-d7692b6f] p {
margin: 0 0 8px 0;
}
.description-text[data-v-a99cc1ab] p:last-child {
.description-text[data-v-d7692b6f] p:last-child {
margin-bottom: 0;
}
.description-text[data-v-a99cc1ab] a {
.description-text[data-v-d7692b6f] a {
color: rgba(66, 153, 225, 0.9);
}
.description-text[data-v-a99cc1ab] ul,
.description-text[data-v-a99cc1ab] ol {
.description-text[data-v-d7692b6f] ul,
.description-text[data-v-d7692b6f] ol {
padding-left: 20px;
margin: 4px 0;
}
.description-text[data-v-a99cc1ab] h1,
.description-text[data-v-a99cc1ab] h2,
.description-text[data-v-a99cc1ab] h3 {
.description-text[data-v-d7692b6f] h1,
.description-text[data-v-d7692b6f] h2,
.description-text[data-v-d7692b6f] h3 {
font-size: 13px;
margin: 10px 0 4px 0;
font-weight: 600;
opacity: 0.95;
}
.description-text[data-v-a99cc1ab] code {
.description-text[data-v-d7692b6f] code {
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.description-text[data-v-a99cc1ab] img {
.description-text[data-v-d7692b6f] img {
max-width: 100%;
border-radius: 4px;
}
/* ── Placeholder (shared) ── */
.placeholder[data-v-a99cc1ab] {
.placeholder[data-v-d7692b6f] {
font-style: italic;
color: rgba(226, 232, 240, 0.5);
text-align: center;
@@ -2465,10 +2465,10 @@ to { transform: rotate(360deg);
}
/* ── Spinner (Font Awesome) ── */
.fa-spinner[data-v-a99cc1ab] {
animation: fa-spin-a99cc1ab 1s linear infinite;
.fa-spinner[data-v-d7692b6f] {
animation: fa-spin-d7692b6f 1s linear infinite;
}
@keyframes fa-spin-a99cc1ab {
@keyframes fa-spin-d7692b6f {
0% { transform: rotate(0deg);
}
100% { transform: rotate(360deg);
@@ -15316,7 +15316,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
};
}
});
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-3f3d7a1a"]]);
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-55e3316e"]]);
const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" };
@@ -15511,8 +15511,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
if (data.filePath !== void 0) filePath.value = data.filePath;
}
};
props.widget.options.getValue = buildValue;
props.widget.options.setValue = applyValue;
if (props.widget.options) {
props.widget.options.getValue = buildValue;
props.widget.options.setValue = applyValue;
} else {
console.warn("[LoraInfoWidget] widget.options missing, value persistence disabled");
}
props.widget.serializeValue = async () => buildValue();
props.widget.onSetValue = applyValue;
const widgetIndex = (_b = (_a2 = props.widget.node) == null ? void 0 : _a2.widgets) == null ? void 0 : _b.findIndex(
@@ -15641,7 +15645,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
};
}
});
const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a99cc1ab"]]);
const LoraInfoWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d7692b6f"]]);
function createVueWidgetCleanup(vueApp, onCleanup) {
let didUnmount = false;
return () => {
@@ -16637,7 +16641,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
applyAutocompleteTextLayoutFix(
widget,
container,
typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode
typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode === true
);
}
const vueCleanup = createVueWidgetCleanup(vueApp, () => {
@@ -16747,13 +16751,7 @@ app$1.registerExtension({
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
return createAutocompleteTextWidgetFactory(node, "text", "loras", options);
},
// Autocomplete text widget for embeddings (used by Prompt node)
// @ts-ignore
AUTOCOMPLETE_TEXT_EMBEDDINGS(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
return createAutocompleteTextWidgetFactory(node, "text", "embeddings", options);
},
// Autocomplete text widget for prompt (supports both embeddings and custom words)
// Autocomplete text widget for prompt (used by Prompt and Text nodes)
// @ts-ignore
AUTOCOMPLETE_TEXT_PROMPT(node) {
const options = widgetInputOptions.get(`${node.comfyClass}:text`) || {};
File diff suppressed because one or more lines are too long