Compare commits

..

37 Commits

Author SHA1 Message Date
Will Miao
2eef629821 fix(checkpoints): singleflight pending hash calculation 2026-04-23 11:36:32 +08:00
Will Miao
658a04736d fix(recipes): save widget checkpoint metadata as dict 2026-04-23 11:20:20 +08:00
Will Miao
ef7f677933 chore(skills): add lora manager runtime context 2026-04-23 09:42:47 +08:00
Will Miao
63f0942452 fix(models): classify Anima as diffusion model 2026-04-23 07:35:34 +08:00
Will Miao
a1dff6dd47 fix(download): auto fetch example images after model download 2026-04-21 22:48:06 +08:00
Will Miao
7fa40023b0 fix(trigger-words): edit tag on double click 2026-04-21 22:31:56 +08:00
Will Miao
3c8acdb65e fix(trigger-words): support stable inline editing 2026-04-21 22:18:35 +08:00
Will Miao
1e9a7812d6 fix(model-modal): allow resizing notes editor 2026-04-21 21:42:06 +08:00
Will Miao
37f0e8f213 fix(trigger-words): raise group word limit 2026-04-21 16:35:25 +08:00
Will Miao
ecf7ea21e4 fix(duplicates): clear stale hash mismatch state (#900) 2026-04-21 16:22:04 +08:00
Will Miao
79dd9a1b29 fix(trigger-word-toggle): compact group editing for #907 2026-04-21 10:44:05 +08:00
Will Miao
ef4923fd94 fix(settings): normalize default root path comparisons 2026-04-21 09:43:37 +08:00
Will Miao
1eeba666f5 fix(network): restore destination-scoped memory download guard 2026-04-20 18:27:38 +08:00
pixelpaws
89e26d9292 Merge pull request #906 from willmiao/codex/github-mention-fixnetwork-add-connectivityguard-to-short
fix(network): return friendly offline message for memory downloads
2026-04-20 16:07:06 +08:00
pixelpaws
fc19a145ff Merge branch 'main' into codex/github-mention-fixnetwork-add-connectivityguard-to-short 2026-04-20 15:54:30 +08:00
Will Miao
34f03d6495 fix(settings): preserve extra default roots in comfyui sync 2026-04-20 15:48:30 +08:00
pixelpaws
9443175abc fix(network): return friendly offline message for memory downloads 2026-04-20 15:42:03 +08:00
pixelpaws
dc5072628f Merge pull request #905 from willmiao/codex/task-title
fix(network): add ConnectivityGuard to short‑circuit offline requests and reduce log spam
2026-04-20 15:41:38 +08:00
pixelpaws
ff4b8ec849 test(network): align cooldown short-circuit test with per-host guard 2026-04-20 15:30:50 +08:00
pixelpaws
7ab271c752 fix(network): scope connectivity cooldown by destination 2026-04-20 15:20:57 +08:00
pixelpaws
5a7f4dc88b fix(network): add offline cooldown guard for remote metadata requests 2026-04-20 15:04:04 +08:00
Will Miao
761108bfd1 fix(download): restore aria2 resume lifecycle 2026-04-20 09:52:48 +08:00
Will Miao
24dd3a777c fix(settings): align modal form control widths 2026-04-19 21:59:33 +08:00
Will Miao
1c530ea013 feat(download): add experimental aria2 backend 2026-04-19 21:46:09 +08:00
mudknight
0ced53c059 Use flex gap for header spacing (#901)
* Use flex gap for header spacing

* Remove extra margin
2026-04-18 19:33:39 +08:00
Will Miao
67ad68a23f fix(filters): apply preset base models from full list 2026-04-18 07:00:24 +08:00
pixelpaws
d9ec9c512e Merge pull request #899 from Phinease/fix/resumable-download-retries
fix: preserve resumable downloads across retries
2026-04-17 20:46:22 +08:00
Will Miao
0bcd8e09a9 fix(filters): improve base model filtering UX 2026-04-17 20:27:48 +08:00
Shuangrui CHEN
fa049a28c8 fix: preserve resumable downloads across retries 2026-04-17 03:35:41 +08:00
Will Miao
89fd2b43d6 chore(release): bump version to v1.0.5 and add release notes 2026-04-16 21:52:34 +08:00
Will Miao
c53f44e7ef feat(excluded-models): add excluded management view 2026-04-16 21:40:59 +08:00
Will Miao
ae7bfdb517 fix(download): normalize civitai.red download URLs (#898) 2026-04-16 18:25:16 +08:00
Will Miao
68bf8442eb chore(release): bump version to v1.0.4 and add release notes 2026-04-16 14:26:28 +08:00
Will Miao
605fbf4117 feat(civitai): add host preference for view links 2026-04-16 13:28:51 +08:00
Will Miao
406d5fea6a fix(civitai): use red-only api host (#897) 2026-04-16 12:08:07 +08:00
Will Miao
af2146f96c fix(civitai): fallback image info hosts on request failure 2026-04-16 09:29:03 +08:00
Will Miao
bdc8dec860 fix(civitai): support civitai.red URLs (#897) 2026-04-16 08:54:12 +08:00
115 changed files with 11092 additions and 990 deletions

View File

@@ -0,0 +1,69 @@
---
name: lora-manager-runtime-context
description: Inspect ComfyUI LoRA Manager runtime configuration and local diagnostic state. Use when debugging LoRA Manager issues that require locating or reading settings.json, active library paths, model metadata JSON sidecars, recipe metadata JSON files, example image folders, SQLite caches, symlink maps, download history, aria2 state, or other cache files under the LoRA Manager user config directory.
---
# LoRA Manager Runtime Context
## Core Rules
- Treat runtime state as local user data. Prefer read-only inspection unless the user explicitly asks for mutation.
- Never print secret-like settings values. Redact keys containing `key`, `token`, `secret`, `password`, `auth`, or `credential`, including `civitai_api_key`.
- Resolve paths from the runtime configuration before guessing. In this environment the settings file is normally `/home/miao/.config/ComfyUI-LoRA-Manager/settings.json`, but portable settings can override this through the repository `settings.json`.
- Use the active library when selecting per-library caches and paths. Read `active_library` from settings; fall back to `default` if missing.
- Normalize and expand `~` before comparing paths. Symlinks are common in this repo.
## Quick Start
Use the bundled helper for a safe first pass:
```bash
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py summary
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py caches
```
The script redacts sensitive settings, opens SQLite databases read-only, and reports inaccessible or locked databases as warnings.
For focused checks:
```bash
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py recipes
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py model --path /path/to/model.safetensors
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py sqlite --db /path/to/cache.sqlite --limit 3
```
## Runtime Path Rules
- Settings directory: use `py/utils/settings_paths.py`. Default platform path is `platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`.
- Settings file: `<settings_dir>/settings.json`.
- Cache root: `<settings_dir>/cache`.
- Canonical cache files:
- Model cache: `cache/model/<active_library>.sqlite`.
- Recipe cache: `cache/recipe/<active_library>.sqlite`.
- Model update cache: `cache/model_update/<active_library>.sqlite`.
- Recipe FTS: `cache/fts/recipe_fts.sqlite`.
- Tag FTS: `cache/fts/tag_fts.sqlite`.
- Symlink map: `cache/symlink/symlink_map.json`.
- Download history: `cache/download_history/downloaded_versions.sqlite`.
- aria2 state: `cache/aria2/downloads.json`.
- Legacy cache locations may exist; prefer canonical paths unless diagnosing migrations.
## Data Location Rules
- Model roots come from `settings.folder_paths` and the active library payload under `settings.libraries[active_library]`.
- Model metadata JSON sidecars live next to the model file as `<model basename>.metadata.json`.
- Recipes root is `settings.recipes_path` when it is a non-empty string. If empty, use the first configured LoRA root plus `/recipes`.
- Recipe JSON files are named `*.recipe.json` under the recipes root and may be nested in folders.
- Example image root is `settings.example_images_path`.
- If multiple libraries are configured, example images are stored under `<example_images_path>/<sanitized_library>/<sha256>/`; otherwise they are under `<example_images_path>/<sha256>/`.
## Useful Cache Tables
- Model cache: `models`, `model_tags`, `hash_index`, `excluded_models`.
- Recipe cache: `recipes`, `cache_metadata`.
- Model update cache: `model_update_status`, `model_update_versions`.
- Tag FTS cache: `tags`, `fts_metadata`, plus FTS internal tables.
- Recipe FTS cache: `recipe_rowid`, `fts_metadata`, plus FTS internal tables.
- Download history: `downloaded_model_versions`.
Prefer querying only counts, schema, and a few sample rows unless the user asks for full output.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "LoRA Manager Runtime Context"
short_description: "Inspect LoRA Manager runtime state"
default_prompt: "Use $lora-manager-runtime-context to inspect LoRA Manager settings, metadata paths, and caches for debugging."

View File

@@ -0,0 +1,381 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import sqlite3
import sys
import tempfile
from pathlib import Path
from typing import Any
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
APP_NAME = "ComfyUI-LoRA-Manager"
CACHE_SQLITE = {
"model": ("model", "{library}.sqlite"),
"recipe": ("recipe", "{library}.sqlite"),
"model_update": ("model_update", "{library}.sqlite"),
"recipe_fts": ("fts", "recipe_fts.sqlite"),
"tag_fts": ("fts", "tag_fts.sqlite"),
"download_history": ("download_history", "downloaded_versions.sqlite"),
}
CACHE_JSON = {
"symlink": ("symlink", "symlink_map.json"),
"aria2": ("aria2", "downloads.json"),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Inspect LoRA Manager runtime state read-only.")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("summary", help="Print redacted settings and resolved paths.")
subparsers.add_parser("caches", help="Print cache paths and SQLite table summaries.")
subparsers.add_parser("recipes", help="Print resolved recipes root and recipe JSON count.")
model_parser = subparsers.add_parser("model", help="Inspect a model metadata sidecar path.")
model_parser.add_argument("--path", required=True, help="Path to a model file or metadata JSON file.")
sqlite_parser = subparsers.add_parser("sqlite", help="Inspect a SQLite database read-only.")
sqlite_parser.add_argument("--db", required=True, help="Path to the SQLite database.")
sqlite_parser.add_argument("--limit", type=int, default=3, help="Rows to sample from each user table.")
args = parser.parse_args()
context = build_context()
if args.command == "summary":
print_json(summary_payload(context))
elif args.command == "caches":
print_json(caches_payload(context))
elif args.command == "recipes":
print_json(recipes_payload(context))
elif args.command == "model":
print_json(model_payload(args.path))
elif args.command == "sqlite":
print_json(sqlite_payload(Path(args.db).expanduser(), args.limit))
return 0
def build_context() -> dict[str, Any]:
settings_path = resolve_settings_path()
settings = load_json(settings_path)
settings_dir = settings_path.parent
active_library = settings.get("active_library") or "default"
safe_library = sanitize_library_name(str(active_library))
cache_root = settings_dir / "cache"
return {
"settings_path": str(settings_path),
"settings_dir": str(settings_dir),
"settings": settings,
"active_library": active_library,
"safe_library": safe_library,
"cache_root": str(cache_root),
"cache_paths": resolve_cache_paths(cache_root, safe_library),
}
def resolve_settings_path() -> Path:
repo_root = find_repo_root()
portable = repo_root / "settings.json"
if portable.exists():
payload = load_json(portable)
if isinstance(payload, dict) and payload.get("use_portable_settings") is True:
return portable
config_home = os.environ.get("XDG_CONFIG_HOME")
if config_home:
return Path(config_home).expanduser() / APP_NAME / "settings.json"
return Path.home() / ".config" / APP_NAME / "settings.json"
def find_repo_root() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "py").is_dir() and (parent / "standalone.py").exists():
return parent
return Path.cwd()
def load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
return {"_error": f"invalid JSON: {exc}"}
except OSError as exc:
return {"_error": f"unreadable: {exc}"}
return payload if isinstance(payload, dict) else {"_error": "JSON root is not an object"}
def resolve_cache_paths(cache_root: Path, library: str) -> dict[str, str]:
paths: dict[str, str] = {}
for name, (subdir, filename) in CACHE_SQLITE.items():
paths[name] = str(cache_root / subdir / filename.format(library=library))
for name, (subdir, filename) in CACHE_JSON.items():
paths[name] = str(cache_root / subdir / filename)
return paths
def summary_payload(context: dict[str, Any]) -> dict[str, Any]:
settings = context["settings"]
return {
"settings_path": context["settings_path"],
"settings_dir": context["settings_dir"],
"active_library": context["active_library"],
"settings": redact(settings),
"model_roots": model_roots(settings, context["active_library"]),
"recipes_root": str(resolve_recipes_root(settings, context["active_library"]) or ""),
"example_images": example_images_payload(settings, context["active_library"]),
"cache_root": context["cache_root"],
"cache_paths": context["cache_paths"],
}
def caches_payload(context: dict[str, Any]) -> dict[str, Any]:
caches: dict[str, Any] = {}
for name, path_string in context["cache_paths"].items():
path = Path(path_string)
item: dict[str, Any] = {
"path": str(path),
"exists": path.exists(),
"size": path.stat().st_size if path.exists() else None,
}
if path.suffix == ".sqlite":
item["sqlite"] = sqlite_payload(path, limit=0)
elif path.suffix == ".json":
item["json"] = json_file_summary(path)
caches[name] = item
return {"active_library": context["active_library"], "caches": caches}
def recipes_payload(context: dict[str, Any]) -> dict[str, Any]:
root = resolve_recipes_root(context["settings"], context["active_library"])
files: list[str] = []
if root and root.exists():
files = [str(path) for path in sorted(root.rglob("*.recipe.json"))[:20]]
return {
"recipes_root": str(root or ""),
"exists": bool(root and root.exists()),
"recipe_json_count": count_recipe_files(root),
"sample_recipe_json": files,
"recipe_cache": context["cache_paths"].get("recipe"),
}
def model_payload(raw_path: str) -> dict[str, Any]:
path = Path(raw_path).expanduser()
metadata_path = path if path.name.endswith(".metadata.json") else path.with_suffix(".metadata.json")
payload = {
"input_path": str(path),
"metadata_path": str(metadata_path),
"model_exists": path.exists(),
"metadata_exists": metadata_path.exists(),
}
if metadata_path.exists():
data = load_json(metadata_path)
payload["metadata_summary"] = redact(summarize_value(data))
return payload
def sqlite_payload(path: Path, limit: int = 3, allow_copy: bool = True) -> dict[str, Any]:
result: dict[str, Any] = {"path": str(path), "exists": path.exists(), "tables": {}}
if not path.exists():
return result
try:
conn = connect_sqlite_readonly(path)
except sqlite3.Error as exc:
result["error"] = str(exc)
return result
try:
table_rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).fetchall()
for table_row in table_rows:
table = table_row["name"]
columns = [
row["name"]
for row in conn.execute(f"PRAGMA table_info({quote_identifier(table)})").fetchall()
]
table_info: dict[str, Any] = {"columns": columns}
try:
table_info["count"] = conn.execute(
f"SELECT COUNT(*) FROM {quote_identifier(table)}"
).fetchone()[0]
except sqlite3.Error as exc:
table_info["count_error"] = str(exc)
if limit > 0 and columns and not is_internal_sqlite_table(table):
try:
rows = conn.execute(
f"SELECT * FROM {quote_identifier(table)} LIMIT ?", (limit,)
).fetchall()
table_info["sample"] = [redact(dict(row)) for row in rows]
except sqlite3.Error as exc:
table_info["sample_error"] = str(exc)
result["tables"][table] = table_info
except sqlite3.Error as exc:
fallback = sqlite_copy_payload(path, limit, str(exc)) if allow_copy else None
if fallback is not None:
result.update(fallback)
else:
result["error"] = str(exc)
finally:
conn.close()
return result
def connect_sqlite_readonly(path: Path) -> sqlite3.Connection:
errors: list[str] = []
for query in ("mode=ro", "mode=ro&immutable=1"):
try:
conn = sqlite3.connect(f"file:{path}?{query}", uri=True)
conn.row_factory = sqlite3.Row
return conn
except sqlite3.Error as exc:
errors.append(f"{query}: {exc}")
raise sqlite3.OperationalError("; ".join(errors))
def sqlite_copy_payload(path: Path, limit: int, original_error: str) -> dict[str, Any] | None:
try:
with tempfile.TemporaryDirectory(prefix="lm-cache-inspect-") as temp_dir:
copy_path = Path(temp_dir) / path.name
shutil.copy2(path, copy_path)
payload = sqlite_payload(copy_path, limit, allow_copy=False)
payload["path"] = str(path)
payload["inspected_copy"] = True
payload["original_error"] = original_error
return payload
except Exception:
return None
def json_file_summary(path: Path) -> dict[str, Any]:
if not path.exists():
return {"exists": False}
data = load_json(path)
return {"exists": True, "summary": redact(summarize_value(data))}
def model_roots(settings: dict[str, Any], active_library: str) -> dict[str, list[str]]:
roots: dict[str, list[str]] = {}
sources = [settings]
library = settings.get("libraries", {}).get(active_library)
if isinstance(library, dict):
sources.insert(0, library)
for source in sources:
folder_paths = source.get("folder_paths")
if isinstance(folder_paths, dict):
for key, value in folder_paths.items():
roots.setdefault(key, []).extend(normalize_path_list(value))
for default_key, folder_key in (
("default_lora_root", "loras"),
("default_checkpoint_root", "checkpoints"),
("default_embedding_root", "embeddings"),
("default_unet_root", "unet"),
):
value = settings.get(default_key)
if isinstance(value, str) and value:
roots.setdefault(folder_key, []).append(expand_path(value))
return {key: dedupe(values) for key, values in roots.items()}
def resolve_recipes_root(settings: dict[str, Any], active_library: str) -> Path | None:
recipes_path = settings.get("recipes_path")
library = settings.get("libraries", {}).get(active_library)
if isinstance(library, dict) and isinstance(library.get("recipes_path"), str):
recipes_path = library["recipes_path"] or recipes_path
if isinstance(recipes_path, str) and recipes_path.strip():
return Path(expand_path(recipes_path.strip()))
lora_roots = model_roots(settings, active_library).get("loras") or []
return Path(lora_roots[0]) / "recipes" if lora_roots else None
def example_images_payload(settings: dict[str, Any], active_library: str) -> dict[str, Any]:
root = settings.get("example_images_path") or ""
libraries = settings.get("libraries")
library_count = len(libraries) if isinstance(libraries, dict) else 0
scoped = library_count > 1
root_path = Path(expand_path(root)) if isinstance(root, str) and root else None
library_root = root_path / sanitize_library_name(active_library) if root_path and scoped else root_path
return {
"root": str(root_path or ""),
"uses_library_scoped_folders": scoped,
"library_root": str(library_root or ""),
}
def count_recipe_files(root: Path | None) -> int:
if not root or not root.exists():
return 0
return sum(1 for _ in root.rglob("*.recipe.json"))
def normalize_path_list(value: Any) -> list[str]:
if isinstance(value, str):
return [expand_path(value)] if value else []
if isinstance(value, list):
return [expand_path(item) for item in value if isinstance(item, str) and item]
return []
def expand_path(value: str) -> str:
return str(Path(value).expanduser().resolve(strict=False))
def sanitize_library_name(name: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "default")
return safe or "default"
def dedupe(values: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value not in seen:
result.append(value)
seen.add(value)
return result
def redact(value: Any, key: str = "") -> Any:
if key and SECRET_PATTERN.search(key):
return "<redacted>"
if isinstance(value, dict):
return {str(k): redact(v, str(k)) for k, v in value.items()}
if isinstance(value, list):
return [redact(item) for item in value]
return value
def summarize_value(value: Any) -> Any:
if isinstance(value, dict):
return {key: summarize_value(item) for key, item in value.items()}
if isinstance(value, list):
return {
"type": "array",
"length": len(value),
"first": summarize_value(value[0]) if value else None,
}
return value
def quote_identifier(identifier: str) -> str:
return '"' + identifier.replace('"', '""') + '"'
def is_internal_sqlite_table(table: str) -> bool:
return table.startswith("sqlite_") or table.endswith(("_data", "_idx", "_docsize", "_config", "_content"))
def print_json(payload: Any) -> None:
json.dump(payload, sys.stdout, indent=2, ensure_ascii=False)
sys.stdout.write("\n")
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -56,6 +56,18 @@ Insomnia Art Designs, megakirbs, Brennok, 2018cfh, W+K+White, wackop, Takkan, Ca
## Release Notes
### v1.0.5
* **Excluded Models Management View** - Added a new global-menu view for excluded models, with actions to restore them or delete them permanently.
* **Fix for `401 Unauthorized` Downloads** - Fixed an issue where some `civitai.red` downloads could lose authentication during redirect and fail with `401 Unauthorized`.
### v1.0.4
* **Civitai Domain Split Support** - Added support for `civitai.com` and `civitai.red` model URLs and recipe/image URLs across import, analysis, and download flows.
* **Civitai API Host Migration** - Updated core Civitai API requests to use `civitai.red` for compatibility with Civitai's current API host.
* **Configurable Civitai View Host** - Added a setting to choose which Civitai site opens by default for model, search, and view links.
* **401 Unauthorized Reminder** - Some users have reported `401 Unauthorized` errors. If you run into this, try generating a new API key on `civitai.red` and updating it in LoRA Manager settings.
### v1.0.3
* **Custom Recipe Storage Path** - Added support for configuring a custom storage path for recipes, with migration support to move existing recipe data when changing locations.

View File

@@ -175,6 +175,9 @@
"success": "{count} Rezepte erfolgreich repariert.",
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
},
"manageExcludedModels": {
"label": "Ausgeschlossene Modelle verwalten"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?",
"presetNamePlaceholder": "Voreinstellungsname...",
"baseModel": "Basis-Modell",
"baseModelSearchPlaceholder": "Basismodelle durchsuchen...",
"modelTags": "Tags (Top 20)",
"modelTypes": "Modelltypen",
"license": "Lizenz",
"noCreditRequired": "Kein Credit erforderlich",
"allowSellingGeneratedContent": "Verkauf erlaubt",
"noTags": "Keine Tags",
"noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.",
"clearAll": "Alle Filter löschen",
"any": "Beliebig",
"all": "Alle",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Geben Sie Ihren Civitai API Key ein",
"civitaiApiKeyHelp": "Wird für die Authentifizierung beim Herunterladen von Modellen von Civitai verwendet",
"civitaiHost": {
"label": "Civitai-Host",
"help": "Wählen Sie aus, welche Civitai-Seite geöffnet wird, wenn Sie „View on Civitai“-Links verwenden.",
"options": {
"com": "civitai.com (nur SFW)",
"red": "civitai.red (uneingeschränkt)"
}
},
"downloadBackend": {
"label": "Download-Backend",
"help": "Wähle aus, wie Modelldateien heruntergeladen werden. Python verwendet den eingebauten Downloader. aria2 verwendet den experimentellen externen Downloader-Prozess.",
"options": {
"python": "Python (integriert)",
"aria2": "aria2 (experimentell)"
}
},
"aria2cPath": {
"label": "aria2c-Pfad",
"help": "Optionaler Pfad zur ausführbaren aria2c-Datei. Leer lassen, um aria2c aus dem System-PATH zu verwenden.",
"placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
},
"civitaiHostBanner": {
"title": "Civitai-Host-Einstellung verfügbar",
"content": "Civitai verwendet jetzt civitai.com für SFW-Inhalte und civitai.red für uneingeschränkte Inhalte. In den Einstellungen können Sie ändern, welche Seite standardmäßig geöffnet wird.",
"openSettings": "Einstellungen öffnen"
},
"openSettingsFileLocation": {
"label": "Einstellungsordner öffnen",
"tooltip": "Den Ordner mit der settings.json öffnen",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "Inhaltsfilterung",
"downloads": "Downloads",
"videoSettings": "Video-Einstellungen",
"layoutSettings": "Layout-Einstellungen",
"misc": "Verschiedenes",
@@ -667,6 +699,7 @@
"moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren",
"excludeModel": "Modell ausschließen",
"restoreModel": "Modell wiederherstellen",
"deleteModel": "Modell löschen",
"shareRecipe": "Rezept teilen",
"viewAllLoras": "Alle LoRAs anzeigen",
@@ -1157,6 +1190,8 @@
"cancel": "Bearbeitung abbrechen",
"save": "Änderungen speichern",
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
"editWord": "Trigger Word bearbeiten",
"editPlaceholder": "Trigger Word bearbeiten",
"copyWord": "Trigger Word kopieren",
"deleteWord": "Trigger Word löschen",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "Konnte trainierte Wörter nicht laden",
"tooLong": "Trigger Word sollte 100 Wörter nicht überschreiten",
"tooMany": "Maximal 30 Trigger Words erlaubt",
"tooLong": "Trigger Word sollte 500 Wörter nicht überschreiten",
"tooMany": "Maximal 100 Trigger Words erlaubt",
"alreadyExists": "Dieses Trigger Word existiert bereits",
"updateSuccess": "Trigger Words erfolgreich aktualisiert",
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
@@ -1790,6 +1825,8 @@
"deleteFailed": "Fehler beim Löschen von {type}: {message}",
"excludeSuccess": "{type} erfolgreich ausgeschlossen",
"excludeFailed": "Fehler beim Ausschließen von {type}: {message}",
"restoreSuccess": "{type} erfolgreich wiederhergestellt",
"restoreFailed": "{type} konnte nicht wiederhergestellt werden: {message}",
"fileNameUpdated": "Dateiname erfolgreich aktualisiert",
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
"previewUpdated": "Vorschau erfolgreich aktualisiert",

View File

@@ -175,6 +175,9 @@
"success": "Successfully repaired {count} recipes.",
"cancelled": "Repair cancelled. {count} recipes were repaired.",
"error": "Recipe repair failed: {message}"
},
"manageExcludedModels": {
"label": "Manage Excluded Models"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?",
"presetNamePlaceholder": "Preset name...",
"baseModel": "Base Model",
"baseModelSearchPlaceholder": "Search base models...",
"modelTags": "Tags (Top 20)",
"modelTypes": "Model Types",
"license": "License",
"noCreditRequired": "No Credit Required",
"allowSellingGeneratedContent": "Allow Selling",
"noTags": "No tags",
"noBaseModelMatches": "No base models match the current search.",
"clearAll": "Clear All Filters",
"any": "Any",
"all": "All",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiHost": {
"label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.",
"options": {
"com": "civitai.com (SFW)",
"red": "civitai.red (unrestricted)"
}
},
"downloadBackend": {
"label": "Download backend",
"help": "Choose how model files are downloaded. Python uses the built-in downloader. aria2 uses the experimental external downloader process.",
"options": {
"python": "Python (built-in)",
"aria2": "aria2 (experimental)"
}
},
"aria2cPath": {
"label": "aria2c path",
"help": "Optional path to the aria2c executable. Leave empty to use aria2c from your system PATH.",
"placeholder": "Leave empty to use aria2c from PATH"
},
"civitaiHostBanner": {
"title": "Civitai host preference available",
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
"openSettings": "Open Settings"
},
"openSettingsFileLocation": {
"label": "Open settings folder",
"tooltip": "Open folder containing settings.json",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "Content Filtering",
"downloads": "Downloads",
"videoSettings": "Video Settings",
"layoutSettings": "Layout Settings",
"misc": "Miscellaneous",
@@ -667,6 +699,7 @@
"moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata",
"excludeModel": "Exclude Model",
"restoreModel": "Restore Model",
"deleteModel": "Delete Model",
"shareRecipe": "Share Recipe",
"viewAllLoras": "View All LoRAs",
@@ -685,9 +718,9 @@
"title": "Import a recipe from image or URL",
"urlLocalPath": "URL / Local Path",
"uploadImage": "Upload Image",
"urlSectionDescription": "Input a Civitai image URL or local file path to import as a recipe.",
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
"imageUrlOrPath": "Image URL or File Path:",
"urlPlaceholder": "https://civitai.com/images/... or C:/path/to/image.png",
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
"fetchImage": "Fetch Image",
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
"selectImage": "Select Image",
@@ -1090,9 +1123,9 @@
},
"proceedText": "Only proceed if you're sure this is what you want.",
"urlLabel": "Civitai Model URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": {
"title": "Paste any Civitai model URL. Supported formats:",
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1157,6 +1190,8 @@
"cancel": "Cancel editing",
"save": "Save changes",
"addPlaceholder": "Type to add or click suggestions below",
"editWord": "Edit trigger word",
"editPlaceholder": "Edit trigger word",
"copyWord": "Copy trigger word",
"deleteWord": "Delete trigger word",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "Could not load trained words",
"tooLong": "Trigger word should not exceed 100 words",
"tooMany": "Maximum 30 trigger words allowed",
"tooLong": "Trigger word should not exceed 500 words",
"tooMany": "Maximum 100 trigger words allowed",
"alreadyExists": "This trigger word already exists",
"updateSuccess": "Trigger words updated successfully",
"updateFailed": "Failed to update trigger words",
@@ -1790,6 +1825,8 @@
"deleteFailed": "Failed to delete {type}: {message}",
"excludeSuccess": "{type} excluded successfully",
"excludeFailed": "Failed to exclude {type}: {message}",
"restoreSuccess": "{type} restored successfully",
"restoreFailed": "Failed to restore {type}: {message}",
"fileNameUpdated": "File name updated successfully",
"fileRenameFailed": "Failed to rename file: {error}",
"previewUpdated": "Preview updated successfully",

View File

@@ -175,6 +175,9 @@
"success": "Se repararon con éxito {count} recetas.",
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
"error": "Error al reparar recetas: {message}"
},
"manageExcludedModels": {
"label": "Gestionar modelos excluidos"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?",
"presetNamePlaceholder": "Nombre del preajuste...",
"baseModel": "Modelo base",
"baseModelSearchPlaceholder": "Buscar modelos base...",
"modelTags": "Etiquetas (Top 20)",
"modelTypes": "Tipos de modelos",
"license": "Licencia",
"noCreditRequired": "Sin crédito requerido",
"allowSellingGeneratedContent": "Venta permitida",
"noTags": "Sin etiquetas",
"noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.",
"clearAll": "Limpiar todos los filtros",
"any": "Cualquiera",
"all": "Todos",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Clave API de Civitai",
"civitaiApiKeyPlaceholder": "Introduce tu clave API de Civitai",
"civitaiApiKeyHelp": "Utilizada para autenticación al descargar modelos de Civitai",
"civitaiHost": {
"label": "Host de Civitai",
"help": "Elige qué sitio de Civitai se abre al usar los enlaces de \"View on Civitai\".",
"options": {
"com": "civitai.com (solo SFW)",
"red": "civitai.red (sin restricciones)"
}
},
"downloadBackend": {
"label": "Backend de descarga",
"help": "Elige cómo se descargan los archivos del modelo. Python usa el descargador integrado. aria2 usa el proceso externo experimental de descarga.",
"options": {
"python": "Python (integrado)",
"aria2": "aria2 (experimental)"
}
},
"aria2cPath": {
"label": "Ruta de aria2c",
"help": "Ruta opcional al ejecutable aria2c. Déjalo vacío para usar aria2c desde el PATH del sistema.",
"placeholder": "Déjalo vacío para usar aria2c desde el PATH"
},
"civitaiHostBanner": {
"title": "Preferencia de host de Civitai disponible",
"content": "Civitai ahora usa civitai.com para contenido SFW y civitai.red para contenido sin restricciones. Puedes cambiar en Ajustes qué sitio se abre por defecto.",
"openSettings": "Abrir ajustes"
},
"openSettingsFileLocation": {
"label": "Abrir carpeta de ajustes",
"tooltip": "Abrir la carpeta que contiene settings.json",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "Filtrado de contenido",
"downloads": "Descargas",
"videoSettings": "Configuración de video",
"layoutSettings": "Configuración de diseño",
"misc": "Varios",
@@ -667,6 +699,7 @@
"moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos",
"excludeModel": "Excluir modelo",
"restoreModel": "Restaurar modelo",
"deleteModel": "Eliminar modelo",
"shareRecipe": "Compartir receta",
"viewAllLoras": "Ver todos los LoRAs",
@@ -1157,6 +1190,8 @@
"cancel": "Cancelar edición",
"save": "Guardar cambios",
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
"editWord": "Editar palabra de activación",
"editPlaceholder": "Editar palabra de activación",
"copyWord": "Copiar palabra clave",
"deleteWord": "Eliminar palabra clave",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "No se pudieron cargar palabras entrenadas",
"tooLong": "La palabra clave no debe exceder 100 palabras",
"tooMany": "Máximo 30 palabras clave permitidas",
"tooLong": "La palabra clave no debe exceder 500 palabras",
"tooMany": "Máximo 100 palabras clave permitidas",
"alreadyExists": "Esta palabra clave ya existe",
"updateSuccess": "Palabras clave actualizadas exitosamente",
"updateFailed": "Error al actualizar palabras clave",
@@ -1790,6 +1825,8 @@
"deleteFailed": "Error al eliminar {type}: {message}",
"excludeSuccess": "{type} excluido exitosamente",
"excludeFailed": "Error al excluir {type}: {message}",
"restoreSuccess": "{type} restaurado correctamente",
"restoreFailed": "No se pudo restaurar {type}: {message}",
"fileNameUpdated": "Nombre de archivo actualizado exitosamente",
"fileRenameFailed": "Error al renombrar archivo: {error}",
"previewUpdated": "Vista previa actualizada exitosamente",

View File

@@ -175,6 +175,9 @@
"success": "{count} recettes réparées avec succès.",
"cancelled": "Réparation annulée. {count} recettes ont été réparées.",
"error": "Échec de la réparation des recettes : {message}"
},
"manageExcludedModels": {
"label": "Gérer les modèles exclus"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?",
"presetNamePlaceholder": "Nom du préréglage...",
"baseModel": "Modèle de base",
"baseModelSearchPlaceholder": "Rechercher des modèles de base...",
"modelTags": "Tags (Top 20)",
"modelTypes": "Types de modèles",
"license": "Licence",
"noCreditRequired": "Crédit non requis",
"allowSellingGeneratedContent": "Vente autorisée",
"noTags": "Aucun tag",
"noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.",
"clearAll": "Effacer tous les filtres",
"any": "N'importe quel",
"all": "Tous",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Clé API Civitai",
"civitaiApiKeyPlaceholder": "Entrez votre clé API Civitai",
"civitaiApiKeyHelp": "Utilisée pour l'authentification lors du téléchargement de modèles depuis Civitai",
"civitaiHost": {
"label": "Hôte Civitai",
"help": "Choisissez quel site Civitai s'ouvre lorsque vous utilisez les liens « View on Civitai ».",
"options": {
"com": "civitai.com (SFW uniquement)",
"red": "civitai.red (sans restriction)"
}
},
"downloadBackend": {
"label": "Moteur de téléchargement",
"help": "Choisissez comment les fichiers de modèles sont téléchargés. Python utilise le téléchargeur intégré. aria2 utilise le processus externe expérimental de téléchargement.",
"options": {
"python": "Python (intégré)",
"aria2": "aria2 (expérimental)"
}
},
"aria2cPath": {
"label": "Chemin vers aria2c",
"help": "Chemin facultatif vers lexécutable aria2c. Laissez vide pour utiliser aria2c depuis le PATH système.",
"placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
},
"civitaiHostBanner": {
"title": "Préférence dhôte Civitai disponible",
"content": "Civitai utilise désormais civitai.com pour le contenu SFW et civitai.red pour le contenu sans restriction. Vous pouvez modifier dans les paramètres le site ouvert par défaut.",
"openSettings": "Ouvrir les paramètres"
},
"openSettingsFileLocation": {
"label": "Ouvrir le dossier des paramètres",
"tooltip": "Ouvrir le dossier contenant settings.json",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "Filtrage du contenu",
"downloads": "Téléchargements",
"videoSettings": "Paramètres vidéo",
"layoutSettings": "Paramètres d'affichage",
"misc": "Divers",
@@ -667,6 +699,7 @@
"moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées",
"excludeModel": "Exclure le modèle",
"restoreModel": "Restaurer le modèle",
"deleteModel": "Supprimer le modèle",
"shareRecipe": "Partager la recipe",
"viewAllLoras": "Voir tous les LoRAs",
@@ -1157,6 +1190,8 @@
"cancel": "Annuler la modification",
"save": "Sauvegarder les modifications",
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
"editWord": "Modifier le mot déclencheur",
"editPlaceholder": "Modifier le mot déclencheur",
"copyWord": "Copier le mot-clé",
"deleteWord": "Supprimer le mot-clé",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "Impossible de charger les mots entraînés",
"tooLong": "Le mot-clé ne doit pas dépasser 100 mots",
"tooMany": "Maximum 30 mots-clés autorisés",
"tooLong": "Le mot-clé ne doit pas dépasser 500 mots",
"tooMany": "Maximum 100 mots-clés autorisés",
"alreadyExists": "Ce mot-clé existe déjà",
"updateSuccess": "Mots-clés mis à jour avec succès",
"updateFailed": "Échec de la mise à jour des mots-clés",
@@ -1790,6 +1825,8 @@
"deleteFailed": "Échec de la suppression de {type} : {message}",
"excludeSuccess": "{type} exclu avec succès",
"excludeFailed": "Échec de l'exclusion de {type} : {message}",
"restoreSuccess": "{type} restauré avec succès",
"restoreFailed": "Échec de la restauration de {type} : {message}",
"fileNameUpdated": "Nom de fichier mis à jour avec succès",
"fileRenameFailed": "Échec du renommage du fichier : {error}",
"previewUpdated": "Aperçu mis à jour avec succès",

View File

@@ -175,6 +175,9 @@
"success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}"
},
"manageExcludedModels": {
"label": "ניהול מודלים מוחרגים"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?",
"presetNamePlaceholder": "שם קביעה מראש...",
"baseModel": "מודל בסיס",
"baseModelSearchPlaceholder": "חפש מודלי בסיס...",
"modelTags": "תגיות (20 המובילות)",
"modelTypes": "סוגי מודלים",
"license": "רישיון",
"noCreditRequired": "ללא קרדיט נדרש",
"allowSellingGeneratedContent": "אפשר מכירה",
"noTags": "ללא תגיות",
"noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.",
"clearAll": "נקה את כל המסננים",
"any": "כלשהו",
"all": "כל התגים",
@@ -250,6 +255,32 @@
"civitaiApiKey": "מפתח API של Civitai",
"civitaiApiKeyPlaceholder": "הזן את מפתח ה-API שלך מ-Civitai",
"civitaiApiKeyHelp": "משמש לאימות בעת הורדת מודלים מ-Civitai",
"civitaiHost": {
"label": "מארח Civitai",
"help": "בחר איזה אתר של Civitai ייפתח בעת שימוש בקישורי \"View on Civitai\".",
"options": {
"com": "civitai.com (SFW בלבד)",
"red": "civitai.red (ללא הגבלות)"
}
},
"downloadBackend": {
"label": "מנגנון הורדה",
"help": "בחר כיצד יורדים קבצי המודל. Python משתמש במוריד המובנה. aria2 משתמש בתהליך הורדה חיצוני ניסיוני.",
"options": {
"python": "Python (מובנה)",
"aria2": "aria2 (ניסיוני)"
}
},
"aria2cPath": {
"label": "נתיב aria2c",
"help": "נתיב אופציונלי לקובץ ההפעלה aria2c. השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH של המערכת.",
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
},
"civitaiHostBanner": {
"title": "העדפת מארח Civitai זמינה",
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
"openSettings": "פתח הגדרות"
},
"openSettingsFileLocation": {
"label": "פתח תיקיית הגדרות",
"tooltip": "פתח את התיקייה שמכילה את settings.json",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "סינון תוכן",
"downloads": "הורדות",
"videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה",
"misc": "שונות",
@@ -667,6 +699,7 @@
"moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-דאטה",
"excludeModel": "החרג מודל",
"restoreModel": "שחזור מודל",
"deleteModel": "מחק מודל",
"shareRecipe": "שתף מתכון",
"viewAllLoras": "הצג את כל ה-LoRAs",
@@ -1157,6 +1190,8 @@
"cancel": "בטל עריכה",
"save": "שמור שינויים",
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר",
"deleteWord": "מחק מילת טריגר",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "לא ניתן היה לטעון מילים מאומנות",
"tooLong": "מילת טריגר לא תעלה על 100 מילים",
"tooMany": "מותרות עד 30 מילות טריגר",
"tooLong": "מילת טריגר לא תעלה על 500 מילים",
"tooMany": "מותרות עד 100 מילות טריגר",
"alreadyExists": "מילת טריגר זו כבר קיימת",
"updateSuccess": "מילות הטריגר עודכנו בהצלחה",
"updateFailed": "עדכון מילות הטריגר נכשל",
@@ -1790,6 +1825,8 @@
"deleteFailed": "מחיקת {type} נכשלה: {message}",
"excludeSuccess": "{type} הוחרג בהצלחה",
"excludeFailed": "החרגת {type} נכשלה: {message}",
"restoreSuccess": "{type} שוחזר בהצלחה",
"restoreFailed": "שחזור {type} נכשל: {message}",
"fileNameUpdated": "שם הקובץ עודכן בהצלחה",
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",

View File

@@ -175,6 +175,9 @@
"success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}"
},
"manageExcludedModels": {
"label": "除外モデルを管理"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?",
"presetNamePlaceholder": "プリセット名...",
"baseModel": "ベースモデル",
"baseModelSearchPlaceholder": "ベースモデルを検索...",
"modelTags": "タグ上位20",
"modelTypes": "モデルタイプ",
"license": "ライセンス",
"noCreditRequired": "クレジット不要",
"allowSellingGeneratedContent": "販売許可",
"noTags": "タグなし",
"noBaseModelMatches": "現在の検索に一致するベースモデルはありません。",
"clearAll": "すべてのフィルタをクリア",
"any": "いずれか",
"all": "すべて",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiHost": {
"label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
"options": {
"com": "civitai.comSFW のみ)",
"red": "civitai.red制限なし"
}
},
"downloadBackend": {
"label": "ダウンロードバックエンド",
"help": "モデルファイルのダウンロード方法を選択します。Python は内蔵ダウンローダーを使用し、aria2 は実験的な外部ダウンローダープロセスを使用します。",
"options": {
"python": "Python内蔵",
"aria2": "aria2実験的"
}
},
"aria2cPath": {
"label": "aria2c のパス",
"help": "aria2c 実行ファイルへの任意のパスです。空欄のままにすると、システム PATH 上の aria2c を使用します。",
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
},
"civitaiHostBanner": {
"title": "Civitai ホスト設定を利用できます",
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
"openSettings": "設定を開く"
},
"openSettingsFileLocation": {
"label": "設定フォルダーを開く",
"tooltip": "settings.json を含むフォルダーを開きます",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "コンテンツフィルタリング",
"downloads": "ダウンロード",
"videoSettings": "動画設定",
"layoutSettings": "レイアウト設定",
"misc": "その他",
@@ -667,6 +699,7 @@
"moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"excludeModel": "モデルを除外",
"restoreModel": "モデルを復元",
"deleteModel": "モデルを削除",
"shareRecipe": "レシピを共有",
"viewAllLoras": "すべてのLoRAを表示",
@@ -1157,6 +1190,8 @@
"cancel": "編集をキャンセル",
"save": "変更を保存",
"addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー",
"deleteWord": "トリガーワードを削除",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "学習済みワードを読み込めませんでした",
"tooLong": "トリガーワードは100ワードを超えてはいけません",
"tooMany": "最大30トリガーワードまで許可されています",
"tooLong": "トリガーワードは500ワードを超えてはいけません",
"tooMany": "最大100トリガーワードまで許可されています",
"alreadyExists": "このトリガーワードは既に存在します",
"updateSuccess": "トリガーワードが正常に更新されました",
"updateFailed": "トリガーワードの更新に失敗しました",
@@ -1790,6 +1825,8 @@
"deleteFailed": "{type}の削除に失敗しました:{message}",
"excludeSuccess": "{type}が正常に除外されました",
"excludeFailed": "{type}の除外に失敗しました:{message}",
"restoreSuccess": "{type}を復元しました",
"restoreFailed": "{type}の復元に失敗しました: {message}",
"fileNameUpdated": "ファイル名が正常に更新されました",
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
"previewUpdated": "プレビューが正常に更新されました",

View File

@@ -175,6 +175,9 @@
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}"
},
"manageExcludedModels": {
"label": "제외된 모델 관리"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?",
"presetNamePlaceholder": "프리셋 이름...",
"baseModel": "베이스 모델",
"baseModelSearchPlaceholder": "베이스 모델 검색...",
"modelTags": "태그 (상위 20개)",
"modelTypes": "모델 유형",
"license": "라이선스",
"noCreditRequired": "크레딧 표기 없음",
"allowSellingGeneratedContent": "판매 허용",
"noTags": "태그 없음",
"noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.",
"clearAll": "모든 필터 지우기",
"any": "아무",
"all": "모두",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Civitai API 키",
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
"civitaiHost": {
"label": "Civitai 호스트",
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
"options": {
"com": "civitai.com(SFW 전용)",
"red": "civitai.red(무제한)"
}
},
"downloadBackend": {
"label": "다운로드 백엔드",
"help": "모델 파일을 다운로드하는 방식을 선택합니다. Python은 내장 다운로더를 사용하고, aria2는 실험적인 외부 다운로더 프로세스를 사용합니다.",
"options": {
"python": "Python(내장)",
"aria2": "aria2(실험적)"
}
},
"aria2cPath": {
"label": "aria2c 경로",
"help": "aria2c 실행 파일의 선택적 경로입니다. 비워 두면 시스템 PATH의 aria2c를 사용합니다.",
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
},
"civitaiHostBanner": {
"title": "Civitai 호스트 기본 설정 사용 가능",
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
"openSettings": "설정 열기"
},
"openSettingsFileLocation": {
"label": "설정 폴더 열기",
"tooltip": "settings.json이 있는 폴더를 엽니다",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "콘텐츠 필터링",
"downloads": "다운로드",
"videoSettings": "비디오 설정",
"layoutSettings": "레이아웃 설정",
"misc": "기타",
@@ -667,6 +699,7 @@
"moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구",
"excludeModel": "모델 제외",
"restoreModel": "모델 복원",
"deleteModel": "모델 삭제",
"shareRecipe": "레시피 공유",
"viewAllLoras": "모든 LoRA 보기",
@@ -1157,6 +1190,8 @@
"cancel": "편집 취소",
"save": "변경사항 저장",
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사",
"deleteWord": "트리거 단어 삭제",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "학습된 단어를 로딩할 수 없습니다",
"tooLong": "트리거 단어는 100단어를 초과할 수 없습니다",
"tooMany": "최대 30개의 트리거 단어만 허용됩니다",
"tooLong": "트리거 단어는 500단어를 초과할 수 없습니다",
"tooMany": "최대 100개의 트리거 단어만 허용됩니다",
"alreadyExists": "이 트리거 단어는 이미 존재합니다",
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
@@ -1790,6 +1825,8 @@
"deleteFailed": "{type} 삭제 실패: {message}",
"excludeSuccess": "{type}이(가) 성공적으로 제외되었습니다",
"excludeFailed": "{type} 제외 실패: {message}",
"restoreSuccess": "{type} 복원 완료",
"restoreFailed": "{type} 복원 실패: {message}",
"fileNameUpdated": "파일명이 성공적으로 업데이트되었습니다",
"fileRenameFailed": "파일 이름 변경 실패: {error}",
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",

View File

@@ -175,6 +175,9 @@
"success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}"
},
"manageExcludedModels": {
"label": "Управление исключёнными моделями"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?",
"presetNamePlaceholder": "Имя пресета...",
"baseModel": "Базовая модель",
"baseModelSearchPlaceholder": "Поиск базовых моделей...",
"modelTags": "Теги (Топ 20)",
"modelTypes": "Типы моделей",
"license": "Лицензия",
"noCreditRequired": "Без указания авторства",
"allowSellingGeneratedContent": "Продажа разрешена",
"noTags": "Без тегов",
"noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.",
"clearAll": "Очистить все фильтры",
"any": "Любой",
"all": "Все",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Ключ API Civitai",
"civitaiApiKeyPlaceholder": "Введите ваш ключ API Civitai",
"civitaiApiKeyHelp": "Используется для аутентификации при загрузке моделей с Civitai",
"civitaiHost": {
"label": "Хост Civitai",
"help": "Выберите, какой сайт Civitai будет открываться при использовании ссылок «View on Civitai».",
"options": {
"com": "civitai.com (только SFW)",
"red": "civitai.red (без ограничений)"
}
},
"downloadBackend": {
"label": "Бэкенд загрузки",
"help": "Выберите способ загрузки файлов моделей. Python использует встроенный загрузчик. aria2 использует экспериментальный внешний процесс загрузки.",
"options": {
"python": "Python (встроенный)",
"aria2": "aria2 (экспериментальный)"
}
},
"aria2cPath": {
"label": "Путь к aria2c",
"help": "Необязательный путь к исполняемому файлу aria2c. Оставьте пустым, чтобы использовать aria2c из системного PATH.",
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
},
"civitaiHostBanner": {
"title": "Доступна настройка хоста Civitai",
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
"openSettings": "Открыть настройки"
},
"openSettingsFileLocation": {
"label": "Открыть папку настроек",
"tooltip": "Открыть папку, содержащую settings.json",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "Фильтрация контента",
"downloads": "Загрузки",
"videoSettings": "Настройки видео",
"layoutSettings": "Настройки макета",
"misc": "Разное",
@@ -667,6 +699,7 @@
"moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные",
"excludeModel": "Исключить модель",
"restoreModel": "Восстановить модель",
"deleteModel": "Удалить модель",
"shareRecipe": "Поделиться рецептом",
"viewAllLoras": "Посмотреть все LoRAs",
@@ -1157,6 +1190,8 @@
"cancel": "Отменить редактирование",
"save": "Сохранить изменения",
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово",
"deleteWord": "Удалить триггерное слово",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "Не удалось загрузить обученные слова",
"tooLong": "Триггерное слово не должно превышать 100 слов",
"tooMany": "Максимум 30 триггерных слов разрешено",
"tooLong": "Триггерное слово не должно превышать 500 слов",
"tooMany": "Максимум 100 триггерных слов разрешено",
"alreadyExists": "Это триггерное слово уже существует",
"updateSuccess": "Триггерные слова успешно обновлены",
"updateFailed": "Не удалось обновить триггерные слова",
@@ -1790,6 +1825,8 @@
"deleteFailed": "Не удалось удалить {type}: {message}",
"excludeSuccess": "{type} успешно исключен",
"excludeFailed": "Не удалось исключить {type}: {message}",
"restoreSuccess": "{type} успешно восстановлен",
"restoreFailed": "Не удалось восстановить {type}: {message}",
"fileNameUpdated": "Имя файла успешно обновлено",
"fileRenameFailed": "Не удалось переименовать файл: {error}",
"previewUpdated": "Превью успешно обновлено",

View File

@@ -175,6 +175,9 @@
"success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?",
"presetNamePlaceholder": "预设名称...",
"baseModel": "基础模型",
"baseModelSearchPlaceholder": "搜索基础模型...",
"modelTags": "标签前20",
"modelTypes": "模型类型",
"license": "许可证",
"noCreditRequired": "无需署名",
"allowSellingGeneratedContent": "允许销售",
"noTags": "无标签",
"noBaseModelMatches": "没有基础模型符合当前搜索。",
"clearAll": "清除所有筛选",
"any": "任一",
"all": "全部",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiHost": {
"label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
"options": {
"com": "civitai.com仅 SFW",
"red": "civitai.red无限制"
}
},
"downloadBackend": {
"label": "下载后端",
"help": "选择模型文件的下载方式。Python 使用内置下载器。aria2 使用实验性的外部下载进程。",
"options": {
"python": "Python内置",
"aria2": "aria2实验性"
}
},
"aria2cPath": {
"label": "aria2c 路径",
"help": "可选的 aria2c 可执行文件路径。留空则使用系统 PATH 中的 aria2c。",
"placeholder": "留空则使用 PATH 中的 aria2c"
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站点偏好设置",
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
"openSettings": "打开设置"
},
"openSettingsFileLocation": {
"label": "打开设置文件夹",
"tooltip": "打开包含 settings.json 的文件夹",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "内容过滤",
"downloads": "下载",
"videoSettings": "视频设置",
"layoutSettings": "布局设置",
"misc": "其他",
@@ -667,6 +699,7 @@
"moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"excludeModel": "排除模型",
"restoreModel": "恢复模型",
"deleteModel": "删除模型",
"shareRecipe": "分享配方",
"viewAllLoras": "查看所有 LoRA",
@@ -685,9 +718,9 @@
"title": "从图片或 URL 导入配方",
"urlLocalPath": "URL / 本地路径",
"uploadImage": "上传图片",
"urlSectionDescription": "输入 Civitai 图片 URL 或本地文件路径以导入为配方。",
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL或本地文件路径以导入为配方。",
"imageUrlOrPath": "图片 URL 或文件路径:",
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
"fetchImage": "获取图片",
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
"selectImage": "选择图片",
@@ -1090,9 +1123,9 @@
},
"proceedText": "仅在你确定需要此操作时继续。",
"urlLabel": "Civitai 模型 URL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
"helpText": {
"title": "粘贴任意 Civitai 模型 URL。支持格式",
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
@@ -1157,6 +1190,8 @@
"cancel": "取消编辑",
"save": "保存更改",
"addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词",
"deleteWord": "删除触发词",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "无法加载训练词",
"tooLong": "触发词不能超过100个词",
"tooMany": "最多允许30个触发词",
"tooLong": "触发词不能超过500个词",
"tooMany": "最多允许100个触发词",
"alreadyExists": "该触发词已存在",
"updateSuccess": "触发词更新成功",
"updateFailed": "触发词更新失败",
@@ -1790,6 +1825,8 @@
"deleteFailed": "删除 {type} 失败:{message}",
"excludeSuccess": "{type} 排除成功",
"excludeFailed": "排除 {type} 失败:{message}",
"restoreSuccess": "{type} 已成功恢复",
"restoreFailed": "恢复 {type} 失败:{message}",
"fileNameUpdated": "文件名更新成功",
"fileRenameFailed": "重命名文件失败:{error}",
"previewUpdated": "预览图片更新成功",

View File

@@ -175,6 +175,9 @@
"success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
}
},
"header": {
@@ -222,12 +225,14 @@
"presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?",
"presetNamePlaceholder": "預設名稱...",
"baseModel": "基礎模型",
"baseModelSearchPlaceholder": "搜尋基礎模型...",
"modelTags": "標籤(前 20",
"modelTypes": "模型類型",
"license": "授權",
"noCreditRequired": "無需署名",
"allowSellingGeneratedContent": "允許銷售",
"noTags": "無標籤",
"noBaseModelMatches": "沒有基礎模型符合目前的搜尋。",
"clearAll": "清除所有篩選",
"any": "任一",
"all": "全部",
@@ -250,6 +255,32 @@
"civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiHost": {
"label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
"options": {
"com": "civitai.com僅 SFW",
"red": "civitai.red無限制"
}
},
"downloadBackend": {
"label": "下載後端",
"help": "選擇模型檔案的下載方式。Python 使用內建下載器。aria2 使用實驗性的外部下載程序。",
"options": {
"python": "Python內建",
"aria2": "aria2實驗性"
}
},
"aria2cPath": {
"label": "aria2c 路徑",
"help": "可選的 aria2c 可執行檔路徑。留空則使用系統 PATH 中的 aria2c。",
"placeholder": "留空則使用 PATH 中的 aria2c"
},
"civitaiHostBanner": {
"title": "已提供 Civitai 站點偏好設定",
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
"openSettings": "開啟設定"
},
"openSettingsFileLocation": {
"label": "開啟設定資料夾",
"tooltip": "開啟包含 settings.json 的資料夾",
@@ -260,6 +291,7 @@
},
"sections": {
"contentFiltering": "內容過濾",
"downloads": "下載",
"videoSettings": "影片設定",
"layoutSettings": "版面設定",
"misc": "其他",
@@ -667,6 +699,7 @@
"moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"excludeModel": "排除模型",
"restoreModel": "還原模型",
"deleteModel": "刪除模型",
"shareRecipe": "分享配方",
"viewAllLoras": "檢視全部 LoRA",
@@ -1157,6 +1190,8 @@
"cancel": "取消編輯",
"save": "儲存變更",
"addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞",
"deleteWord": "刪除觸發詞",
"suggestions": {
@@ -1728,8 +1763,8 @@
},
"triggerWords": {
"loadFailed": "無法載入訓練詞",
"tooLong": "觸發詞不可超過 100 個字",
"tooMany": "最多允許 30 個觸發詞",
"tooLong": "觸發詞不可超過 500 個字",
"tooMany": "最多允許 100 個觸發詞",
"alreadyExists": "此觸發詞已存在",
"updateSuccess": "觸發詞已更新",
"updateFailed": "更新觸發詞失敗",
@@ -1790,6 +1825,8 @@
"deleteFailed": "刪除 {type} 失敗:{message}",
"excludeSuccess": "{type} 已成功排除",
"excludeFailed": "排除 {type} 失敗:{message}",
"restoreSuccess": "{type} 已成功還原",
"restoreFailed": "還原 {type} 失敗:{message}",
"fileNameUpdated": "檔案名稱已成功更新",
"fileRenameFailed": "重新命名檔案失敗:{error}",
"previewUpdated": "預覽圖片已成功更新",

View File

@@ -1,5 +1,6 @@
import os
import platform
import posixpath
import threading
from pathlib import Path
import folder_paths # type: ignore
@@ -25,21 +26,57 @@ standalone_mode = (
logger = logging.getLogger(__name__)
def _normalize_root_identity(path: str) -> str:
"""Normalize a root path for comparisons across slash styles."""
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
if len(normalized) >= 2 and normalized[1] == ":":
return normalized.lower()
return normalized
def _resolve_valid_default_root(
current: str, primary_paths: List[str], name: str
current: str, primary_paths: List[str], allowed_paths: List[str], name: str
) -> str:
"""Return a valid default root from the current primary path set."""
"""Return a valid default root from the current primary/extra path set."""
valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
if not valid_paths:
return ""
fallback_paths: List[str] = []
seen: Set[str] = set()
for path in allowed_paths:
if not isinstance(path, str):
continue
stripped = path.strip()
if not stripped:
continue
identity = _normalize_root_identity(stripped)
if identity in seen:
continue
seen.add(identity)
fallback_paths.append(stripped)
if current in valid_paths:
allowed = {_normalize_root_identity(path) for path in fallback_paths}
if current and _normalize_root_identity(current) in allowed:
return current
if not valid_paths:
if not fallback_paths:
return ""
if current:
logger.info(
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
name,
current,
fallback_paths[0],
)
else:
logger.info("Auto-setting %s to '%s'", name, fallback_paths[0])
return fallback_paths[0]
if current:
logger.info(
"Repaired stale %s from '%s' to '%s'",
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
name,
current,
valid_paths[0],
@@ -226,39 +263,76 @@ class Config:
default_lora_root = _resolve_valid_default_root(
comfy_library.get("default_lora_root", ""),
list(self.loras_roots or []),
list(self.loras_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []),
"default_lora_root",
)
default_checkpoint_root = _resolve_valid_default_root(
comfy_library.get("default_checkpoint_root", ""),
list(self.checkpoints_roots or []),
list(self.checkpoints_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []),
"default_checkpoint_root",
)
default_embedding_root = _resolve_valid_default_root(
comfy_library.get("default_embedding_root", ""),
list(self.embeddings_roots or []),
list(self.embeddings_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []),
"default_embedding_root",
)
metadata = dict(comfy_library.get("metadata", {}))
metadata.setdefault("display_name", "ComfyUI")
metadata["source"] = "comfyui"
extra_folder_paths = {}
if isinstance(comfy_library, Mapping):
existing_extra_paths = comfy_library.get("extra_folder_paths", {})
if isinstance(existing_extra_paths, Mapping):
extra_folder_paths = {
key: list(value) if isinstance(value, list) else []
for key, value in existing_extra_paths.items()
}
active_library_name = settings_service.get_active_library_name()
should_activate = (
active_library_name == "comfyui"
or self._should_activate_comfy_library(libraries, libraries_changed)
)
settings_service.upsert_library(
"comfyui",
folder_paths=target_folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_embedding_root=default_embedding_root,
metadata=metadata,
activate=True,
activate=should_activate,
)
logger.info("Updated 'comfyui' library with current folder paths")
if should_activate:
logger.info("Updated 'comfyui' library with current folder paths")
else:
logger.info(
"Updated 'comfyui' library with current folder paths without activating it"
)
except Exception as e:
logger.warning(f"Failed to save folder paths: {e}")
def _should_activate_comfy_library(
self, libraries: Mapping[str, Any], libraries_changed: bool
) -> bool:
"""Return whether startup sync should make the ComfyUI library active."""
if libraries_changed:
return True
if not libraries:
return True
return "comfyui" in libraries and len(libraries) == 1
def _is_link(self, path: str) -> bool:
try:
if os.path.islink(path):

View File

@@ -780,8 +780,10 @@ NODE_EXTRACTORS = {
"GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
"DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
"CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes
"CheckpointLoaderLM": CheckpointLoaderExtractor, # LoRA Manager
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
"LoraLoader": LoraLoaderExtractor,
"LoraLoaderLM": LoraLoaderManagerExtractor,
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,

View File

@@ -76,6 +76,9 @@ class TriggerWordToggleLM:
# Filter out empty strings and return as set
return set(word for word in words if word)
def _group_has_child_items(self, item):
return isinstance(item, dict) and isinstance(item.get("items"), list)
def process_trigger_words(
self,
id,
@@ -112,7 +115,11 @@ class TriggerWordToggleLM:
if isinstance(trigger_data, list):
if group_mode:
if allow_strength_adjustment:
if any(self._group_has_child_items(item) for item in trigger_data):
filtered_groups = self._process_group_items(
trigger_data, allow_strength_adjustment
)
elif allow_strength_adjustment:
parsed_items = [
self._parse_trigger_item(
item, allow_strength_adjustment
@@ -174,6 +181,41 @@ class TriggerWordToggleLM:
return (filtered_triggers,)
def _process_group_items(self, trigger_data, allow_strength_adjustment):
filtered_groups = []
for item in trigger_data:
group = self._parse_trigger_item(item, allow_strength_adjustment)
if not group["text"] or not group["active"]:
continue
raw_items = item.get("items") if isinstance(item, dict) else None
if isinstance(raw_items, list):
active_items = []
for raw_item in raw_items:
child = self._parse_trigger_item(
raw_item, allow_strength_adjustment=False
)
if child["text"] and child["active"]:
active_items.append(child["text"])
if not active_items:
continue
group_text = ", ".join(active_items)
else:
group_text = group["text"]
filtered_groups.append(
self._format_word_output(
group_text,
group["strength"],
allow_strength_adjustment,
)
)
return filtered_groups
def _parse_trigger_item(self, item, allow_strength_adjustment):
text = (item.get("text") or "").strip()
active = bool(item.get("active", False))

View File

@@ -1,11 +1,11 @@
import logging
import json
import re
import os
from typing import Any, Dict, Optional
from .merger import GenParamsMerger
from .base import RecipeMetadataParser
from ..services.metadata_service import get_default_metadata_provider
from ..utils.civitai_utils import extract_civitai_image_id
logger = logging.getLogger(__name__)
@@ -39,11 +39,12 @@ class RecipeEnricher:
source_url = recipe.get("source_url") or recipe.get("source_path", "")
# Check if it's a Civitai image URL
image_id_match = re.search(r'civitai\.com/images/(\d+)', str(source_url))
if image_id_match:
image_id = image_id_match.group(1)
image_id = extract_civitai_image_id(str(source_url))
if image_id:
try:
image_info = await civitai_client.get_image_info(image_id)
image_info = await civitai_client.get_image_info(
image_id, source_url=str(source_url)
)
if image_info:
# Handle nested meta often found in Civitai API responses
raw_meta = image_info.get("meta")

View File

@@ -16,6 +16,10 @@ import jinja2
from ...config import config
from ...services.download_coordinator import DownloadCoordinator
from ...services.connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE,
is_expected_offline_error,
)
from ...services.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService
from ...services.preview_asset_service import PreviewAssetService
@@ -224,6 +228,42 @@ class ModelListingHandler:
)
return web.json_response({"error": str(exc)}, status=500)
async def get_excluded_models(self, request: web.Request) -> web.Response:
start_time = time.perf_counter()
try:
params = self._parse_common_params(request)
result = await self._service.get_excluded_paginated_data(**params)
format_start = time.perf_counter()
formatted_result = {
"items": [
await self._service.format_response(item)
for item in result["items"]
],
"total": result["total"],
"page": result["page"],
"page_size": result["page_size"],
"total_pages": result["total_pages"],
}
format_duration = time.perf_counter() - format_start
duration = time.perf_counter() - start_time
self._logger.debug(
"Request for %s/excluded took %.3fs (formatting: %.3fs)",
self._service.model_type,
duration,
format_duration,
)
return web.json_response(formatted_result)
except Exception as exc:
self._logger.error(
"Error retrieving excluded %ss: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"error": str(exc)}, status=500)
def _parse_common_params(self, request: web.Request) -> Dict:
page = int(request.query.get("page", "1"))
page_size = min(int(request.query.get("page_size", "20")), 100)
@@ -392,6 +432,21 @@ class ModelManagementHandler:
self._logger.error("Error excluding model: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500)
async def unexclude_model(self, request: web.Request) -> web.Response:
try:
data = await request.json()
file_path = data.get("file_path")
if not file_path:
return web.Response(text="Model path is required", status=400)
result = await self._lifecycle_service.unexclude_model(file_path)
return web.json_response(result)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
self._logger.error("Error restoring model: %s", exc, exc_info=True)
return web.Response(text=str(exc), status=500)
async def fetch_civitai(self, request: web.Request) -> web.Response:
try:
data = await request.json()
@@ -453,6 +508,11 @@ class ModelManagementHandler:
formatted_metadata = await self._service.format_response(model_data)
return web.json_response({"success": True, "metadata": formatted_metadata})
except Exception as exc:
if is_expected_offline_error(str(exc)):
return web.json_response(
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
status=503,
)
self._logger.error("Error fetching from CivitAI: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -499,6 +559,11 @@ class ModelManagementHandler:
}
)
except Exception as exc:
if is_expected_offline_error(str(exc)):
return web.json_response(
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
status=503,
)
self._logger.error("Error re-linking to CivitAI: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -859,7 +924,7 @@ class ModelQueryHandler:
async def get_base_models(self, request: web.Request) -> web.Response:
try:
limit = int(request.query.get("limit", "20"))
if limit < 1 or limit > 100:
if limit < 0 or limit > 100:
limit = 20
base_models = await self._service.get_base_models(limit)
return web.json_response({"success": True, "base_models": base_models})
@@ -1807,6 +1872,11 @@ class ModelUpdateHandler:
status=429,
)
except Exception as exc: # pragma: no cover - defensive log
if is_expected_offline_error(str(exc)):
return web.json_response(
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
status=503,
)
self._logger.error("Failed to fetch license info: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -1895,9 +1965,12 @@ class ModelUpdateHandler:
{"success": False, "error": str(exc) or "Rate limited"}, status=429
)
except Exception as exc: # pragma: no cover - defensive logging
self._logger.error(
"Failed to refresh model updates: %s", exc, exc_info=True
)
if is_expected_offline_error(str(exc)):
return web.json_response(
{"success": False, "error": OFFLINE_FRIENDLY_MESSAGE},
status=503,
)
self._logger.error("Failed to refresh model updates: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
serialized_records = []
@@ -2437,8 +2510,10 @@ class ModelHandlerSet:
return {
"handle_models_page": self.page_view.handle,
"get_models": self.listing.get_models,
"get_excluded_models": self.listing.get_excluded_models,
"delete_model": self.management.delete_model,
"exclude_model": self.management.exclude_model,
"unexclude_model": self.management.unexclude_model,
"fetch_civitai": self.management.fetch_civitai,
"fetch_all_civitai": self.civitai.fetch_all_civitai,
"relink_civitai": self.management.relink_civitai,

View File

@@ -26,7 +26,7 @@ from ...services.recipes import (
RecipeValidationError,
)
from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
@@ -329,6 +329,7 @@ class RecipeQueryHandler:
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
limit = int(request.query.get("limit", "20"))
cache = await recipe_scanner.get_cached_data()
base_model_counts: Dict[str, int] = {}
@@ -344,6 +345,8 @@ class RecipeQueryHandler:
for model, count in base_model_counts.items()
]
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
if limit > 0:
sorted_models = sorted_models[:limit]
return web.json_response({"success": True, "base_models": sorted_models})
except Exception as exc:
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
@@ -1196,13 +1199,15 @@ class RecipeManagementHandler:
temp_path = temp_file.name
download_url = image_url
image_info = None
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", image_url)
if civitai_match:
civitai_image_id = extract_civitai_image_id(image_url)
if civitai_image_id:
if civitai_client is None:
raise RecipeDownloadError(
"Civitai client unavailable for image download"
)
image_info = await civitai_client.get_image_info(civitai_match.group(1))
image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=image_url
)
if not image_info:
raise RecipeDownloadError(
"Failed to fetch image information from Civitai"
@@ -1236,7 +1241,7 @@ class RecipeManagementHandler:
return (
file_obj.read(),
extension,
image_info.get("meta") if civitai_match and image_info else None,
image_info.get("meta") if civitai_image_id and image_info else None,
)
except RecipeDownloadError:
raise

View File

@@ -22,8 +22,10 @@ class RouteDefinition:
COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/{prefix}/list", "get_models"),
RouteDefinition("GET", "/api/lm/{prefix}/excluded", "get_excluded_models"),
RouteDefinition("POST", "/api/lm/{prefix}/delete", "delete_model"),
RouteDefinition("POST", "/api/lm/{prefix}/exclude", "exclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/unexclude", "unexclude_model"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-civitai", "fetch_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/fetch-all-civitai", "fetch_all_civitai"),
RouteDefinition("POST", "/api/lm/{prefix}/relink-civitai", "relink_civitai"),

View File

@@ -0,0 +1,570 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import secrets
import shutil
import socket
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
import aiohttp
from .downloader import DownloadProgress, get_downloader
from .aria2_transfer_state import Aria2TransferStateStore
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
CIVITAI_DOWNLOAD_URL_PREFIXES = (
"https://civitai.com/api/download/",
"https://civitai.red/api/download/",
)
class Aria2Error(RuntimeError):
"""Raised when aria2 integration fails."""
@dataclass
class Aria2Transfer:
"""Track an aria2 download registered by the Python coordinator."""
gid: str
save_path: str
class Aria2Downloader:
"""Manage an aria2 RPC daemon for experimental model downloads."""
_instance = None
_lock = asyncio.Lock()
@classmethod
async def get_instance(cls) -> "Aria2Downloader":
async with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self) -> None:
if hasattr(self, "_initialized"):
return
self._initialized = True
self._process: Optional[asyncio.subprocess.Process] = None
self._rpc_port: Optional[int] = None
self._rpc_secret = ""
self._rpc_url = ""
self._rpc_session: Optional[aiohttp.ClientSession] = None
self._rpc_session_lock = asyncio.Lock()
self._process_lock = asyncio.Lock()
self._transfers: Dict[str, Aria2Transfer] = {}
self._poll_interval = 0.5
self._state_store = Aria2TransferStateStore()
@property
def is_running(self) -> bool:
return self._process is not None and self._process.returncode is None
async def download_file(
self,
url: str,
save_path: str,
*,
download_id: str,
progress_callback=None,
headers: Optional[Dict[str, str]] = None,
) -> Tuple[bool, str]:
"""Download a file using aria2 RPC and wait for completion."""
await self._ensure_process()
save_path = os.path.abspath(save_path)
transfer = self._transfers.get(download_id)
if transfer is None or os.path.abspath(transfer.save_path) != save_path:
gid = await self._schedule_download(
url,
save_path,
download_id=download_id,
headers=headers,
)
transfer = Aria2Transfer(gid=gid, save_path=save_path)
self._transfers[download_id] = transfer
try:
while True:
status = await self.get_status(download_id)
if status is None:
return False, "aria2 download not found"
snapshot = self._build_progress_snapshot(status)
if progress_callback is not None:
await self._dispatch_progress(progress_callback, snapshot)
state = status.get("status", "")
if state == "complete":
completed_path = self._resolve_completed_path(status, save_path)
return True, completed_path
if state == "error":
return False, status.get("errorMessage") or "aria2 download failed"
if state == "removed":
return False, "Download was cancelled"
await asyncio.sleep(self._poll_interval)
finally:
self._transfers.pop(download_id, None)
async def _schedule_download(
self,
url: str,
save_path: str,
*,
download_id: str,
headers: Optional[Dict[str, str]] = None,
) -> str:
save_dir = os.path.dirname(save_path)
out_name = os.path.basename(save_path)
Path(save_dir).mkdir(parents=True, exist_ok=True)
resolved_url = url
request_headers = headers
if headers and url.startswith(CIVITAI_DOWNLOAD_URL_PREFIXES):
resolved_url = await self._resolve_authenticated_redirect_url(url, headers)
if resolved_url != url:
request_headers = None
logger.debug(
"Resolved Civitai download %s to signed URL for aria2",
download_id,
)
options: Dict[str, str] = {
"dir": save_dir,
"out": out_name,
"continue": "true",
"max-connection-per-server": "4",
"split": "4",
"min-split-size": "1M",
"allow-overwrite": "true",
"auto-file-renaming": "false",
"file-allocation": "none",
}
if request_headers:
options["header"] = [
f"{key}: {value}" for key, value in request_headers.items()
]
logger.debug(
"Submitting aria2 download %s -> %s (auth=%s, civitai_signed=%s)",
download_id,
save_path,
bool(request_headers),
resolved_url != url,
)
try:
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
except Exception as exc:
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
await self._state_store.upsert(
download_id,
{
"gid": gid,
"save_path": save_path,
"status": "downloading",
"url": url,
},
)
return gid
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
"""Return the raw aria2 status payload for a known download."""
transfer = self._transfers.get(download_id)
if transfer is None:
return None
keys = [
"gid",
"status",
"totalLength",
"completedLength",
"downloadSpeed",
"errorMessage",
"files",
]
try:
status = await self._rpc_call("aria2.tellStatus", [transfer.gid, keys])
except Exception as exc:
raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc
if isinstance(status, dict):
return status
return None
async def get_status_by_gid(self, gid: str) -> Optional[Dict[str, Any]]:
keys = [
"gid",
"status",
"totalLength",
"completedLength",
"downloadSpeed",
"errorMessage",
"files",
]
try:
status = await self._rpc_call("aria2.tellStatus", [gid, keys])
except Exception as exc:
message = str(exc)
if "cannot be found" in message.lower() or "not found" in message.lower():
return None
raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc
if isinstance(status, dict):
return status
return None
async def restore_transfer(self, download_id: str, gid: str, save_path: str) -> None:
await self._ensure_process()
self._transfers[download_id] = Aria2Transfer(
gid=gid,
save_path=os.path.abspath(save_path),
)
async def reassign_transfer(
self, from_download_id: str, to_download_id: str
) -> Optional[Aria2Transfer]:
transfer = self._transfers.get(from_download_id)
if transfer is None:
return None
self._transfers[to_download_id] = transfer
if from_download_id != to_download_id:
self._transfers.pop(from_download_id, None)
return transfer
async def has_transfer(self, download_id: str) -> bool:
return download_id in self._transfers
async def pause_download(self, download_id: str) -> Dict[str, Any]:
transfer = self._transfers.get(download_id)
if transfer is None:
return {"success": False, "error": "Download task not found"}
try:
await self._rpc_call("aria2.forcePause", [transfer.gid])
except Exception as exc:
return {"success": False, "error": str(exc)}
await self._state_store.upsert(download_id, {"status": "paused"})
return {"success": True, "message": "Download paused successfully"}
async def resume_download(self, download_id: str) -> Dict[str, Any]:
transfer = self._transfers.get(download_id)
if transfer is None:
return {"success": False, "error": "Download task not found"}
try:
await self._rpc_call("aria2.unpause", [transfer.gid])
except Exception as exc:
return {"success": False, "error": str(exc)}
await self._state_store.upsert(download_id, {"status": "downloading"})
return {"success": True, "message": "Download resumed successfully"}
async def cancel_download(self, download_id: str) -> Dict[str, Any]:
transfer = self._transfers.get(download_id)
if transfer is None:
return {"success": False, "error": "Download task not found"}
try:
await self._rpc_call("aria2.forceRemove", [transfer.gid])
except Exception as exc:
return {"success": False, "error": str(exc)}
await self._state_store.remove(download_id)
return {"success": True, "message": "Download cancelled successfully"}
async def close(self) -> None:
"""Shut down the RPC process and session."""
if self._rpc_session is not None:
await self._rpc_session.close()
self._rpc_session = None
process = self._process
self._process = None
self._transfers.clear()
if process is None:
return
if process.returncode is None:
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=5.0)
except asyncio.TimeoutError:
process.kill()
await process.wait()
async def _dispatch_progress(self, callback, snapshot: DownloadProgress) -> None:
try:
result = callback(snapshot, snapshot)
except TypeError:
result = callback(snapshot.percent_complete)
if asyncio.iscoroutine(result):
await result
elif hasattr(result, "__await__"):
await result
def _build_progress_snapshot(self, status: Dict[str, Any]) -> DownloadProgress:
completed = self._parse_int(status.get("completedLength"))
total = self._parse_int(status.get("totalLength"))
speed = float(self._parse_int(status.get("downloadSpeed")))
percent = 0.0
if total > 0:
percent = (completed / total) * 100.0
return DownloadProgress(
percent_complete=max(0.0, min(percent, 100.0)),
bytes_downloaded=completed,
total_bytes=total or None,
bytes_per_second=speed,
timestamp=datetime.now().timestamp(),
)
def _resolve_completed_path(self, status: Dict[str, Any], default_path: str) -> str:
files = status.get("files")
if isinstance(files, list) and files:
first = files[0]
if isinstance(first, dict):
candidate = first.get("path")
if isinstance(candidate, str) and candidate:
return candidate
return default_path
@staticmethod
def _parse_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
async def _resolve_authenticated_redirect_url(
self,
url: str,
headers: Dict[str, str],
) -> str:
downloader = await get_downloader()
session = await downloader.session
request_headers = dict(downloader.default_headers)
request_headers.update(headers)
request_headers["Accept-Encoding"] = "identity"
try:
async with session.get(
url,
headers=request_headers,
allow_redirects=False,
proxy=downloader.proxy_url,
) as response:
if response.status in {301, 302, 303, 307, 308}:
location = response.headers.get("Location")
if location:
return location
raise Aria2Error(
"Authenticated Civitai redirect did not include a Location header"
)
if response.status == 200:
return url
body = await response.text()
raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: status={response.status} body={body[:300]}"
)
except aiohttp.ClientError as exc:
raise Aria2Error(
f"Failed to resolve authenticated Civitai redirect: {exc}"
) from exc
async def _ensure_process(self) -> None:
async with self._process_lock:
if self.is_running and await self._ping():
return
await self.close()
executable = self._resolve_executable()
self._rpc_port = self._find_free_port()
self._rpc_secret = secrets.token_hex(16)
self._rpc_url = f"http://127.0.0.1:{self._rpc_port}/jsonrpc"
command = [
executable,
"--enable-rpc=true",
"--rpc-listen-all=false",
f"--rpc-listen-port={self._rpc_port}",
f"--rpc-secret={self._rpc_secret}",
"--check-certificate=true",
"--allow-overwrite=true",
"--auto-file-renaming=false",
"--file-allocation=none",
"--max-concurrent-downloads=5",
"--continue=true",
"--daemon=false",
"--quiet=true",
f"--stop-with-process={os.getpid()}",
]
logger.info("Starting aria2 RPC daemon from %s", executable)
self._process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
await self._wait_until_ready()
def _resolve_executable(self) -> str:
settings = get_settings_manager()
configured_path = (settings.get("aria2c_path") or "").strip()
candidate = configured_path or "aria2c"
resolved = shutil.which(candidate)
if resolved:
return resolved
if configured_path and os.path.isfile(configured_path) and os.access(
configured_path, os.X_OK
):
return configured_path
raise Aria2Error(
"aria2c executable was not found. Install aria2 or configure aria2c_path."
)
async def _wait_until_ready(self) -> None:
assert self._process is not None
start_time = asyncio.get_running_loop().time()
last_error = ""
while asyncio.get_running_loop().time() - start_time < 10.0:
if self._process.returncode is not None:
stderr_output = ""
if self._process.stderr is not None:
try:
stderr_output = (
await asyncio.wait_for(self._process.stderr.read(), timeout=0.2)
).decode("utf-8", errors="replace")
except Exception:
stderr_output = ""
raise Aria2Error(
f"aria2 RPC process exited early with code {self._process.returncode}: {stderr_output.strip()}"
)
try:
if await self._ping():
return
except Exception as exc: # pragma: no cover - startup race
last_error = str(exc)
await asyncio.sleep(0.2)
raise Aria2Error(
f"Timed out waiting for aria2 RPC to become ready{': ' + last_error if last_error else ''}"
)
async def _ping(self) -> bool:
try:
result = await self._rpc_call("aria2.getVersion", [])
except Exception:
return False
return isinstance(result, dict)
async def _rpc_call(self, method: str, params: list[Any]) -> Any:
if not self._rpc_url:
raise Aria2Error("aria2 RPC endpoint is not initialized")
session = await self._get_rpc_session()
payload = {
"jsonrpc": "2.0",
"id": secrets.token_hex(8),
"method": method,
"params": [f"token:{self._rpc_secret}", *params],
}
async with session.post(self._rpc_url, json=payload) as response:
text = await response.text()
try:
body = json.loads(text)
except json.JSONDecodeError:
body = None
if body is None:
if response.status != 200:
raise Aria2Error(
f"aria2 RPC returned status {response.status} with non-JSON body: {text}"
)
raise Aria2Error(f"Invalid aria2 RPC response: {text}")
if "error" in body:
error = body["error"] or {}
code = error.get("code") if isinstance(error, dict) else None
message = error.get("message") if isinstance(error, dict) else str(error)
logger.error(
"aria2 RPC %s failed with HTTP %s, code=%s, message=%s",
method,
response.status,
code,
message,
)
status_message = (
f"aria2 RPC {method} failed with status {response.status}: {message}"
if response.status != 200
else message
)
raise Aria2Error(status_message or "Unknown aria2 RPC error")
if response.status != 200:
logger.error(
"aria2 RPC %s returned unexpected HTTP status %s without error payload: %s",
method,
response.status,
body,
)
raise Aria2Error(
f"aria2 RPC {method} returned unexpected status {response.status}"
)
return body.get("result")
async def _get_rpc_session(self) -> aiohttp.ClientSession:
if self._rpc_session is None or self._rpc_session.closed:
async with self._rpc_session_lock:
if self._rpc_session is None or self._rpc_session.closed:
timeout = aiohttp.ClientTimeout(total=30)
self._rpc_session = aiohttp.ClientSession(timeout=timeout)
return self._rpc_session
@staticmethod
def _find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
sock.listen(1)
return int(sock.getsockname()[1])
async def get_aria2_downloader() -> Aria2Downloader:
"""Get the singleton aria2 downloader."""
return await Aria2Downloader.get_instance()

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import asyncio
import json
import os
from copy import deepcopy
from typing import Any, Dict, Optional
from ..utils.cache_paths import get_cache_base_dir
def get_aria2_state_path() -> str:
base_dir = get_cache_base_dir(create=True)
state_dir = os.path.join(base_dir, "aria2")
os.makedirs(state_dir, exist_ok=True)
return os.path.join(state_dir, "downloads.json")
class Aria2TransferStateStore:
"""Persist aria2 transfer metadata needed for restart recovery."""
_locks_by_path: Dict[str, asyncio.Lock] = {}
def __init__(self, state_path: Optional[str] = None) -> None:
self._state_path = os.path.abspath(state_path or get_aria2_state_path())
self._lock = self._locks_by_path.setdefault(self._state_path, asyncio.Lock())
def _read_all_unlocked(self) -> Dict[str, Dict[str, Any]]:
try:
with open(self._state_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
return {}
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
normalized: Dict[str, Dict[str, Any]] = {}
for download_id, entry in data.items():
if isinstance(download_id, str) and isinstance(entry, dict):
normalized[download_id] = entry
return normalized
def _write_all_unlocked(self, data: Dict[str, Dict[str, Any]]) -> None:
directory = os.path.dirname(self._state_path)
if directory:
os.makedirs(directory, exist_ok=True)
temp_path = f"{self._state_path}.tmp"
with open(temp_path, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=True, indent=2, sort_keys=True)
os.replace(temp_path, self._state_path)
async def load_all(self) -> Dict[str, Dict[str, Any]]:
async with self._lock:
return deepcopy(self._read_all_unlocked())
async def get(self, download_id: str) -> Optional[Dict[str, Any]]:
async with self._lock:
return deepcopy(self._read_all_unlocked().get(download_id))
async def upsert(self, download_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
async with self._lock:
data = self._read_all_unlocked()
current = data.get(download_id, {})
current.update(payload)
data[download_id] = current
self._write_all_unlocked(data)
return deepcopy(current)
async def remove(self, download_id: str) -> None:
async with self._lock:
data = self._read_all_unlocked()
if download_id in data:
del data[download_id]
self._write_all_unlocked(data)
async def find_by_save_path(
self, save_path: str, *, exclude_download_id: Optional[str] = None
) -> Optional[Dict[str, Any]]:
normalized_target = os.path.abspath(save_path)
async with self._lock:
data = self._read_all_unlocked()
for download_id, entry in data.items():
if exclude_download_id and download_id == exclude_download_id:
continue
candidate = entry.get("save_path")
if isinstance(candidate, str) and os.path.abspath(candidate) == normalized_target:
result = dict(entry)
result["download_id"] = download_id
return result
return None
async def reassign(self, from_download_id: str, to_download_id: str) -> Optional[Dict[str, Any]]:
async with self._lock:
data = self._read_all_unlocked()
existing = data.get(from_download_id)
if existing is None:
return None
updated = dict(existing)
updated["download_id"] = to_download_id
data[to_download_id] = updated
if from_download_id != to_download_id:
data.pop(from_download_id, None)
self._write_all_unlocked(data)
return deepcopy(updated)

View File

@@ -20,6 +20,7 @@ from .model_query import (
resolve_sub_type,
)
from .settings_manager import get_settings_manager
from ..utils.civitai_utils import build_civitai_model_page_url
logger = logging.getLogger(__name__)
@@ -178,6 +179,57 @@ class BaseModelService(ABC):
)
return paginated
async def get_excluded_paginated_data(
self,
page: int,
page_size: int,
sort_by: str = "name",
search: str = None,
fuzzy_search: bool = False,
search_options: dict = None,
**kwargs,
) -> Dict:
"""Get paginated excluded model data."""
excluded_paths = list(self.scanner.get_excluded_models())
excluded_entries: List[Dict[str, Any]] = []
stale_paths: List[str] = []
for file_path in excluded_paths:
if not file_path or not os.path.exists(file_path):
stale_paths.append(file_path)
continue
entry = await self._build_excluded_entry(file_path)
if entry:
excluded_entries.append(entry)
else:
stale_paths.append(file_path)
if stale_paths:
current_excluded = getattr(self.scanner, "_excluded_models", None)
if isinstance(current_excluded, list):
stale_set = set(stale_paths)
self.scanner._excluded_models = [
path for path in current_excluded if path not in stale_set
]
persist_current_cache = getattr(self.scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
excluded_entries = self._sort_entries(excluded_entries, sort_by)
if search:
excluded_entries = await self._apply_search_filters(
excluded_entries,
search,
fuzzy_search,
search_options,
)
paginated = self._paginate(excluded_entries, page, page_size)
paginated["items"] = await self._annotate_update_flags(paginated["items"])
return paginated
async def _fetch_with_usage_sort(self, sort_params):
"""Fetch data sorted by usage count (desc/asc)."""
cache = await self.cache_repository.get_cache()
@@ -217,6 +269,62 @@ class BaseModelService(ABC):
)
return annotated
def _sort_entries(self, data: List[Dict[str, Any]], sort_by: str) -> List[Dict[str, Any]]:
sort_params = self.cache_repository.parse_sort(sort_by)
key_name = sort_params.key
if key_name == "date":
key_fn = lambda item: (
float(item.get("modified", 0.0) or 0.0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "size":
key_fn = lambda item: (
int(item.get("size", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
elif key_name == "usage":
key_fn = lambda item: (
int(item.get("usage_count", 0) or 0),
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
else:
key_fn = lambda item: (
(item.get("model_name") or item.get("file_name") or "").lower(),
item.get("file_path", "").lower(),
)
return sorted(data, key=key_fn, reverse=sort_params.order == "desc")
async def _build_excluded_entry(self, file_path: str) -> Optional[Dict[str, Any]]:
root_path = self.scanner._find_root_for_file(file_path)
if not root_path:
return None
metadata, should_skip = await MetadataManager.load_metadata(
file_path,
self.metadata_class,
)
if should_skip:
return None
if metadata is None:
metadata = await self.scanner._create_default_metadata(file_path)
if metadata is None:
return None
metadata = self.scanner.adjust_metadata(metadata, file_path, root_path)
folder = os.path.dirname(os.path.relpath(file_path, root_path)).replace(
os.path.sep, "/"
)
entry = self.scanner._build_cache_entry(metadata, folder=folder)
entry = self.scanner.adjust_cached_entry(entry)
entry["exclude"] = True
return entry
async def _apply_hash_filters(
self, data: List[Dict], hash_filters: Dict
) -> List[Dict]:
@@ -774,9 +882,12 @@ class BaseModelService(ABC):
version_id = civitai_data.get("id")
if model_id:
civitai_url = f"https://civitai.com/models/{model_id}"
if version_id:
civitai_url += f"?modelVersionId={version_id}"
civitai_host = self.settings.get("civitai_host", "civitai.com")
civitai_url = build_civitai_model_page_url(
model_id,
version_id,
host=civitai_host,
)
return {
"civitai_url": civitai_url,

View File

@@ -1,3 +1,4 @@
import asyncio
import json
import logging
import os
@@ -36,6 +37,9 @@ class CheckpointScanner(ModelScanner):
file_extensions=file_extensions,
hash_index=ModelHashIndex(),
)
if not hasattr(self, "_hash_calculation_lock"):
self._hash_calculation_lock = asyncio.Lock()
self._hash_calculation_tasks: dict[str, asyncio.Task[Optional[str]]] = {}
async def _create_default_metadata(
self, file_path: str
@@ -88,7 +92,7 @@ class CheckpointScanner(ModelScanner):
return None
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
"""Calculate hash for a checkpoint on-demand.
"""Calculate hash for a checkpoint on-demand with per-file singleflight.
Args:
file_path: Path to the model file
@@ -96,14 +100,65 @@ class CheckpointScanner(ModelScanner):
Returns:
SHA256 hash string, or None if calculation failed
"""
from ..utils.file_utils import calculate_sha256
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found for hash calculation: {file_path}")
return None
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if (
metadata is not None
and metadata.hash_status == "completed"
and metadata.sha256
):
return metadata.sha256
async with self._hash_calculation_lock:
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if (
metadata is not None
and metadata.hash_status == "completed"
and metadata.sha256
):
return metadata.sha256
task = self._hash_calculation_tasks.get(real_path)
if task is None:
task = asyncio.create_task(
self._run_hash_calculation_task(file_path, real_path)
)
self._hash_calculation_tasks[real_path] = task
return await asyncio.shield(task)
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
return None
async def _run_hash_calculation_task(
self, file_path: str, real_path: str
) -> Optional[str]:
"""Run a hash calculation task and remove it from the in-flight map."""
try:
return await self._calculate_hash_for_model_uncached(file_path, real_path)
finally:
task = asyncio.current_task()
async with self._hash_calculation_lock:
if self._hash_calculation_tasks.get(real_path) is task:
del self._hash_calculation_tasks[real_path]
async def _calculate_hash_for_model_uncached(
self, file_path: str, real_path: str
) -> Optional[str]:
"""Calculate hash for a checkpoint without checking in-flight tasks."""
from ..utils.file_utils import calculate_sha256
try:
# Load current metadata
metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.model_class

View File

@@ -42,6 +42,7 @@ class CheckpointService(BaseModelService):
"notes": checkpoint_data.get("notes", ""),
"sub_type": sub_type,
"favorite": checkpoint_data.get("favorite", False),
"exclude": bool(checkpoint_data.get("exclude", False)),
"update_available": bool(checkpoint_data.get("update_available", False)),
"skip_metadata_refresh": bool(checkpoint_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(checkpoint_data.get("civitai", {}), minimal=True)

View File

@@ -30,7 +30,7 @@ class CivitaiBaseModelService:
DEFAULT_CACHE_TTL = 7 * 24 * 60 * 60
# Civitai API endpoint for enums
CIVITAI_ENUMS_URL = "https://civitai.com/api/v1/enums"
CIVITAI_ENUMS_URL = "https://civitai.red/api/v1/enums"
@classmethod
async def get_instance(cls) -> CivitaiBaseModelService:

View File

@@ -3,6 +3,11 @@ import copy
import logging
import os
from typing import Any, Optional, Dict, Tuple, List, Sequence
from .connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE,
is_expected_offline_error,
is_offline_cooldown_error,
)
from .model_metadata_provider import (
CivitaiModelMetadataProvider,
ModelMetadataProviderManager,
@@ -39,7 +44,10 @@ class CivitaiClient:
return
self._initialized = True
self.base_url = "https://civitai.com/api/v1"
self.base_url = "https://civitai.red/api/v1"
def _build_image_info_url(self, image_id: str) -> str:
return f"{self.base_url}/images?imageId={image_id}&nsfw=X"
async def _make_request(
self,
@@ -62,6 +70,8 @@ class CivitaiClient:
if result.provider is None:
result.provider = "civitai_api"
raise result
if not success and is_offline_cooldown_error(result):
return False, OFFLINE_FRIENDLY_MESSAGE
return success, result
@staticmethod
@@ -121,6 +131,8 @@ class CivitaiClient:
)
if not success:
message = str(version)
if is_expected_offline_error(message):
return None, OFFLINE_FRIENDLY_MESSAGE
if "not found" in message.lower():
return None, "Model not found"
@@ -161,6 +173,9 @@ class CivitaiClient:
return True
return False
except Exception as e:
if is_expected_offline_error(str(e)):
logger.debug("Preview download skipped due to offline state.")
return False
logger.error(f"Download Error: {str(e)}")
return False
@@ -190,7 +205,9 @@ class CivitaiClient:
"""Get all versions of a model with local availability info"""
try:
success, result = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
"GET",
f"{self.base_url}/models/{model_id}",
use_auth=True,
)
if success:
# Also return model type along with versions
@@ -202,6 +219,9 @@ class CivitaiClient:
message = self._extract_error_message(result)
if message and "not found" in message.lower():
raise ResourceNotFoundError(f"Resource not found for model {model_id}")
if is_expected_offline_error(message):
logger.info("Civitai request skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
return None
if message:
raise RuntimeError(message)
return None
@@ -346,10 +366,14 @@ class CivitaiClient:
async def _fetch_model_data(self, model_id: int) -> Optional[Dict]:
success, data = await self._make_request(
"GET", f"{self.base_url}/models/{model_id}", use_auth=True
"GET",
f"{self.base_url}/models/{model_id}",
use_auth=True,
)
if success:
return data
if is_expected_offline_error(data):
return None
logger.warning(f"Failed to fetch model data for model {model_id}")
return None
@@ -358,10 +382,14 @@ class CivitaiClient:
return None
success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/{version_id}", use_auth=True
"GET",
f"{self.base_url}/model-versions/{version_id}",
use_auth=True,
)
if success:
return version
if is_expected_offline_error(version):
return None
logger.warning(f"Failed to fetch version by id {version_id}")
return None
@@ -371,10 +399,14 @@ class CivitaiClient:
return None
success, version = await self._make_request(
"GET", f"{self.base_url}/model-versions/by-hash/{model_hash}", use_auth=True
"GET",
f"{self.base_url}/model-versions/by-hash/{model_hash}",
use_auth=True,
)
if success:
return version
if is_expected_offline_error(version):
return None
logger.warning(f"Failed to fetch version by hash {model_hash}")
return None
@@ -453,17 +485,17 @@ class CivitaiClient:
try:
url = f"{self.base_url}/model-versions/{version_id}"
logger.debug(f"Resolving DNS for model version info: {url}")
logger.debug("Resolving Civitai model version info: %s", url)
success, result = await self._make_request("GET", url, use_auth=True)
if success:
logger.debug(
f"Successfully fetched model version info for: {version_id}"
)
logger.debug("Successfully fetched model version info for: %s", version_id)
self._remove_comfy_metadata(result)
return result, None
# Handle specific error cases
if is_expected_offline_error(result):
return None, OFFLINE_FRIENDLY_MESSAGE
if "not found" in str(result):
error_msg = f"Model not found"
logger.warning(f"Model version not found: {version_id} - {error_msg}")
@@ -479,48 +511,60 @@ class CivitaiClient:
logger.error(error_msg)
return None, error_msg
async def get_image_info(self, image_id: str) -> Optional[Dict]:
async def get_image_info(
self, image_id: str, source_url: str | None = None
) -> Optional[Dict]:
"""Fetch image information from Civitai API
Args:
image_id: The Civitai image ID
source_url: Original image page URL. Accepted for caller compatibility;
API requests always target ``civitai.red``.
Returns:
Optional[Dict]: The image data or None if not found
"""
try:
url = f"{self.base_url}/images?imageId={image_id}&nsfw=X"
requested_id = int(image_id)
logger.debug(f"Fetching image info for ID: {image_id}")
url = self._build_image_info_url(image_id)
success, result = await self._make_request("GET", url, use_auth=True)
if success:
if result and "items" in result and isinstance(result["items"], list):
items = result["items"]
# First, try to find the item with matching ID
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(f"Successfully fetched image info for ID: {image_id}")
return item
# No matching ID found - log warning with details about returned items
returned_ids = [
item.get("id") for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
f"CivitAI API returned no matching image for requested ID {image_id}. "
f"Returned {len(items)} item(s) with IDs: {returned_ids}. "
f"This may indicate the image was deleted, hidden, or there is a database lag."
)
if not success:
if is_expected_offline_error(result):
return None
logger.warning(f"No image found with ID: {image_id}")
logger.error(
"Failed to fetch image info for ID %s from civitai.red: %s",
image_id,
result,
)
return None
logger.error(f"Failed to fetch image info for ID: {image_id}: {result}")
if result and "items" in result and isinstance(result["items"], list):
items = result["items"]
for item in items:
if isinstance(item, dict) and item.get("id") == requested_id:
logger.debug(
"Successfully fetched image info for ID %s from civitai.red",
image_id,
)
return item
returned_ids = [
item.get("id")
for item in items
if isinstance(item, dict) and "id" in item
]
logger.warning(
"CivitAI API returned no matching image for requested ID %s from civitai.red. Returned %d item(s) with IDs: %s. This may indicate the image was deleted, hidden, or there is a database lag.",
image_id,
len(items),
returned_ids,
)
return None
logger.warning("No image found with ID: %s", image_id)
return None
except RateLimitError:
raise
@@ -539,10 +583,17 @@ class CivitaiClient:
return None
try:
url = f"{self.base_url}/models?username={username}"
success, result = await self._make_request("GET", url, use_auth=True)
success, result = await self._make_request(
"GET",
f"{self.base_url}/models",
use_auth=True,
params={"username": username},
)
if not success:
if is_expected_offline_error(result):
logger.info("User model fetch skipped: %s", OFFLINE_FRIENDLY_MESSAGE)
return None
logger.error("Failed to fetch models for %s: %s", username, result)
return None

View File

@@ -0,0 +1,204 @@
"""In-memory connectivity guard to suppress repeated network retries when offline."""
from __future__ import annotations
import asyncio
import errno
import logging
import socket
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
OFFLINE_COOLDOWN_ERROR = "offline_cooldown"
OFFLINE_FRIENDLY_MESSAGE = "Network offline, will retry automatically later"
def is_offline_cooldown_error(value: Any) -> bool:
"""Return True when a response payload represents guard short-circuit."""
return isinstance(value, str) and value == OFFLINE_COOLDOWN_ERROR
def is_expected_offline_error(value: Any) -> bool:
"""Return True when payload is an expected offline-related result."""
if is_offline_cooldown_error(value):
return True
if not isinstance(value, str):
return False
normalized = value.lower()
return "network offline" in normalized or "offline" in normalized
class ConnectivityGuard:
"""Tracks network failures and gates outbound requests during cooldown."""
_instance: "ConnectivityGuard | None" = None
_instance_lock = asyncio.Lock()
@classmethod
async def get_instance(cls) -> "ConnectivityGuard":
async with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self) -> None:
if hasattr(self, "_initialized"):
return
self._initialized = True
self._default_destination = "__global__"
self._destination_states: dict[str, _DestinationState] = {
self._default_destination: _DestinationState()
}
self.base_backoff_seconds = 30
self.max_backoff_seconds = 300
self.failure_threshold = 3
@property
def online(self) -> bool:
return self._state_for_destination(None).online
@online.setter
def online(self, value: bool) -> None:
self._state_for_destination(None).online = value
@property
def failure_count(self) -> int:
return self._state_for_destination(None).failure_count
@failure_count.setter
def failure_count(self, value: int) -> None:
self._state_for_destination(None).failure_count = value
@property
def cooldown_until(self) -> datetime | None:
return self._state_for_destination(None).cooldown_until
@cooldown_until.setter
def cooldown_until(self, value: datetime | None) -> None:
self._state_for_destination(None).cooldown_until = value
def _now(self) -> datetime:
return datetime.now()
def _normalize_destination(self, destination: str | None) -> str:
if destination is None or not destination.strip():
return self._default_destination
return destination.lower().strip()
def _state_for_destination(self, destination: str | None) -> "_DestinationState":
destination_key = self._normalize_destination(destination)
if destination_key not in self._destination_states:
self._destination_states[destination_key] = _DestinationState()
return self._destination_states[destination_key]
def in_cooldown(self, destination: str | None = None) -> bool:
state = self._state_for_destination(destination)
if state.cooldown_until is None:
return False
return self._now() < state.cooldown_until
def cooldown_remaining_seconds(self, destination: str | None = None) -> float:
state = self._state_for_destination(destination)
if state.cooldown_until is None:
return 0.0
return max(0.0, (state.cooldown_until - self._now()).total_seconds())
def should_block_request(self, destination: str | None = None) -> bool:
return self.in_cooldown(destination)
def register_success(self, destination: str | None = None) -> None:
destination_key = self._normalize_destination(destination)
state = self._state_for_destination(destination_key)
was_offline = (not state.online) or state.cooldown_until is not None
state.online = True
state.failure_count = 0
state.cooldown_until = None
if was_offline:
logger.info(
"Connectivity restored for destination '%s'; requests resumed.",
destination_key,
)
def register_network_failure(
self, exc: Exception, destination: str | None = None
) -> None:
destination_key = self._normalize_destination(destination)
state = self._state_for_destination(destination_key)
state.online = False
state.failure_count += 1
if state.failure_count < self.failure_threshold:
logger.debug(
"Network failure tracked for destination '%s' (%d/%d): %s",
destination_key,
state.failure_count,
self.failure_threshold,
exc,
)
return
retry_step = state.failure_count - self.failure_threshold
backoff = min(
self.max_backoff_seconds,
self.base_backoff_seconds * (2**retry_step),
)
should_log_warning = not self.in_cooldown(destination_key)
state.cooldown_until = self._now() + timedelta(seconds=backoff)
if should_log_warning:
logger.warning(
"Connectivity offline for destination '%s'; enter cooldown for %ss after %d network failures.",
destination_key,
int(backoff),
state.failure_count,
)
else:
logger.debug(
"Cooldown still active for destination '%s'; failure_count=%d, backoff=%ss.",
destination_key,
state.failure_count,
int(backoff),
)
@staticmethod
def is_network_unreachable_error(exc: Exception) -> bool:
"""Return whether the exception should count as connectivity failure."""
if isinstance(exc, asyncio.CancelledError):
return False
if isinstance(
exc,
(
asyncio.TimeoutError,
TimeoutError,
ConnectionRefusedError,
socket.gaierror,
aiohttp.ServerTimeoutError,
aiohttp.ConnectionTimeoutError,
aiohttp.ClientConnectorError,
aiohttp.ClientConnectionError,
),
):
return True
if isinstance(exc, OSError) and exc.errno in {
errno.ENETUNREACH,
errno.EHOSTUNREACH,
errno.ETIMEDOUT,
errno.ECONNREFUSED,
}:
return True
return False
@dataclass
class _DestinationState:
online: bool = True
failure_count: int = 0
cooldown_until: datetime | None = None

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,14 @@ from collections import deque
from dataclasses import dataclass
from datetime import datetime, timedelta
from email.utils import parsedate_to_datetime
from urllib.parse import urlparse
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
from ..services.settings_manager import get_settings_manager
from .connectivity_guard import (
OFFLINE_COOLDOWN_ERROR,
OFFLINE_FRIENDLY_MESSAGE,
ConnectivityGuard,
)
from .errors import RateLimitError
logger = logging.getLogger(__name__)
@@ -138,7 +144,7 @@ class Downloader:
self.chunk_size = (
16 * 1024 * 1024
) # 16MB chunks to balance I/O reduction and memory usage
self.max_retries = 5
self.max_retries = self._resolve_max_retries()
self.base_delay = 2.0 # Base delay for exponential backoff
self.session_timeout = 300 # 5 minutes
self.stall_timeout = self._resolve_stall_timeout()
@@ -192,6 +198,18 @@ class Downloader:
return max(30.0, timeout_value)
def _resolve_max_retries(self) -> int:
"""Determine max retry count from environment while preserving defaults."""
default_retries = 5
raw_value = os.environ.get("COMFYUI_DOWNLOAD_MAX_RETRIES")
try:
retries = int(raw_value)
except (TypeError, ValueError):
retries = default_retries
return max(0, retries)
def _should_refresh_session(self) -> bool:
"""Check if session should be refreshed"""
if self._session is None:
@@ -334,6 +352,7 @@ class Downloader:
logger.info(f"Resuming download from offset {resume_offset} bytes")
total_size = 0
range_redirect_retry_urls: set[str] = set()
while retry_count <= self.max_retries:
try:
@@ -372,6 +391,23 @@ class Downloader:
if response.status == 200:
# Full content response
if resume_offset > 0:
redirected_url = str(response.url)
if (
allow_resume
and response.history
and redirected_url
and redirected_url != url
and redirected_url not in range_redirect_retry_urls
):
range_redirect_retry_urls.add(redirected_url)
logger.info(
"Range request was not honored after redirect; retrying final URL directly: %s",
redirected_url,
)
url = redirected_url
response.release()
continue
# Server doesn't support ranges, restart from beginning
logger.warning(
"Server doesn't support range requests, restarting download"
@@ -571,37 +607,53 @@ class Downloader:
expected_size = total_size if total_size > 0 else None
integrity_error: Optional[str] = None
resumable_incomplete = False
if final_size <= 0:
integrity_error = "Downloaded file is empty"
elif expected_size is not None and final_size != expected_size:
integrity_error = f"File size mismatch. Expected: {expected_size}, Got: {final_size}"
resumable_incomplete = (
allow_resume
and part_path != save_path
and final_size > 0
and final_size < expected_size
)
if integrity_error is not None:
logger.error(
log_fn = logger.warning if resumable_incomplete else logger.error
log_fn(
"Download integrity check failed for %s: %s",
save_path,
integrity_error,
)
# Remove the corrupted payload so future attempts start fresh
if os.path.exists(part_path):
try:
os.remove(part_path)
except OSError as remove_error:
logger.warning(
"Failed to delete corrupted download %s: %s",
part_path,
remove_error,
)
if part_path != save_path and os.path.exists(save_path):
try:
os.remove(save_path)
except OSError as remove_error:
logger.warning(
"Failed to delete target file %s after integrity error: %s",
save_path,
remove_error,
)
if resumable_incomplete:
logger.info(
"Preserving incomplete download for resume: %s (%s/%s bytes)",
part_path,
final_size,
expected_size,
)
else:
# Remove corrupted payloads that cannot be safely resumed.
if os.path.exists(part_path):
try:
os.remove(part_path)
except OSError as remove_error:
logger.warning(
"Failed to delete corrupted download %s: %s",
part_path,
remove_error,
)
if part_path != save_path and os.path.exists(save_path):
try:
os.remove(save_path)
except OSError as remove_error:
logger.warning(
"Failed to delete target file %s after integrity error: %s",
save_path,
remove_error,
)
retry_count += 1
if retry_count <= self.max_retries:
@@ -611,8 +663,16 @@ class Downloader:
delay,
)
await asyncio.sleep(delay)
resume_offset = 0
total_size = 0
if resumable_incomplete and os.path.exists(part_path):
resume_offset = os.path.getsize(part_path)
total_size = expected_size or 0
logger.info(
"Will resume incomplete download from byte %s",
resume_offset,
)
else:
resume_offset = 0
total_size = 0
await self._create_session()
continue
@@ -743,6 +803,11 @@ class Downloader:
Returns:
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested)
"""
guard = await ConnectivityGuard.get_instance()
destination = self._guard_destination(url)
if guard.should_block_request(destination):
return False, OFFLINE_FRIENDLY_MESSAGE, None
try:
session = await self.session
# Debug log for proxy mode at request time
@@ -765,6 +830,7 @@ class Downloader:
) as response:
if response.status == 200:
content = await response.read()
guard.register_success(destination)
if return_headers:
return True, content, dict(response.headers)
else:
@@ -783,6 +849,12 @@ class Downloader:
return False, error_msg, None
except Exception as e:
if guard.is_network_unreachable_error(e):
guard.register_network_failure(e, destination)
if guard.should_block_request(destination):
return False, OFFLINE_FRIENDLY_MESSAGE, None
logger.debug("Network unavailable during memory download: %s", e)
return False, str(e), None
logger.error(f"Error downloading to memory from {url}: {e}")
return False, str(e), None
@@ -803,6 +875,11 @@ class Downloader:
Returns:
Tuple[bool, Union[Dict, str]]: (success, headers dict or error message)
"""
guard = await ConnectivityGuard.get_instance()
destination = self._guard_destination(url)
if guard.should_block_request(destination):
return False, OFFLINE_COOLDOWN_ERROR
try:
session = await self.session
# Debug log for proxy mode at request time
@@ -824,11 +901,18 @@ class Downloader:
url, headers=headers, proxy=self.proxy_url
) as response:
if response.status == 200:
guard.register_success(destination)
return True, dict(response.headers)
else:
return False, f"Head request failed with status {response.status}"
except Exception as e:
if guard.is_network_unreachable_error(e):
guard.register_network_failure(e, destination)
if guard.should_block_request(destination):
return False, OFFLINE_COOLDOWN_ERROR
logger.debug("Network unavailable during header probe: %s", e)
return False, str(e)
logger.error(f"Error getting headers from {url}: {e}")
return False, str(e)
@@ -853,6 +937,11 @@ class Downloader:
Returns:
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
"""
guard = await ConnectivityGuard.get_instance()
destination = self._guard_destination(url)
if guard.should_block_request(destination):
return False, OFFLINE_COOLDOWN_ERROR
try:
session = await self.session
# Debug log for proxy mode at request time
@@ -876,6 +965,7 @@ class Downloader:
method, url, headers=headers, **kwargs
) as response:
if response.status == 200:
guard.register_success(destination)
# Try to parse as JSON, fall back to text
try:
data = await response.json()
@@ -906,6 +996,12 @@ class Downloader:
return False, f"Request failed with status {response.status}"
except Exception as e:
if guard.is_network_unreachable_error(e):
guard.register_network_failure(e, destination)
if guard.should_block_request(destination):
return False, OFFLINE_COOLDOWN_ERROR
logger.debug("Network unavailable for %s %s: %s", method, url, e)
return False, str(e)
logger.error(f"Error making {method} request to {url}: {e}")
return False, str(e)
@@ -956,6 +1052,14 @@ class Downloader:
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
return max(0.0, delta.total_seconds())
@staticmethod
def _guard_destination(url: str) -> str:
"""Build per-destination connectivity guard scope from request URL."""
parsed_url = urlparse(url)
if parsed_url.hostname:
return parsed_url.hostname.lower()
return "unknown"
# Global instance accessor
async def get_downloader() -> Downloader:

View File

@@ -42,6 +42,7 @@ class EmbeddingService(BaseModelService):
"notes": embedding_data.get("notes", ""),
"sub_type": sub_type,
"favorite": embedding_data.get("favorite", False),
"exclude": bool(embedding_data.get("exclude", False)),
"update_available": bool(embedding_data.get("update_available", False)),
"skip_metadata_refresh": bool(embedding_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(embedding_data.get("civitai", {}), minimal=True)

View File

@@ -48,6 +48,7 @@ class LoraService(BaseModelService):
"usage_tips": lora_data.get("usage_tips", ""),
"notes": lora_data.get("notes", ""),
"favorite": lora_data.get("favorite", False),
"exclude": bool(lora_data.get("exclude", False)),
"update_available": bool(lora_data.get("update_available", False)),
"skip_metadata_refresh": bool(
lora_data.get("skip_metadata_refresh", False)

View File

@@ -11,6 +11,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
from ..services.settings_manager import SettingsManager
from ..utils.civitai_utils import resolve_license_payload
from ..utils.model_utils import determine_base_model
from .connectivity_guard import OFFLINE_FRIENDLY_MESSAGE, is_expected_offline_error
from .errors import RateLimitError
logger = logging.getLogger(__name__)
@@ -274,11 +275,18 @@ class MetadataSyncService:
else "No provider returned metadata"
)
resolved_error = last_error or default_error
if is_expected_offline_error(resolved_error):
resolved_error = OFFLINE_FRIENDLY_MESSAGE
error_msg = (
f"Error fetching metadata: {last_error or default_error} "
f"Error fetching metadata: {resolved_error} "
f"(model_name={model_data.get('model_name', '')})"
)
logger.error(error_msg)
if is_expected_offline_error(resolved_error):
logger.info(error_msg)
else:
logger.error(error_msg)
return False, error_msg
model_data["from_civitai"] = True
@@ -347,6 +355,9 @@ class MetadataSyncService:
return False, error_msg
except Exception as exc: # pragma: no cover - error path
error_msg = f"Error fetching metadata: {exc}"
if is_expected_offline_error(str(exc)):
logger.info(OFFLINE_FRIENDLY_MESSAGE)
return False, OFFLINE_FRIENDLY_MESSAGE
logger.error(error_msg, exc_info=True)
return False, error_msg

View File

@@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Opti
from ..services.service_registry import ServiceRegistry
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.metadata_manager import MetadataManager
logger = logging.getLogger(__name__)
@@ -207,11 +208,56 @@ class ModelLifecycleService:
excluded = getattr(self._scanner, "_excluded_models", None)
if isinstance(excluded, list):
excluded.append(file_path)
if file_path not in excluded:
excluded.append(file_path)
persist_current_cache = getattr(self._scanner, "_persist_current_cache", None)
if callable(persist_current_cache):
await persist_current_cache()
message = f"Model {os.path.basename(file_path)} excluded"
return {"success": True, "message": message}
async def unexclude_model(self, file_path: str) -> Dict[str, object]:
"""Restore a previously excluded model to the active cache."""
if not file_path:
raise ValueError("Model path is required")
if not os.path.exists(file_path):
raise ValueError("Model file does not exist")
metadata_path = os.path.splitext(file_path)[0] + ".metadata.json"
metadata_payload = await self._metadata_loader(metadata_path)
metadata_payload["exclude"] = False
await self._metadata_manager.save_metadata(file_path, metadata_payload)
metadata, should_skip = await MetadataManager.load_metadata(
file_path,
self._scanner.model_class,
)
if should_skip:
metadata = None
if metadata is None:
metadata = metadata_payload
excluded = getattr(self._scanner, "_excluded_models", None)
if isinstance(excluded, list):
self._scanner._excluded_models = [
path for path in excluded if path != file_path
]
await self._scanner.update_single_model_cache(
file_path,
file_path,
metadata,
recalculate_type=True,
)
message = f"Model {os.path.basename(file_path)} restored"
return {"success": True, "message": message}
async def bulk_delete_models(self, file_paths: Iterable[str]) -> Dict[str, object]:
"""Delete a collection of models via the scanner bulk operation."""

View File

@@ -1535,7 +1535,7 @@ class ModelScanner:
return sorted_tags[:limit]
async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]:
"""Get base models sorted by frequency"""
"""Get base models sorted by count. If limit is 0, return all."""
cache = await self.get_cached_data()
base_model_counts = {}
@@ -1546,7 +1546,9 @@ class ModelScanner:
sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()]
sorted_models.sort(key=lambda x: x['count'], reverse=True)
if limit == 0:
return sorted_models
return sorted_models[:limit]
async def get_model_info_by_name(self, name):

View File

@@ -1815,6 +1815,15 @@ class RecipeScanner:
return await self._lora_scanner.get_model_info_by_name(name)
async def get_local_checkpoint(self, name: str) -> Optional[Dict[str, Any]]:
"""Lookup a local checkpoint model by name."""
checkpoint_scanner = getattr(self, "_checkpoint_scanner", None)
if not checkpoint_scanner or not name:
return None
return await checkpoint_scanner.get_model_info_by_name(name)
async def get_paginated_data(
self,
page: int,

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import base64
import io
import os
import re
import tempfile
from dataclasses import dataclass
from typing import Any, Callable, Optional
@@ -14,7 +13,7 @@ import numpy as np
from PIL import Image
from ...utils.utils import calculate_recipe_fingerprint
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.civitai_utils import extract_civitai_image_id, rewrite_preview_url
from .errors import (
RecipeDownloadError,
RecipeNotFoundError,
@@ -104,9 +103,11 @@ class RecipeAnalysisService:
extension = ".jpg" # Default
try:
civitai_match = re.match(r"https://civitai\.com/images/(\d+)", url)
if civitai_match:
image_info = await civitai_client.get_image_info(civitai_match.group(1))
civitai_image_id = extract_civitai_image_id(url)
if civitai_image_id:
image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=url
)
if not image_info:
raise RecipeDownloadError(
"Failed to fetch image information from Civitai"

View File

@@ -508,6 +508,10 @@ class RecipePersistenceService:
most_common_base_model = (
max(base_model_counts.items(), key=lambda item: item[1])[0] if base_model_counts else ""
)
checkpoint_entry = await self._build_widget_checkpoint_entry(
recipe_scanner,
metadata.get("checkpoint"),
)
recipe_data = {
"id": recipe_id,
@@ -515,9 +519,8 @@ class RecipePersistenceService:
"title": recipe_name,
"modified": time.time(),
"created_date": time.time(),
"base_model": most_common_base_model,
"base_model": most_common_base_model or (checkpoint_entry or {}).get("baseModel", ""),
"loras": loras_data,
"checkpoint": self._sanitize_checkpoint_entry(metadata.get("checkpoint", "")),
"gen_params": {
key: value
for key, value in metadata.items()
@@ -525,6 +528,8 @@ class RecipePersistenceService:
},
"loras_stack": lora_stack,
}
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry
json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename)
@@ -546,6 +551,91 @@ class RecipePersistenceService:
# Helper methods ---------------------------------------------------
async def _build_widget_checkpoint_entry(
self,
recipe_scanner,
checkpoint_raw: Any,
) -> Optional[dict[str, Any]]:
"""Build recipe checkpoint metadata from widget generation metadata."""
if isinstance(checkpoint_raw, dict):
return self._sanitize_checkpoint_entry(checkpoint_raw)
if not isinstance(checkpoint_raw, str):
return None
checkpoint_name = checkpoint_raw.strip()
if not checkpoint_name:
return None
file_name = os.path.splitext(os.path.basename(checkpoint_name))[0]
checkpoint_info = await self._lookup_widget_checkpoint(
recipe_scanner,
checkpoint_name,
)
if not checkpoint_info:
return {
"type": "checkpoint",
"name": checkpoint_name,
"file_name": file_name,
"hash": "",
}
civitai = checkpoint_info.get("civitai") or {}
civitai_model = civitai.get("model") or {}
file_path = checkpoint_info.get("file_path") or checkpoint_info.get("path") or ""
cached_file_name = (
checkpoint_info.get("file_name")
or (os.path.splitext(os.path.basename(file_path))[0] if file_path else "")
or file_name
)
return {
"type": "checkpoint",
"modelId": civitai_model.get("id", 0),
"modelVersionId": civitai.get("id", 0),
"name": civitai_model.get("name") or checkpoint_info.get("model_name") or checkpoint_name,
"version": civitai.get("name", ""),
"hash": (checkpoint_info.get("sha256") or checkpoint_info.get("hash") or "").lower(),
"file_name": cached_file_name,
"modelName": civitai_model.get("name", ""),
"modelVersionName": civitai.get("name", ""),
"baseModel": checkpoint_info.get("base_model") or civitai.get("baseModel", ""),
}
async def _lookup_widget_checkpoint(
self,
recipe_scanner,
checkpoint_name: str,
) -> Optional[dict[str, Any]]:
lookup = getattr(recipe_scanner, "get_local_checkpoint", None)
if not callable(lookup):
return None
candidates = []
for candidate in (
checkpoint_name,
os.path.basename(checkpoint_name),
os.path.splitext(os.path.basename(checkpoint_name))[0],
):
if candidate and candidate not in candidates:
candidates.append(candidate)
for candidate in candidates:
try:
checkpoint_info = await lookup(candidate)
except Exception as exc:
self._logger.debug(
"Failed to lookup checkpoint %s while saving widget recipe: %s",
candidate,
exc,
)
continue
if checkpoint_info:
return checkpoint_info
return None
def _extract_checkpoint_entry(self, metadata: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Pull a checkpoint entry from various metadata locations."""

View File

@@ -2,6 +2,7 @@ import asyncio
import copy
import json
import os
import posixpath
import shutil
import tempfile
import logging
@@ -54,6 +55,9 @@ DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
DEFAULT_SETTINGS: Dict[str, Any] = {
"civitai_api_key": "",
"civitai_host": "civitai.com",
"download_backend": "python",
"aria2c_path": "",
"use_portable_settings": False,
"hash_chunk_size_mb": DEFAULT_HASH_CHUNK_SIZE_MB,
"language": "en",
@@ -100,6 +104,15 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
}
def _normalize_root_identity(path: str) -> str:
"""Normalize a root path for equality checks across slash styles."""
normalized = posixpath.normpath(path.strip().replace("\\", "/"))
if len(normalized) >= 2 and normalized[1] == ":":
return normalized.lower()
return normalized
class SettingsManager:
def __init__(self):
self.settings_file = ensure_settings_file(logger)
@@ -760,34 +773,29 @@ class SettingsManager:
if self._preserve_disk_template:
return
folder_paths = self.settings.get("folder_paths", {})
updated = False
def _check_and_auto_set(key: str, setting_key: str) -> bool:
"""Repair default roots when empty or no longer present."""
current = self.settings.get(setting_key, "")
candidates = folder_paths.get(key, [])
if not isinstance(candidates, list) or not candidates:
primary_candidates = self._get_valid_root_candidates(key)
if not primary_candidates:
return False
# Filter valid path strings
valid_paths = [p for p in candidates if isinstance(p, str) and p.strip()]
if not valid_paths:
allowed_roots = self._get_allowed_roots(key)
if current and _normalize_root_identity(current) in allowed_roots:
return False
if current in valid_paths:
return False
self.settings[setting_key] = valid_paths[0]
self.settings[setting_key] = primary_candidates[0]
if current:
logger.info(
"Repaired stale %s from '%s' to '%s'",
"Repaired stale %s from '%s' to '%s' because it is not present in primary or extra roots",
setting_key,
current,
valid_paths[0],
primary_candidates[0],
)
else:
logger.info("Auto-set %s to '%s'", setting_key, valid_paths[0])
logger.info("Auto-set %s to '%s'", setting_key, primary_candidates[0])
return True
# Process all model types
@@ -810,6 +818,36 @@ class SettingsManager:
else:
self._save_settings()
def _get_valid_root_candidates(self, key: str) -> List[str]:
"""Return stable root candidates, preferring primary roots over extra roots."""
candidates: List[str] = []
seen: set[str] = set()
for mapping_key in ("folder_paths", "extra_folder_paths"):
raw_paths = self.settings.get(mapping_key, {})
if not isinstance(raw_paths, Mapping):
continue
values = raw_paths.get(key, [])
if not isinstance(values, list):
continue
for value in values:
if not isinstance(value, str):
continue
normalized = value.strip()
if not normalized:
continue
identity = _normalize_root_identity(normalized)
if identity in seen:
continue
seen.add(identity)
candidates.append(normalized)
return candidates
def _get_allowed_roots(self, key: str) -> set[str]:
"""Return all valid roots for a model type, including extra roots."""
return {_normalize_root_identity(path) for path in self._get_valid_root_candidates(key)}
def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed"""
env_api_key = os.environ.get("CIVITAI_API_KEY")

View File

@@ -2,10 +2,13 @@
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, Mapping, Sequence
from urllib.parse import urlparse, urlunparse
from urllib.parse import parse_qs, urlparse, urlunparse
_SUPPORTED_CIVITAI_PAGE_HOSTS = frozenset({"civitai.com", "civitai.red"})
DEFAULT_CIVITAI_PAGE_HOST = "civitai.com"
_DEFAULT_ALLOW_COMMERCIAL_USE: Sequence[str] = ("Sell",)
_LICENSE_DEFAULTS: Dict[str, Any] = {
"allowNoCredit": True,
@@ -17,6 +20,133 @@ _COMMERCIAL_ALLOWED_VALUES = {"sell", "rent", "rentcivit", "image"}
_COMMERCIAL_SHIFT = 1
def is_supported_civitai_page_host(hostname: str | None) -> bool:
"""Return whether the hostname is a supported Civitai page domain."""
if not hostname:
return False
return hostname.lower() in _SUPPORTED_CIVITAI_PAGE_HOSTS
def normalize_civitai_page_host(hostname: str | None) -> str:
"""Return a supported Civitai page host or the default host."""
if not isinstance(hostname, str):
return DEFAULT_CIVITAI_PAGE_HOST
normalized = hostname.strip().lower()
if is_supported_civitai_page_host(normalized):
return normalized
return DEFAULT_CIVITAI_PAGE_HOST
def build_civitai_model_page_url(
model_id: str | int | None,
version_id: str | int | None = None,
*,
host: str | None = None,
) -> str | None:
"""Build a Civitai model or model-version page URL."""
normalized_host = normalize_civitai_page_host(host)
normalized_model_id = str(model_id).strip() if model_id is not None else ""
normalized_version_id = str(version_id).strip() if version_id is not None else ""
if normalized_model_id:
path = f"/models/{normalized_model_id}"
query = f"modelVersionId={normalized_version_id}" if normalized_version_id else ""
return urlunparse(("https", normalized_host, path, "", query, ""))
if normalized_version_id:
return urlunparse(
("https", normalized_host, f"/model-versions/{normalized_version_id}", "", "", "")
)
return None
def _parse_supported_civitai_page_url(url: str | None):
if not url:
return None
try:
parsed = urlparse(url)
except ValueError:
return None
if parsed.scheme not in {"http", "https"}:
return None
if not is_supported_civitai_page_host(parsed.hostname):
return None
return parsed
def extract_civitai_model_url_parts(
url: str | None,
) -> tuple[str | None, str | None]:
"""Extract model and version identifiers from a supported Civitai model URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None, None
path_match = re.search(r"/models/(\d+)", parsed.path)
if not path_match:
return None, None
model_id = path_match.group(1)
query_params = parse_qs(parsed.query)
version_values = query_params.get("modelVersionId") or []
version_id = version_values[0] if version_values else None
return model_id, version_id
def extract_civitai_image_id(url: str | None) -> str | None:
"""Extract the image identifier from a supported Civitai image page URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
path_match = re.search(r"/images/(\d+)", parsed.path)
if not path_match:
return None
return path_match.group(1)
def normalize_civitai_download_url(url: str | None) -> str | None:
"""Rewrite Civitai download URLs to the canonical authenticated host."""
if not url:
return url
try:
parsed = urlparse(url)
except ValueError:
return url
hostname = parsed.hostname.lower() if parsed.hostname else None
if hostname != "civitai.red" or not parsed.path.startswith("/api/download/"):
return url
return urlunparse(parsed._replace(netloc="civitai.com"))
def extract_civitai_page_host(url: str | None) -> str | None:
"""Extract the supported Civitai page host from a URL."""
parsed = _parse_supported_civitai_page_url(url)
if parsed is None:
return None
return parsed.hostname.lower() if parsed.hostname else None
def _normalize_commercial_values(value: Any) -> Sequence[str]:
"""Return a normalized list of commercial permissions preserving source values."""
@@ -199,6 +329,10 @@ def rewrite_preview_url(
__all__ = [
"build_license_flags",
"extract_civitai_image_id",
"extract_civitai_page_host",
"extract_civitai_model_url_parts",
"is_supported_civitai_page_host",
"resolve_license_payload",
"resolve_license_info",
"rewrite_preview_url",

View File

@@ -100,6 +100,7 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
DIFFUSION_MODEL_BASE_MODELS = frozenset(
[
"Anima",
"ZImageTurbo",
"ZImageBase",
"Wan Video 1.3B t2v",

View File

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

View File

@@ -243,3 +243,58 @@
-ms-user-select: none;
user-select: none;
}
.excluded-view-banner {
margin-bottom: var(--space-2);
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: linear-gradient(
135deg,
oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.08),
var(--card-bg)
);
}
.excluded-view-banner__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.excluded-view-banner__title {
display: inline-flex;
align-items: center;
gap: 10px;
font-weight: 600;
color: var(--text-color);
}
.excluded-view-banner__back {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-color);
background: var(--card-bg);
color: var(--text-color);
border-radius: var(--border-radius-xs);
padding: 8px 12px;
cursor: pointer;
}
.excluded-view-banner__back:hover {
border-color: var(--lora-accent);
color: var(--lora-accent);
}
@media (max-width: 768px) {
.excluded-view-banner__content {
flex-direction: column;
align-items: stretch;
}
.excluded-view-banner__back {
justify-content: center;
}
}

View File

@@ -680,3 +680,22 @@
margin-left: 0;
line-height: 1;
}
.excluded-model {
border-style: dashed;
}
.model-excluded-badge {
width: 16px;
height: 16px;
padding: 0;
border-radius: 3px;
background: color-mix(in oklab, var(--warning-color, #d97706) 85%, white 15%);
color: white;
font-size: 0.65rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.9;
}

View File

@@ -19,6 +19,7 @@
align-items: center;
justify-content: space-between;
height: 100%;
gap: 1rem;
}
/* Responsive header container for larger screens */
@@ -65,7 +66,6 @@
display: flex;
gap: 0.5rem;
flex-shrink: 0;
margin-right: 1rem;
}
.nav-item {
@@ -101,7 +101,6 @@
.header-search {
flex: 1;
max-width: 400px;
margin: 0 1rem;
transition: opacity 0.2s ease;
}
@@ -288,4 +287,4 @@
.header-search {
flex: 1;
}
}
}

View File

@@ -140,9 +140,11 @@
/* Add specific styles for notes content */
.info-item.notes .editable-field [contenteditable] {
height: 60px; /* Keep initial modal layout stable regardless of note length */
min-height: 60px; /* Increase height for multiple lines */
max-height: 150px; /* Limit maximum height */
overflow-y: auto; /* Add scrolling for long content */
max-height: 420px; /* Limit maximum height */
overflow: auto; /* Enable scrolling and resize handle for long content */
resize: vertical; /* Allow manual vertical resizing */
white-space: pre-wrap; /* Preserve line breaks */
line-height: 1.5; /* Improve readability */
padding: 8px 12px; /* Slightly increase padding */

View File

@@ -53,6 +53,10 @@
position: relative;
}
.trigger-word-tag:not(.is-editing) {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.trigger-word-content {
color: var(--lora-accent) !important;
font-size: 0.85em;
@@ -65,6 +69,38 @@
border-color: var(--lora-accent);
}
.trigger-words.edit-mode .trigger-word-tag {
cursor: text;
}
.trigger-word-tag.is-editing {
align-items: center;
flex: 0 1 min(var(--trigger-word-edit-width, 48ch), 100%);
width: min(var(--trigger-word-edit-width, 48ch), 100%);
height: var(--trigger-word-edit-height, auto);
border-color: var(--lora-accent);
transition: none;
}
.trigger-word-edit-input {
width: 100%;
height: 100%;
min-width: 0;
box-sizing: border-box;
padding: 1px 2px;
border: none;
resize: none;
overflow: auto;
outline: none;
background: transparent;
color: var(--lora-accent);
font: inherit;
font-size: 0.85em;
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.trigger-word-copy {
display: flex;
align-items: center;
@@ -109,4 +145,4 @@
padding: 2px 5px;
border-radius: 8px;
white-space: nowrap;
}
}

View File

@@ -346,11 +346,13 @@
.api-key-input input {
width: 100%;
padding: 6px 40px 6px 10px; /* Add left padding */
height: 20px;
height: 32px;
box-sizing: border-box;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
}
.api-key-input .toggle-visibility {
@@ -379,7 +381,8 @@
.text-input-wrapper input {
width: 100%;
padding: 6px 10px;
height: 20px;
height: 32px;
box-sizing: border-box;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
@@ -760,10 +763,12 @@
}
.setting-control {
width: 60%; /* Decreased slightly from 65% */
flex: 0 0 60%;
max-width: 60%;
margin-bottom: 0;
display: flex;
justify-content: flex-end; /* Right-align all controls */
min-width: 0;
}
/* Select Control Styles */
@@ -773,6 +778,13 @@
justify-content: flex-end;
}
.setting-control select,
.setting-control input[type="text"],
.setting-control input[type="password"],
.setting-control input[type="number"] {
font-size: 0.95em;
}
.select-control select {
width: 100%;
max-width: 100%; /* Increased from 200px */
@@ -781,8 +793,8 @@
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
height: 32px;
box-sizing: border-box;
}
/* Fix dark theme select dropdown text color */
@@ -888,8 +900,8 @@ input:checked + .toggle-slider:before {
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
height: 32px;
box-sizing: border-box;
}
/* Add warning text style for settings */

View File

@@ -145,7 +145,7 @@
position: fixed;
right: 20px;
top: 50px; /* Position below header */
width: 320px;
width: 366px;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-base);
@@ -197,6 +197,31 @@
margin-bottom: 16px;
}
.filter-search-input {
width: 100%;
box-sizing: border-box;
margin-bottom: 8px;
padding: 8px 10px;
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 13px;
}
.filter-search-input:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 76, 175, 80), 0.15);
}
.filter-empty-state {
margin-top: 8px;
font-size: 13px;
color: var(--text-color);
opacity: 0.7;
}
.filter-section h4 {
margin: 0 0 8px 0;
font-size: 14px;
@@ -733,4 +758,4 @@
right: 20px;
top: 160px; /* Adjusted for mobile layout */
}
}
}

View File

@@ -56,8 +56,10 @@ export function getApiEndpoints(modelType) {
return {
// Base CRUD operations
list: `/api/lm/${modelType}/list`,
excluded: `/api/lm/${modelType}/excluded`,
delete: `/api/lm/${modelType}/delete`,
exclude: `/api/lm/${modelType}/exclude`,
unexclude: `/api/lm/${modelType}/unexclude`,
rename: `/api/lm/${modelType}/rename`,
save: `/api/lm/${modelType}/save-metadata`,
cancelTask: `/api/lm/${modelType}/cancel-task`,

View File

@@ -51,6 +51,7 @@ export class BaseModelApiClient {
async fetchModelsPage(page = 1, pageSize = null) {
const pageState = this.getPageState();
const actualPageSize = pageSize || pageState.pageSize || this.apiConfig.config.defaultPageSize;
const isExcludedView = pageState.viewMode === 'excluded';
try {
const params = this._buildQueryParams({
@@ -71,7 +72,10 @@ export class BaseModelApiClient {
};
}
const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`);
const endpoint = isExcludedView
? this.apiConfig.endpoints.excluded
: this.apiConfig.endpoints.list;
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
}
@@ -84,7 +88,7 @@ export class BaseModelApiClient {
totalPages: data.total_pages,
currentPage: page,
hasMore: page < data.total_pages,
folders: data.folders
folders: data.folders || []
};
} catch (error) {
@@ -212,6 +216,50 @@ export class BaseModelApiClient {
}
}
async unexcludeModel(filePath) {
try {
state.loadingManager.showSimpleLoading(`Restoring ${this.apiConfig.config.singularName}...`);
const response = await fetch(this.apiConfig.endpoints.unexclude, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath })
});
if (!response.ok) {
throw new Error(`Failed to restore ${this.apiConfig.config.singularName}: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
showToast(
'toast.api.restoreSuccess',
{ type: this.apiConfig.config.displayName },
'success',
`Restored ${this.apiConfig.config.displayName}`
);
return true;
}
throw new Error(data.error || `Failed to restore ${this.apiConfig.config.singularName}`);
} catch (error) {
console.error(`Error restoring ${this.apiConfig.config.singularName}:`, error);
showToast(
'toast.api.restoreFailed',
{ type: this.apiConfig.config.singularName, message: error.message },
'error',
`Failed to restore ${this.apiConfig.config.singularName}: ${error.message}`
);
return false;
} finally {
state.loadingManager.hide();
}
}
async renameModelFile(filePath, newFileName) {
try {
state.loadingManager.showSimpleLoading(`Renaming ${this.apiConfig.config.singularName} file...`);
@@ -883,20 +931,21 @@ export class BaseModelApiClient {
_buildQueryParams(baseParams, pageState) {
const params = new URLSearchParams(baseParams);
const isExcludedView = pageState.viewMode === 'excluded';
if (pageState.activeFolder !== null) {
if (!isExcludedView && pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder);
}
if (pageState.showFavoritesOnly) {
if (!isExcludedView && pageState.showFavoritesOnly) {
params.append('favorites_only', 'true');
}
if (pageState.showUpdateAvailableOnly) {
if (!isExcludedView && pageState.showUpdateAvailableOnly) {
params.append('update_available_only', 'true');
}
if (this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
if (!isExcludedView && this.apiConfig.config.supportsLetterFilter && pageState.activeLetterFilter) {
params.append('first_letter', pageState.activeLetterFilter);
}
@@ -918,7 +967,7 @@ export class BaseModelApiClient {
params.append('recursive', pageState.searchOptions.recursive ? 'true' : 'false');
if (pageState.filters) {
if (!isExcludedView && pageState.filters) {
if (pageState.filters.tags && Object.keys(pageState.filters.tags).length > 0) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
if (state === 'include') {
@@ -981,7 +1030,9 @@ export class BaseModelApiClient {
}
}
this._addModelSpecificParams(params, pageState);
if (!isExcludedView) {
this._addModelSpecificParams(params, pageState);
}
return params;
}

View File

@@ -24,6 +24,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
// Update the "Move to other root" label based on current model type
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
@@ -83,6 +84,9 @@ export class CheckpointContextMenu extends BaseContextMenu {
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
}
}

View File

@@ -18,6 +18,11 @@ export class EmbeddingContextMenu extends BaseContextMenu {
async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data);
}
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
}
handleMenuAction(action) {
// First try to handle with common actions
@@ -56,6 +61,9 @@ export class EmbeddingContextMenu extends BaseContextMenu {
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
}
}
}

View File

@@ -22,6 +22,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
if (isRecipesPage) {
@@ -29,12 +30,14 @@ export class GlobalContextMenu extends BaseContextMenu {
licenseRefreshItem?.classList.add('hidden');
downloadExamplesItem?.classList.add('hidden');
cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
licenseRefreshItem?.classList.remove('hidden');
downloadExamplesItem?.classList.remove('hidden');
cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
repairRecipesItem?.classList.add('hidden');
}
@@ -68,12 +71,21 @@ export class GlobalContextMenu extends BaseContextMenu {
console.error('Failed to repair recipes:', error);
});
break;
case 'manage-excluded-models':
this.manageExcludedModels();
break;
default:
console.warn(`Unhandled global context menu action: ${action}`);
break;
}
}
manageExcludedModels() {
window.pageControls?.enterExcludedView?.().catch((error) => {
console.error('Failed to open excluded models view:', error);
});
}
async downloadExampleImages(menuItem) {
const downloadPath = state?.global?.settings?.example_images_path;
if (!downloadPath) {

View File

@@ -20,6 +20,11 @@ export class LoraContextMenu extends BaseContextMenu {
return getModelApiClient().saveModelMetadata(filePath, data);
}
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
}
handleMenuAction(action, menuItem) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -61,6 +66,9 @@ export class LoraContextMenu extends BaseContextMenu {
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
}
}

View File

@@ -6,9 +6,43 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
isExcludedView() {
return state?.pages?.[state.currentPageType]?.viewMode === 'excluded';
},
updateExcludeMenuItem() {
const excludeItem = this.menu?.querySelector('[data-action="exclude"], [data-action="restore"]');
if (!excludeItem) {
return;
}
const isExcludedView = this.isExcludedView();
excludeItem.dataset.action = isExcludedView ? 'restore' : 'exclude';
excludeItem.innerHTML = isExcludedView
? `<i class="fas fa-undo"></i> <span>${translate('loras.contextMenu.restoreModel', {}, 'Restore model')}</span>`
: `<i class="fas fa-eye-slash"></i> <span>${translate('loras.contextMenu.excludeModel', {}, 'Exclude model')}</span>`;
},
async restoreExcludedModel(filePath) {
const restored = await getModelApiClient().unexcludeModel(filePath);
if (!restored) {
return;
}
if (window.pageControls?.exitExcludedView) {
await window.pageControls.exitExcludedView();
} else {
const resetFn = this.resetAndReload || resetAndReload;
if (typeof resetFn === 'function') {
await resetFn(true);
}
}
},
// NSFW Selector methods
initNSFWSelector() {
if (this._nsfwSelectorInitialized) {
@@ -154,25 +188,7 @@ export const ModelContextMenuMixin = {
},
extractModelVersionId(url) {
try {
// Handle all three URL formats:
// 1. https://civitai.com/models/649516
// 2. https://civitai.com/models/649516?modelVersionId=726676
// 3. https://civitai.com/models/649516/cynthia-pokemon-diamond-and-pearl-pdxl-lora?modelVersionId=726676
const parsedUrl = new URL(url);
// Extract model ID from path
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
// Extract model version ID from query parameters
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
return extractCivitaiModelUrlParts(url);
},
parseModelId(value) {

View File

@@ -121,6 +121,7 @@ export class ModelDuplicatesManager {
}
this.duplicateGroups = data.duplicates || [];
this._pruneVerificationState();
// Update the badge with the current count
this.updateDuplicatesBadge(this.duplicateGroups.length);
@@ -402,6 +403,44 @@ export class ModelDuplicatesManager {
}
});
}
_getGroupFilePaths(group) {
return new Set((group?.models || []).map(model => model.file_path));
}
_clearMismatchStateForGroup(group) {
this._getGroupFilePaths(group).forEach(filePath => {
this.mismatchedFiles.delete(filePath);
});
}
_pruneVerificationState() {
const visiblePaths = new Set();
const visibleHashes = new Set();
this.duplicateGroups.forEach(group => {
visibleHashes.add(group.hash);
this._getGroupFilePaths(group).forEach(filePath => visiblePaths.add(filePath));
});
Array.from(this.mismatchedFiles.keys()).forEach(filePath => {
if (!visiblePaths.has(filePath)) {
this.mismatchedFiles.delete(filePath);
}
});
Array.from(this.selectedForDeletion).forEach(filePath => {
if (!visiblePaths.has(filePath)) {
this.selectedForDeletion.delete(filePath);
}
});
Array.from(this.verifiedGroups).forEach(hash => {
if (!visibleHashes.has(hash)) {
this.verifiedGroups.delete(hash);
}
});
}
renderModelCard(model, groupHash) {
// Create basic card structure
@@ -619,10 +658,11 @@ export class ModelDuplicatesManager {
toggleSelectAllInGroup(hash) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${hash}"]`);
const allSelected = Array.from(checkboxes).every(checkbox => checkbox.checked);
const selectableCheckboxes = Array.from(checkboxes).filter(checkbox => !checkbox.disabled);
const allSelected = selectableCheckboxes.length > 0 && selectableCheckboxes.every(checkbox => checkbox.checked);
// If all are selected, deselect all; otherwise select all
checkboxes.forEach(checkbox => {
selectableCheckboxes.forEach(checkbox => {
checkbox.checked = !allSelected;
const filePath = checkbox.dataset.filePath;
const card = checkbox.closest('.model-card');
@@ -830,11 +870,14 @@ export class ModelDuplicatesManager {
// Process verification results
const verifiedAsDuplicates = data.verified_as_duplicates;
const mismatchedFiles = data.mismatched_files || [];
this._clearMismatchStateForGroup(group);
// Update mismatchedFiles map
if (data.new_hash_map) {
Object.entries(data.new_hash_map).forEach(([path, hash]) => {
this.mismatchedFiles.set(path, hash);
this.selectedForDeletion.delete(path);
});
}
@@ -843,6 +886,7 @@ export class ModelDuplicatesManager {
// Re-render the duplicate groups to show verification status
this.renderDuplicateGroups();
this.updateSelectedCount();
// Show appropriate toast message
if (mismatchedFiles.length > 0) {

View File

@@ -1,7 +1,7 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, getSessionItem, setSessionItem } from '../../utils/storageHelpers.js';
import { showToast } from '../../utils/uiHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
@@ -38,8 +38,12 @@ export class PageControls {
// Initialize favorites filter button state
this.initFavoritesFilter();
this.initExcludedViewControls();
this.syncExcludedViewState();
console.log(`PageControls initialized for ${pageType} page`);
window.pageControls = this;
}
/**
@@ -56,6 +60,19 @@ export class PageControls {
// Load sort preference
this.loadSortPreference();
if (!this.pageState.viewMode) {
this.pageState.viewMode = 'active';
}
if (!this.pageState.excludedViewState) {
this.pageState.excludedViewState = {
sortBy: 'name:asc',
search: '',
};
}
if (!this.pageState.filters?.search) {
this.pageState.filters.search = '';
}
}
/**
@@ -116,6 +133,15 @@ export class PageControls {
// Page-specific event listeners
this.initPageSpecificListeners();
}
initExcludedViewControls() {
const backButton = document.getElementById('excludedViewBackBtn');
if (backButton) {
backButton.addEventListener('click', async () => {
await this.exitExcludedView();
});
}
}
/**
* Initialize dropdown functionality
@@ -334,6 +360,13 @@ export class PageControls {
* @param {string} sortValue - The sort value to save
*/
saveSortPreference(sortValue) {
if (this.pageState.viewMode === 'excluded') {
this.pageState.excludedViewState = {
...(this.pageState.excludedViewState || {}),
sortBy: sortValue,
};
return;
}
setStorageItem(`${this.pageType}_sort`, sortValue);
}
@@ -353,18 +386,8 @@ export class PageControls {
const metaData = JSON.parse(card.dataset.meta);
const civitaiId = metaData.modelId;
const versionId = metaData.id;
// Build URL
if (civitaiId) {
let url = `https://civitai.com/models/${civitaiId}`;
if (versionId) {
url += `?modelVersionId=${versionId}`;
}
window.open(url, '_blank');
} else {
// If no ID, try searching by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
}
openCivitaiByMetadata(civitaiId, versionId, modelName);
}
/**
@@ -483,6 +506,8 @@ export class PageControls {
// Update app state
this.pageState.showFavoritesOnly = showFavoritesOnly;
}
this.updateActionButtonStates();
}
/**
@@ -499,12 +524,17 @@ export class PageControls {
if (updateFilterBtn) {
updateFilterBtn.classList.toggle('active', showUpdatesOnly);
}
this.updateActionButtonStates();
}
/**
* Toggle favorites-only filter and reload models
*/
async toggleFavoritesOnly() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
// Toggle the filter state in storage
@@ -531,6 +561,9 @@ export class PageControls {
* Toggle update-available-only filter and reload models
*/
async toggleUpdateAvailableOnly() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const updateFilterBtn = document.getElementById('updateFilterBtn');
const storageKey = `show_update_available_only_${this.pageType}`;
const newState = !this.pageState.showUpdateAvailableOnly;
@@ -545,6 +578,234 @@ export class PageControls {
await this.resetAndReload(true);
}
cloneFilters(filters = this.pageState.filters) {
return JSON.parse(JSON.stringify(filters || {}));
}
buildExcludedFilters(search = '') {
return {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
search,
tagLogic: 'any',
};
}
applyFilterState(filters) {
this.pageState.filters = filters;
if (window.filterManager) {
window.filterManager.filters = window.filterManager.initializeFilters(filters);
window.filterManager.updateActiveFiltersCount();
if (typeof window.filterManager.updateSelections === 'function') {
window.filterManager.updateSelections();
}
window.filterManager.closeFilterPanel();
}
}
updateActionButtonStates() {
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.classList.toggle('active', Boolean(this.pageState.showFavoritesOnly));
}
const updateFilterBtn = document.getElementById('updateFilterBtn');
if (updateFilterBtn) {
updateFilterBtn.classList.toggle('active', Boolean(this.pageState.showUpdateAvailableOnly));
}
}
syncExcludedViewState() {
const isExcludedView = this.pageState.viewMode === 'excluded';
const sortSelect = document.getElementById('sortSelect');
const searchInput = document.getElementById('searchInput');
const excludedBanner = document.getElementById('excludedViewBanner');
const filterButton = document.getElementById('filterButton');
const breadcrumbContainer = document.getElementById('breadcrumbContainer');
const duplicatesBanner = document.getElementById('duplicatesBanner');
const alphabetBarContainer = document.querySelector('.alphabet-bar-container');
const hiddenSelectors = [
'[data-action="fetch"]',
'[data-action="download"]',
'[data-action="bulk"]',
'[data-action="find-duplicates"]',
'#favoriteFilterBtn',
'.update-filter-group',
];
const customFilterIndicator = document.getElementById('customFilterIndicator');
document.body.classList.toggle('excluded-view-active', isExcludedView);
excludedBanner?.classList.toggle('hidden', !isExcludedView);
breadcrumbContainer?.classList.toggle('hidden', isExcludedView);
alphabetBarContainer?.classList.toggle('hidden', isExcludedView);
if (duplicatesBanner && isExcludedView) {
duplicatesBanner.style.display = 'none';
}
hiddenSelectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((element) => {
element.classList.toggle('hidden', isExcludedView);
});
});
if (customFilterIndicator && isExcludedView) {
customFilterIndicator.classList.add('hidden');
}
if (filterButton) {
filterButton.disabled = isExcludedView;
filterButton.classList.toggle('hidden', isExcludedView);
}
const activeFiltersCount = document.getElementById('activeFiltersCount');
if (activeFiltersCount && isExcludedView) {
activeFiltersCount.style.display = 'none';
}
if (sortSelect) {
sortSelect.value = this.pageState.sortBy;
}
if (searchInput) {
searchInput.value = this.pageState.filters?.search || '';
}
this.updateActionButtonStates();
if (this.sidebarManager) {
const shouldShowSidebar = !isExcludedView && state?.global?.settings?.show_folder_sidebar !== false;
this.sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {
console.error('Failed to update sidebar visibility:', error);
});
}
}
suspendInteractiveModes() {
const snapshot = {
bulkMode: Boolean(state.bulkMode),
duplicatesMode: Boolean(this.pageState.duplicatesMode),
};
if (snapshot.bulkMode && window.bulkManager?.toggleBulkMode) {
window.bulkManager.toggleBulkMode();
}
if (snapshot.duplicatesMode && window.modelDuplicatesManager?.exitDuplicateMode) {
window.modelDuplicatesManager.exitDuplicateMode();
}
return snapshot;
}
async restoreInteractiveModes(snapshot = {}) {
if (snapshot.bulkMode && !state.bulkMode && window.bulkManager?.toggleBulkMode) {
window.bulkManager.toggleBulkMode();
}
if (!snapshot.duplicatesMode || this.pageState.duplicatesMode) {
return;
}
const duplicatesManager = window.modelDuplicatesManager;
if (!duplicatesManager) {
return;
}
if (typeof duplicatesManager.enterDuplicateMode === 'function' &&
Array.isArray(duplicatesManager.duplicateGroups) &&
duplicatesManager.duplicateGroups.length > 0) {
duplicatesManager.enterDuplicateMode();
return;
}
if (typeof duplicatesManager.findDuplicates === 'function') {
await duplicatesManager.findDuplicates();
}
}
syncCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator');
if (!indicator) {
return;
}
if (this.pageState.viewMode === 'excluded') {
indicator.classList.add('hidden');
return;
}
if (typeof this.checkCustomFilters === 'function') {
this.checkCustomFilters();
}
}
async enterExcludedView() {
if (this.pageState.viewMode === 'excluded') {
return;
}
const interactionSnapshot = this.suspendInteractiveModes();
this.pageState.activeViewSnapshot = {
sortBy: this.pageState.sortBy,
activeFolder: this.pageState.activeFolder,
activeLetterFilter: this.pageState.activeLetterFilter ?? null,
showFavoritesOnly: this.pageState.showFavoritesOnly,
showUpdateAvailableOnly: this.pageState.showUpdateAvailableOnly,
bulkMode: interactionSnapshot.bulkMode,
duplicatesMode: interactionSnapshot.duplicatesMode,
filters: this.cloneFilters(),
};
const excludedState = this.pageState.excludedViewState || {
sortBy: 'name:asc',
search: '',
};
this.pageState.viewMode = 'excluded';
this.pageState.sortBy = excludedState.sortBy || 'name:asc';
this.pageState.currentPage = 1;
this.pageState.activeFolder = null;
this.pageState.activeLetterFilter = null;
this.pageState.showFavoritesOnly = false;
this.pageState.showUpdateAvailableOnly = false;
this.applyFilterState(this.buildExcludedFilters(excludedState.search || ''));
this.syncExcludedViewState();
await this.resetAndReload(false);
}
async exitExcludedView() {
if (this.pageState.viewMode !== 'excluded') {
return;
}
this.pageState.excludedViewState = {
...(this.pageState.excludedViewState || {}),
sortBy: this.pageState.sortBy,
search: this.pageState.filters?.search || '',
};
const snapshot = this.pageState.activeViewSnapshot || {};
this.pageState.viewMode = 'active';
this.pageState.sortBy = snapshot.sortBy || this.convertLegacySortFormat(getStorageItem(`${this.pageType}_sort`) || 'name:asc');
this.pageState.currentPage = 1;
this.pageState.activeFolder = snapshot.activeFolder ?? getStorageItem(`${this.pageType}_activeFolder`);
this.pageState.activeLetterFilter = snapshot.activeLetterFilter ?? null;
this.pageState.showFavoritesOnly = Boolean(snapshot.showFavoritesOnly);
this.pageState.showUpdateAvailableOnly = Boolean(snapshot.showUpdateAvailableOnly);
this.applyFilterState(snapshot.filters || this.buildExcludedFilters(''));
this.pageState.activeViewSnapshot = null;
this.syncExcludedViewState();
await this.resetAndReload(true);
this.syncCustomFilterIndicator();
await this.restoreInteractiveModes(snapshot);
}
/**
* Find duplicate models

View File

@@ -433,10 +433,11 @@ export function createModelCard(model, modelType) {
card.dataset.usage_count = String(model.usage_count);
card.dataset.notes = model.notes || '';
card.dataset.base_model = model.base_model || 'Unknown';
card.dataset.favorite = model.favorite ? 'true' : 'false';
const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
card.dataset.favorite = model.favorite ? 'true' : 'false';
card.dataset.exclude = model.exclude ? 'true' : 'false';
const hasUpdateAvailable = Boolean(model.update_available);
card.dataset.update_available = hasUpdateAvailable ? 'true' : 'false';
card.dataset.skip_metadata_refresh = model.skip_metadata_refresh ? 'true' : 'false';
// To only show usage_count when sorting by usage.
const pageState = getCurrentPageState();
@@ -487,6 +488,9 @@ export function createModelCard(model, modelType) {
if (model.skip_metadata_refresh) {
card.classList.add('skip-refresh');
}
if (model.exclude) {
card.classList.add('excluded-model');
}
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
@@ -619,6 +623,11 @@ export function createModelCard(model, modelType) {
<i class="fas fa-ban"></i>
</span>
` : ''}
${model.exclude ? `
<span class="model-excluded-badge" title="${translate('globalContextMenu.manageExcludedModels.label', {}, 'Excluded Models')}">
<i class="fas fa-eye-slash"></i>
</span>
` : ''}
</div>
<div class="card-actions">
${actionIcons}

View File

@@ -1,26 +1,21 @@
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { downloadManager } from '../../managers/DownloadManager.js';
import { modalManager } from '../../managers/ModalManager.js';
import { showToast } from '../../utils/uiHelpers.js';
import { openCivitaiUrl, showToast } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { buildCivitaiModelUrl } from '../../utils/civitaiUtils.js';
import { formatFileSize } from './utils.js';
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv'];
const PREVIEW_PLACEHOLDER_URL = '/loras_static/images/no-preview.png';
function buildCivitaiVersionUrl(modelId, versionId) {
if (modelId == null || versionId == null) {
return null;
}
const normalizedModelId = String(modelId).trim();
const normalizedVersionId = String(versionId).trim();
if (!normalizedModelId || !normalizedVersionId) {
return null;
}
const encodedModelId = encodeURIComponent(normalizedModelId);
const encodedVersionId = encodeURIComponent(normalizedVersionId);
return `https://civitai.com/models/${encodedModelId}?modelVersionId=${encodedVersionId}`;
return buildCivitaiModelUrl(
modelId,
versionId,
state?.global?.settings?.civitai_host
);
}
function escapeHtml(value) {
@@ -1352,6 +1347,13 @@ export function initVersionsTab({
}
const row = event.target.closest('.model-version-row.is-clickable');
const civitaiLink = event.target.closest('.version-civitai-link');
if (civitaiLink) {
event.preventDefault();
openCivitaiUrl(civitaiLink.href);
return;
}
if (!row) {
return;
}
@@ -1371,7 +1373,7 @@ export function initVersionsTab({
return;
}
event.preventDefault();
window.open(targetUrl, '_blank', 'noopener,noreferrer');
openCivitaiUrl(targetUrl);
});
// Listen for extension-triggered refresh requests

View File

@@ -8,6 +8,10 @@ import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute, escapeHtml } from './utils.js';
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
const MAX_TRIGGER_WORD_GROUPS = 100;
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
/**
* Fetch trained words for a model
* @param {string} filePath - Path to the model file
@@ -223,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word);
return `
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}">
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
@@ -261,6 +265,8 @@ export function setupTriggerWordsEditMode() {
const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return;
document.querySelectorAll('.trigger-word-tag').forEach(setupDisplayTriggerWordTag);
editBtn.addEventListener('click', async function () {
const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
@@ -293,7 +299,9 @@ export function setupTriggerWordsEditMode() {
// Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => {
tag.onclick = null;
teardownDisplayTriggerWordTag(tag);
tag.addEventListener('click', startEditTriggerWord);
tag.title = translate('modals.model.triggerWords.editWord');
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
@@ -337,6 +345,12 @@ export function setupTriggerWordsEditMode() {
// Focus the input
addForm.querySelector('input').focus();
const pendingEditTag = triggerWordsSection._pendingTriggerWordEditTag;
delete triggerWordsSection._pendingTriggerWordEditTag;
if (pendingEditTag && document.contains(pendingEditTag)) {
startEditTriggerWord.call(pendingEditTag, { target: pendingEditTag, preventDefault() { }, stopPropagation() { } });
}
} else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = translate('modals.model.triggerWords.edit');
@@ -350,6 +364,7 @@ export function setupTriggerWordsEditMode() {
// If canceling, restore original trigger words
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
} else {
commitActiveTriggerWordEdit(triggerWordsSection);
// If saving, reset UI state on current trigger words
resetTriggerWordsUIState(triggerWordsSection);
// Reset the skip restore flag
@@ -429,15 +444,18 @@ function deleteTriggerWord(e) {
* @param {HTMLElement} section - The trigger words section
*/
function resetTriggerWordsUIState(section) {
commitActiveTriggerWordEdit(section);
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality
tag.onclick = () => copyTriggerWord(tag.dataset.word);
tag.removeEventListener('click', startEditTriggerWord);
setupDisplayTriggerWordTag(tag);
tag.title = translate('modals.model.triggerWords.copyWord');
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
@@ -471,25 +489,236 @@ function restoreOriginalTriggerWords(section, originalWords) {
// Recreate original tags
originalWords.forEach(word => {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.onclick = () => copyTriggerWord(tag.dataset.word);
const escapedWord = escapeHtml(word);
tag.innerHTML = `
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
tagsContainer.appendChild(tag);
tagsContainer.appendChild(createTriggerWordTag(word, false));
});
}
/**
* Create a trigger word tag element
* @param {string} word - Trigger word
* @param {boolean} isEditMode - Whether the tag should be editable
* @returns {HTMLElement} Tag element
*/
function createTriggerWordTag(word, isEditMode = false) {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
const escapedWord = escapeHtml(word);
tag.innerHTML = `
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" style="${isEditMode ? '' : 'display:none;'}" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
<i class="fas fa-times"></i>
</button>
`;
const deleteBtn = tag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
if (isEditMode) {
tag.addEventListener('click', startEditTriggerWord);
} else {
setupDisplayTriggerWordTag(tag);
}
return tag;
}
/**
* Set up display-mode click-to-copy and double-click-to-edit behavior
* @param {HTMLElement} tag - Trigger word tag
*/
function setupDisplayTriggerWordTag(tag) {
teardownDisplayTriggerWordTag(tag);
tag.addEventListener('click', handleDisplayTriggerWordClick);
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
tag.title = translate('modals.model.triggerWords.copyWord');
}
/**
* Remove display-mode handlers and pending copy action
* @param {HTMLElement} tag - Trigger word tag
*/
function teardownDisplayTriggerWordTag(tag) {
if (tag.dataset.copyTimerId) {
clearTimeout(Number(tag.dataset.copyTimerId));
delete tag.dataset.copyTimerId;
}
tag.onclick = null;
tag.removeEventListener('click', handleDisplayTriggerWordClick);
tag.removeEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
}
/**
* Copy trigger word after a short delay so dblclick can cancel it
* @param {MouseEvent} e - Click event
*/
function handleDisplayTriggerWordClick(e) {
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
const tag = this.closest('.trigger-word-tag');
if (!tag || tag.closest('.trigger-words')?.classList.contains('edit-mode')) return;
e.stopPropagation();
if (tag.dataset.copyTimerId) {
clearTimeout(Number(tag.dataset.copyTimerId));
}
const timerId = window.setTimeout(() => {
delete tag.dataset.copyTimerId;
copyTriggerWord(tag.dataset.word);
}, TRIGGER_WORD_CLICK_DELAY_MS);
tag.dataset.copyTimerId = String(timerId);
}
/**
* Enter edit mode and start editing the double-clicked trigger word
* @param {MouseEvent} e - Double-click event
*/
function handleDisplayTriggerWordDoubleClick(e) {
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words');
const editBtn = section?.querySelector('.edit-trigger-words-btn');
if (!tag || !section || !editBtn) return;
e.preventDefault();
e.stopPropagation();
if (tag.dataset.copyTimerId) {
clearTimeout(Number(tag.dataset.copyTimerId));
delete tag.dataset.copyTimerId;
}
if (!section.classList.contains('edit-mode')) {
section._pendingTriggerWordEditTag = tag;
editBtn.click();
return;
}
startEditTriggerWord.call(tag, e);
}
/**
* Validate a trigger word against existing tags
* @param {string} word - Trigger word
* @param {HTMLElement} tagsContainer - Tags container
* @param {HTMLElement|null} currentTag - Tag being edited, if any
* @returns {boolean} Whether the word is valid
*/
function validateTriggerWord(word, tagsContainer, currentTag = null) {
if (word.split(/\s+/).length > MAX_WORDS_PER_TRIGGER_GROUP) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return false;
}
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags)
.filter(tag => tag !== currentTag)
.map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return false;
}
return true;
}
/**
* Start inline editing for a trigger word tag
* @param {Event} e - Click event
*/
function startEditTriggerWord(e) {
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words');
if (!tag || !section?.classList.contains('edit-mode') || tag.classList.contains('is-editing')) return;
e.preventDefault();
e.stopPropagation();
commitActiveTriggerWordEdit(section);
const content = tag.querySelector('.trigger-word-content');
const originalWord = tag.dataset.word;
const originalRect = tag.getBoundingClientRect();
if (originalRect.width > 0) {
tag.style.setProperty('--trigger-word-edit-width', `${Math.ceil(originalRect.width)}px`);
}
if (originalRect.height > 0) {
tag.style.setProperty('--trigger-word-edit-height', `${Math.ceil(originalRect.height)}px`);
}
const editor = document.createElement('textarea');
editor.className = 'trigger-word-edit-input';
editor.rows = 1;
editor.value = originalWord;
editor.setAttribute('aria-label', translate('modals.model.triggerWords.editWord'));
editor.placeholder = translate('modals.model.triggerWords.editPlaceholder');
let finished = false;
const finish = (shouldCommit) => {
if (finished) return;
finished = true;
const nextWord = editor.value.trim().replace(/\s*\n+\s*/g, ' ');
if (shouldCommit && nextWord && nextWord !== originalWord) {
const tagsContainer = tag.closest('.trigger-words-tags');
if (tagsContainer && validateTriggerWord(nextWord, tagsContainer, tag)) {
tag.dataset.word = nextWord;
content.textContent = nextWord;
}
}
editor.remove();
content.style.display = '';
tag.classList.remove('is-editing');
tag.style.removeProperty('--trigger-word-edit-width');
tag.style.removeProperty('--trigger-word-edit-height');
updateTrainedWordsDropdown();
};
editor.addEventListener('click', event => event.stopPropagation());
editor.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
finish(false);
}
});
editor.addEventListener('blur', () => finish(true));
editor.style.visibility = 'hidden';
content.after(editor);
tag.classList.add('is-editing');
content.style.display = 'none';
editor.style.visibility = '';
editor.focus();
editor.select();
}
/**
* Commit an active inline trigger word edit if one exists
* @param {HTMLElement} section - Trigger words section
*/
function commitActiveTriggerWordEdit(section) {
const input = section.querySelector('.trigger-word-edit-input');
if (input) {
input.dispatchEvent(new FocusEvent('blur'));
}
}
/**
* Add a new trigger word
* @param {string} word - Trigger word to add
@@ -522,46 +751,16 @@ function addNewTriggerWord(word) {
noTriggerWordsMsg.style.display = 'none';
}
// Validation: Check length
if (word.split(/\s+/).length > 100) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 100) {
if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
showToast('toast.triggerWords.tooMany', {}, 'error');
return;
}
// Validation: Check for duplicates
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'trigger-word-tag';
newTag.dataset.word = word;
const escapedWord = escapeHtml(word);
newTag.innerHTML = `
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="display:none;">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
if (!validateTriggerWord(word, tagsContainer)) return;
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
tagsContainer.appendChild(newTag);
// Update status of items in the trained words dropdown
@@ -633,6 +832,8 @@ async function saveTriggerWords() {
const filePath = editBtn.dataset.filePath;
const triggerWordsSection = editBtn.closest('.trigger-words');
commitActiveTriggerWordEdit(triggerWordsSection);
// Auto-commit any pending input to prevent data loss
const input = triggerWordsSection.querySelector('.metadata-input');
if (input && input.value.trim()) {

View File

@@ -240,9 +240,7 @@ export class BulkManager {
*/
handleGlobalKeyboard(e) {
// Skip if modal is open (handled by event manager conditions)
// Skip if search input is focused
const searchInput = document.getElementById('searchInput');
if (searchInput && document.activeElement === searchInput) {
if (this.isEditingTextInputContext(e.target)) {
return false; // Don't handle, allow default behavior
}
@@ -266,6 +264,26 @@ export class BulkManager {
return false; // Continue with other handlers
}
isEditingTextInputContext(target) {
const activeElement = document.activeElement;
const candidate = target instanceof Element ? target : activeElement;
if (!candidate) {
return false;
}
const tagName = candidate.tagName?.toLowerCase();
if (
candidate.isContentEditable
|| tagName === 'input'
|| tagName === 'textarea'
|| tagName === 'select'
) {
return true;
}
return Boolean(candidate.closest?.('#filterPanel'));
}
toggleBulkMode() {
state.bulkMode = !state.bulkMode;

View File

@@ -6,6 +6,7 @@ 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';
export class DownloadManager {
constructor() {
@@ -197,21 +198,22 @@ export class DownloadManager {
}
extractModelId(url) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
const civarchiveMatch = url.match(/https?:\/\/(?:www\.)?(?:civitaiarchive|civarchive)\.com\/models\/(\d+)/i);
if (civarchiveMatch) {
const versionMatch = url.match(/modelVersionId=(\d+)/i);
this.modelVersionId = versionMatch ? versionMatch[1] : null;
this.source = 'civarchive';
return civarchiveMatch[1];
}
const civitaiMatch = url.match(/https?:\/\/(?:www\.)?civitai\.com\/models\/(\d+)/i);
if (civitaiMatch) {
const { modelId, modelVersionId } = extractCivitaiModelUrlParts(url);
if (modelId) {
this.modelVersionId = modelVersionId;
this.source = null;
return civitaiMatch[1];
return modelId;
}
this.modelVersionId = null;
this.source = null;
return null;
}

View File

@@ -20,6 +20,8 @@ export class FilterManager {
this.filterPanel = document.getElementById('filterPanel');
this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.baseModelSearchInput = document.getElementById('baseModelSearchInput');
this.baseModelOptions = [];
this.tagsLoaded = false;
// Initialize preset manager
@@ -49,6 +51,8 @@ export class FilterManager {
}
initialize() {
this.initializeFilterSearchInputs();
// Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) {
this.createBaseModelTags();
@@ -110,6 +114,18 @@ export class FilterManager {
this.updateTagLogicToggleUI();
}
initializeFilterSearchInputs() {
if (this.baseModelSearchInput) {
this.baseModelSearchInput.addEventListener('input', () => {
this.renderBaseModelTags();
});
}
}
getNormalizedSearchQuery(input) {
return (input?.value || '').trim().toLowerCase();
}
updateTagLogicToggleUI() {
const toggleContainer = document.getElementById('tagLogicToggle');
if (!toggleContainer) return;
@@ -164,11 +180,6 @@ export class FilterManager {
tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
return;
}
// Collect existing tag names from the API response
const existingTagNames = new Set(tags.map(t => t.tag));
@@ -186,6 +197,11 @@ export class FilterManager {
});
}
if (!tags.length) {
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
return;
}
tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
@@ -212,7 +228,6 @@ export class FilterManager {
await this.applyFilters(false);
});
this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none');
tagsContainer.appendChild(tagEl);
});
@@ -235,8 +250,8 @@ export class FilterManager {
await this.applyFilters(false);
});
this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none');
tagsContainer.appendChild(noTagsEl);
this.updateTagSelections();
}
initializeLicenseFilters() {
@@ -323,44 +338,15 @@ export class FilterManager {
if (!baseModelTagsContainer) return;
// Set the API endpoint based on current page
const apiEndpoint = `/api/lm/${this.currentPage}/base-models`;
const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`;
// Fetch base models
fetch(apiEndpoint)
.then(response => response.json())
.then(data => {
if (data.success && data.base_models) {
baseModelTagsContainer.innerHTML = '';
data.base_models.forEach(model => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag`;
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
// Update selections based on stored filters
this.updateTagSelections();
this.baseModelOptions = data.base_models;
this.renderBaseModelTags();
}
})
.catch(error => {
@@ -369,6 +355,57 @@ export class FilterManager {
});
}
renderBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags');
const emptyState = document.getElementById('baseModelEmptyState');
if (!baseModelTagsContainer) return;
baseModelTagsContainer.innerHTML = '';
if (!this.baseModelOptions.length) {
baseModelTagsContainer.innerHTML = '<div class="no-tags">No base models available</div>';
if (emptyState) {
emptyState.hidden = true;
}
return;
}
const query = this.getNormalizedSearchQuery(this.baseModelSearchInput);
const filteredModels = query
? this.baseModelOptions.filter(model => model.name.toLowerCase().includes(query))
: this.baseModelOptions;
filteredModels.forEach(model => {
const tag = document.createElement('div');
tag.className = 'filter-tag base-model-tag';
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
if (emptyState) {
emptyState.hidden = filteredModels.length > 0;
}
this.updateTagSelections();
}
async createModelTypeTags() {
const modelTypeContainer = document.getElementById('modelTypeTags');
if (!modelTypeContainer) return;
@@ -453,6 +490,7 @@ export class FilterManager {
this.filterPanel.classList.remove('hidden');
this.filterButton.classList.add('active');
this.baseModelSearchInput?.focus();
// Load tags if they haven't been loaded yet
if (!this.tagsLoaded) {

View File

@@ -232,7 +232,7 @@ export class FilterPresetManager {
try {
const fetchOptions = signal ? { signal } : {};
const response = await fetch(`/api/lm/${this.currentPage}/base-models`, fetchOptions);
const response = await fetch(`/api/lm/${this.currentPage}/base-models?limit=0`, fetchOptions);
if (!response.ok) throw new Error('Failed to fetch base models');

View File

@@ -802,6 +802,21 @@ export class SettingsManager {
usePortableCheckbox.checked = !!state.global.settings.use_portable_settings;
}
const civitaiHostSelect = document.getElementById('civitaiHost');
if (civitaiHostSelect) {
civitaiHostSelect.value = state.global.settings.civitai_host || 'civitai.com';
}
const downloadBackendSelect = document.getElementById('downloadBackend');
if (downloadBackendSelect) {
downloadBackendSelect.value = state.global.settings.download_backend || 'python';
}
const aria2cPathInput = document.getElementById('aria2cPath');
if (aria2cPathInput) {
aria2cPathInput.value = state.global.settings.aria2c_path || '';
}
const recipesPathInput = document.getElementById('recipesPath');
if (recipesPathInput) {
recipesPathInput.value = state.global.settings.recipes_path || '';
@@ -945,9 +960,36 @@ export class SettingsManager {
languageSelect.value = currentLanguage;
}
this.loadDownloadBackendSettings();
this.loadProxySettings();
}
loadDownloadBackendSettings() {
const downloadBackendSelect = document.getElementById('downloadBackend');
const aria2PathSetting = document.getElementById('aria2PathSetting');
const updateVisibility = () => {
if (!aria2PathSetting || !downloadBackendSelect) {
return;
}
aria2PathSetting.style.display = downloadBackendSelect.value === 'aria2' ? 'block' : 'none';
};
if (downloadBackendSelect) {
downloadBackendSelect.value = state.global.settings.download_backend || 'python';
downloadBackendSelect.onchange = () => {
updateVisibility();
this.saveSelectSetting('downloadBackend', 'download_backend');
};
}
const aria2cPathInput = document.getElementById('aria2cPath');
if (aria2cPathInput) {
aria2cPathInput.value = state.global.settings.aria2c_path || '';
}
updateVisibility();
}
setupPriorityTagInputs() {
['lora', 'checkpoint', 'embedding'].forEach((modelType) => {
const textarea = document.getElementById(`${modelType}PriorityTagsInput`);

View File

@@ -5,6 +5,9 @@ import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/co
const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '',
civitai_host: 'civitai.com',
download_backend: 'python',
aria2c_path: '',
use_portable_settings: false,
language: 'en',
show_only_sfw: false,
@@ -89,7 +92,9 @@ export const state = {
baseModel: [],
tags: {},
license: {},
modelTypes: []
modelTypes: [],
search: '',
tagLogic: 'any',
},
bulkMode: false,
selectedLoras: new Set(),
@@ -97,6 +102,12 @@ export const state = {
showFavoritesOnly: false,
showUpdateAvailableOnly: false,
duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
},
recipes: {
@@ -146,7 +157,9 @@ export const state = {
baseModel: [],
tags: {},
license: {},
modelTypes: []
modelTypes: [],
search: '',
tagLogic: 'any',
},
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
bulkMode: false,
@@ -155,6 +168,12 @@ export const state = {
showFavoritesOnly: false,
showUpdateAvailableOnly: false,
duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
},
[MODEL_TYPES.EMBEDDING]: {
@@ -177,7 +196,9 @@ export const state = {
baseModel: [],
tags: {},
license: {},
modelTypes: []
modelTypes: [],
search: '',
tagLogic: 'any',
},
bulkMode: false,
selectedModels: new Set(),
@@ -185,6 +206,12 @@ export const state = {
showFavoritesOnly: false,
showUpdateAvailableOnly: false,
duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}
},

View File

@@ -13,6 +13,64 @@ export const OptimizationMode = {
THUMBNAIL: 'thumbnail',
};
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
'civitai.com',
'civitai.red',
]);
export function normalizeCivitaiPageHost(hostname) {
if (!hostname || typeof hostname !== 'string') {
return DEFAULT_CIVITAI_PAGE_HOST;
}
const normalized = hostname.trim().toLowerCase();
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(normalized)) {
return normalized;
}
return DEFAULT_CIVITAI_PAGE_HOST;
}
export function buildCivitaiModelUrl(modelId, versionId = null, host = DEFAULT_CIVITAI_PAGE_HOST) {
const normalizedHost = normalizeCivitaiPageHost(host);
const normalizedModelId = modelId == null ? '' : String(modelId).trim();
const normalizedVersionId = versionId == null ? '' : String(versionId).trim();
if (normalizedModelId) {
const encodedModelId = encodeURIComponent(normalizedModelId);
let url = `https://${normalizedHost}/models/${encodedModelId}`;
if (normalizedVersionId) {
url += `?modelVersionId=${encodeURIComponent(normalizedVersionId)}`;
}
return url;
}
if (normalizedVersionId) {
return `https://${normalizedHost}/model-versions/${encodeURIComponent(normalizedVersionId)}`;
}
return null;
}
export function buildCivitaiSearchUrl(query, host = DEFAULT_CIVITAI_PAGE_HOST) {
const normalizedQuery = query == null ? '' : String(query).trim();
if (!normalizedQuery) {
return null;
}
const normalizedHost = normalizeCivitaiPageHost(host);
return `https://${normalizedHost}/models?query=${encodeURIComponent(normalizedQuery)}`;
}
export function buildCivitaiUrl({ modelId = null, versionId = null, modelName = null, host = DEFAULT_CIVITAI_PAGE_HOST } = {}) {
return (
buildCivitaiModelUrl(modelId, versionId, host)
|| buildCivitaiSearchUrl(modelName, host)
);
}
/**
* Rewrite Civitai preview URLs to use optimized renditions.
* Mirrors the backend's rewrite_preview_url() function from py/utils/civitai_utils.py
@@ -119,3 +177,50 @@ export function isCivitaiUrl(url) {
return false;
}
}
export function isSupportedCivitaiPageHost(hostname) {
if (!hostname) {
return false;
}
return SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname.toLowerCase());
}
export function extractCivitaiModelUrlParts(url) {
if (!url) {
return { modelId: null, modelVersionId: null };
}
try {
const parsedUrl = new URL(url);
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
return { modelId: null, modelVersionId: null };
}
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
const modelId = pathMatch ? pathMatch[1] : null;
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
return { modelId, modelVersionId };
} catch (e) {
return { modelId: null, modelVersionId: null };
}
}
export function extractCivitaiImageId(url) {
if (!url) {
return null;
}
try {
const parsedUrl = new URL(url);
if (!isSupportedCivitaiPageHost(parsedUrl.hostname)) {
return null;
}
const pathMatch = parsedUrl.pathname.match(/\/images\/(\d+)/);
return pathMatch ? pathMatch[1] : null;
} catch (e) {
return null;
}
}

View File

@@ -25,6 +25,7 @@ export function initializeEventManagement() {
setupPageUnloadCleanup();
// Register global event handlers that need coordination
registerGlobalEventHandlers();
registerContextMenuEvents();
registerGlobalClickHandlers();
@@ -148,6 +149,10 @@ function registerGlobalClickHandlers() {
* Register common application-wide event handlers
*/
export function registerGlobalEventHandlers() {
eventManager.removeHandler('keydown', 'global-escape');
eventManager.removeHandler('focusin', 'global-focus');
eventManager.removeHandler('click', 'global-analytics');
// Escape key handler for closing modals/panels
eventManager.addHandler('keydown', 'global-escape', (e) => {
if (e.key === 'Escape') {
@@ -156,6 +161,14 @@ export function registerGlobalEventHandlers() {
modalManager.closeCurrentModal();
return true; // Stop propagation
}
if (
window.filterManager?.filterPanel
&& !window.filterManager.filterPanel.classList.contains('hidden')
) {
window.filterManager.closeFilterPanel();
return true; // Stop propagation
}
// Check if node selector is active and close it
if (eventManager.getState('nodeSelectorActive')) {

View File

@@ -3,6 +3,66 @@ import { state, getCurrentPageState } from '../state/index.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
import { NODE_TYPE_ICONS, DEFAULT_NODE_COLOR } from './constants.js';
import { eventManager } from './EventManager.js';
import { bannerService } from '../managers/BannerService.js';
import { modalManager } from '../managers/ModalManager.js';
import { buildCivitaiUrl, normalizeCivitaiPageHost } from './civitaiUtils.js';
const CIVITAI_HOST_INFO_BANNER_ID = 'civitai-host-preference';
const CIVITAI_HOST_INFO_BANNER_SEEN_KEY = 'civitai_host_info_banner_seen';
function getPreferredCivitaiHost() {
return normalizeCivitaiPageHost(state?.global?.settings?.civitai_host);
}
function maybeRegisterCivitaiHostInfoBanner() {
if (getStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, false)) {
return;
}
setStorageItem(CIVITAI_HOST_INFO_BANNER_SEEN_KEY, true);
bannerService.registerBanner(CIVITAI_HOST_INFO_BANNER_ID, {
id: CIVITAI_HOST_INFO_BANNER_ID,
title: translate(
'settings.civitaiHostBanner.title',
{},
'Civitai host preference available'
),
content: translate(
'settings.civitaiHostBanner.content',
{},
'Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.'
),
actions: [
{
text: translate('settings.civitaiHostBanner.openSettings', {}, 'Open Settings'),
icon: 'fas fa-cog',
action: 'open-settings-modal',
type: 'primary',
},
],
dismissible: true,
priority: 70,
onRegister: (bannerElement) => {
const button = bannerElement.querySelector('.banner-action[data-action="open-settings-modal"]');
if (button) {
button.addEventListener('click', (event) => {
event.preventDefault();
modalManager.showModal('settingsModal');
});
}
},
});
}
export function openCivitaiUrl(url) {
if (!url) {
return null;
}
maybeRegisterCivitaiHostInfoBanner();
return window.open(url, '_blank', 'noopener,noreferrer');
}
/**
* Utility function to copy text to clipboard with fallback for older browsers
@@ -184,14 +244,15 @@ function filterByFolder(folderPath) {
}
export function openCivitaiByMetadata(civitaiId, versionId, modelName = null) {
if (versionId) {
// Use model-versions endpoint which auto-redirects to correct model page
window.open(`https://civitai.com/model-versions/${versionId}`, '_blank');
} else if (civitaiId) {
window.open(`https://civitai.com/models/${civitaiId}`, '_blank');
} else if (modelName) {
// Fallback: search by name
window.open(`https://civitai.com/models?query=${encodeURIComponent(modelName)}`, '_blank');
const url = buildCivitaiUrl({
modelId: civitaiId,
versionId,
modelName,
host: getPreferredCivitaiHost(),
});
if (url) {
openCivitaiUrl(url);
}
}

View File

@@ -111,6 +111,9 @@
<div class="context-menu-item" data-action="cleanup-example-images-folders">
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
</div>
<div class="context-menu-item" data-action="manage-excluded-models">
<i class="fas fa-eye-slash"></i> <span>{{ t('globalContextMenu.manageExcludedModels.label', default='Manage Excluded Models') }}</span>
</div>
<div class="context-menu-item" data-action="repair-recipes">
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
</div>
@@ -136,4 +139,4 @@
<div id="nodeSelector" class="node-selector">
<!-- Dynamic node list will be populated here -->
</div>
</div>

View File

@@ -1,4 +1,16 @@
<div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<div class="excluded-view-banner__content">
<div class="excluded-view-banner__title">
<i class="fas fa-eye-slash"></i>
<span>{{ t('globalContextMenu.manageExcludedModels.label', default='Excluded Models') }}</span>
</div>
<button id="excludedViewBackBtn" class="excluded-view-banner__back">
<i class="fas fa-arrow-left"></i>
<span>{{ t('common.actions.back', default='Back') }}</span>
</button>
</div>
</div>
<div class="actions">
<div class="action-buttons">
<div title="{{ t('loras.controls.sort.title') }}" class="control-group">

View File

@@ -145,9 +145,22 @@
<div class="filter-section">
<h4>{{ t('header.filter.baseModel') }}</h4>
<input
type="text"
id="baseModelSearchInput"
class="filter-search-input"
placeholder="{{ t('header.filter.baseModelSearchPlaceholder') }}"
autocomplete="off"
autocorrect="off"
autocapitalize="none"
spellcheck="false"
>
<div class="filter-tags" id="baseModelTags">
<!-- Tags will be dynamically inserted here -->
</div>
<div id="baseModelEmptyState" class="filter-empty-state" hidden>
{{ t('header.filter.noBaseModelMatches') }}
</div>
</div>
<div class="filter-section">
<div class="filter-section-header">
@@ -188,4 +201,4 @@
{{ t('header.filter.clearAll') }}
</button>
</div>
</div>
</div>

View File

@@ -114,6 +114,58 @@
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="civitaiHost">{{ t('settings.civitaiHost.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiHost.help') }}"></i>
</div>
<div class="setting-control select-control">
<select id="civitaiHost" onchange="settingsManager.saveSelectSetting('civitaiHost', 'civitai_host')">
<option value="civitai.com">{{ t('settings.civitaiHost.options.com') }}</option>
<option value="civitai.red">{{ t('settings.civitaiHost.options.red') }}</option>
</select>
</div>
</div>
</div>
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>{{ t('settings.sections.downloads') }}</h4>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
</div>
<div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="aria2PathSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text"
id="aria2cPath"
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div>
<!-- Backup -->
<div class="settings-subsection">
<div class="settings-subsection-header">

View File

@@ -46,6 +46,7 @@ def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp
self.delete_calls = []
self.upsert_calls = []
self._renamed = False
self.active_library = "default"
def get_libraries(self):
if self._renamed:
@@ -62,6 +63,11 @@ def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp
def rename_library(self, old_name: str, new_name: str):
self.rename_calls.append((old_name, new_name))
self._renamed = True
if self.active_library == old_name:
self.active_library = new_name
def get_active_library_name(self):
return self.active_library
def delete_library(self, name: str): # pragma: no cover - defensive guard
self.delete_calls.append(name)
@@ -104,6 +110,7 @@ def test_save_paths_logs_warning_when_upsert_fails(
class RaisingSettingsService:
def __init__(self):
self.upsert_attempts = []
self.active_library = "comfyui"
def get_libraries(self):
return {
@@ -116,6 +123,9 @@ def test_save_paths_logs_warning_when_upsert_fails(
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.upsert_attempts.append((name, payload))
raise RuntimeError("boom")
@@ -135,6 +145,8 @@ def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch,
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self):
return {
"comfyui": {
@@ -148,6 +160,9 @@ def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch,
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
@@ -167,6 +182,8 @@ def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch,
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self):
return {
"comfyui": {
@@ -180,6 +197,9 @@ def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch,
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
@@ -199,6 +219,8 @@ def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, t
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self):
return {
"comfyui": {
@@ -212,6 +234,9 @@ def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, t
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
@@ -258,6 +283,7 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
self.rename_calls = []
self.delete_calls = []
self.upsert_calls = []
self.active_library = "default"
def get_libraries(self):
return self.libraries
@@ -265,6 +291,8 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
def rename_library(self, old_name: str, new_name: str):
self.rename_calls.append((old_name, new_name))
self.libraries[new_name] = self.libraries.pop(old_name)
if self.active_library == old_name:
self.active_library = new_name
def delete_library(self, name: str):
self.delete_calls.append(name)
@@ -273,6 +301,11 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
def upsert_library(self, name: str, **payload):
self.upsert_calls.append((name, payload))
self.libraries[name] = {**payload}
if payload.get("activate"):
self.active_library = name
def get_active_library_name(self):
return self.active_library
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
@@ -313,6 +346,199 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
assert payload["activate"] is True
def test_save_paths_keeps_default_roots_in_extra_paths(monkeypatch: pytest.MonkeyPatch, tmp_path):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
extra_lora_dir = tmp_path / "extra_loras"
extra_checkpoint_dir = tmp_path / "extra_checkpoints"
extra_embedding_dir = tmp_path / "extra_embeddings"
for directory in (extra_lora_dir, extra_checkpoint_dir, extra_embedding_dir):
directory.mkdir()
class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"extra_folder_paths": {
"loras": [str(extra_lora_dir)],
"checkpoints": [str(extra_checkpoint_dir)],
"embeddings": [str(extra_embedding_dir)],
},
"default_lora_root": str(extra_lora_dir),
"default_checkpoint_root": str(extra_checkpoint_dir),
"default_embedding_root": str(extra_embedding_dir),
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert fake_settings.name == "comfyui"
assert fake_settings.payload["extra_folder_paths"]["loras"] == [str(extra_lora_dir).replace("\\", "/")]
assert fake_settings.payload["extra_folder_paths"]["checkpoints"] == [
str(extra_checkpoint_dir).replace("\\", "/")
]
assert fake_settings.payload["extra_folder_paths"]["embeddings"] == [
str(extra_embedding_dir).replace("\\", "/")
]
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/")
assert fake_settings.payload["default_checkpoint_root"] == str(extra_checkpoint_dir).replace("\\", "/")
assert fake_settings.payload["default_embedding_root"] == str(extra_embedding_dir).replace("\\", "/")
assert fake_settings.payload["activate"] is True
def test_save_paths_keeps_default_roots_in_extra_paths_with_windows_slash_mismatch(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"extra_folder_paths": {
"loras": ["U:\\Lora7\\Loras"],
"checkpoints": ["U:\\Lora7\\Models"],
"embeddings": [],
},
"default_lora_root": "U:/Lora7/Loras",
"default_checkpoint_root": "U:/Lora7/Models",
"default_embedding_root": folder_paths["embeddings"][0],
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == "U:/Lora7/Loras"
assert fake_settings.payload["default_checkpoint_root"] == "U:/Lora7/Models"
def test_save_paths_repairs_empty_default_roots_to_extra_paths_when_primary_missing(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_setup_config_environment(monkeypatch, tmp_path)
extra_lora_dir = tmp_path / "extra_loras"
extra_lora_dir.mkdir()
monkeypatch.setattr(
config_module.folder_paths,
"get_folder_paths",
lambda kind: [] if kind == "loras" else [],
)
class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self):
return {
"comfyui": {
"folder_paths": {
"loras": [],
"checkpoints": [],
"unet": [],
"embeddings": [],
},
"extra_folder_paths": {
"loras": [str(extra_lora_dir)],
},
"default_lora_root": "",
}
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.name = name
self.payload = payload
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert fake_settings.name == "comfyui"
assert fake_settings.payload["default_lora_root"] == str(extra_lora_dir).replace("\\", "/")
def test_save_paths_does_not_activate_comfyui_library_when_another_library_is_active(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService:
def __init__(self):
self.active_library = "studio"
self.upsert_calls = []
def get_libraries(self):
return {
"studio": {
"folder_paths": {"loras": ["/studio/loras"]},
},
"comfyui": {
"folder_paths": {key: list(value) for key, value in folder_paths.items()},
"default_lora_root": folder_paths["loras"][0],
"default_checkpoint_root": folder_paths["checkpoints"][0],
"default_embedding_root": folder_paths["embeddings"][0],
},
}
def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked")
def get_active_library_name(self):
return self.active_library
def upsert_library(self, name: str, **payload):
self.upsert_calls.append((name, payload))
fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings)
config_module.Config()
assert len(fake_settings.upsert_calls) == 1
name, payload = fake_settings.upsert_calls[0]
assert name == "comfyui"
assert payload["activate"] is False
def test_apply_library_settings_merges_extra_paths(monkeypatch, tmp_path):
"""Test that apply_library_settings correctly merges folder_paths with extra_folder_paths."""
loras_dir = tmp_path / "loras"

View File

@@ -2102,6 +2102,7 @@ describe('Interaction-level regression coverage', () => {
<div class="context-menu-item" data-action="check-model-updates"></div>
<div class="context-menu-item" data-action="fetch-missing-licenses"></div>
<div class="context-menu-item" data-action="cleanup-example-images-folders"></div>
<div class="context-menu-item" data-action="manage-excluded-models"></div>
</div>
`;
@@ -2120,6 +2121,9 @@ describe('Interaction-level regression coverage', () => {
startProgressUpdates: vi.fn(),
updateDownloadButtonText: vi.fn(),
};
window.pageControls = {
enterExcludedView: vi.fn().mockResolvedValue(undefined),
};
global.fetch = vi.fn()
.mockResolvedValueOnce({
@@ -2224,5 +2228,10 @@ describe('Interaction-level regression coverage', () => {
);
expect(loadingManagerStub.showSimpleLoading).toHaveBeenNthCalledWith(2, 'Refreshing license metadata for LoRAs...');
expect(fetchMissingItem.classList.contains('disabled')).toBe(false);
menu.showMenu(560, 600);
const excludedItem = document.querySelector('[data-action="manage-excluded-models"]');
excludedItem.dispatchEvent(new Event('click', { bubbles: true }));
expect(window.pageControls.enterExcludedView).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
describe('ModelContextMenuMixin.extractModelVersionId', () => {
it('accepts civitai.red model URLs', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777'
)
).toEqual({ modelId: '65423', modelVersionId: '777' });
});
it('rejects model-like URLs from unsupported hosts', () => {
expect(
ModelContextMenuMixin.extractModelVersionId(
'https://example.com/models/65423?modelVersionId=777'
)
).toEqual({ modelId: null, modelVersionId: null });
});
});

View File

@@ -0,0 +1,232 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const resetAndReloadMock = vi.fn();
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: resetAndReloadMock,
}));
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
const { state } = await import('../../../static/js/state/index.js');
const carPath = '/models/loras/aspark-owl.safetensors';
const copyPath = '/models/loras/aspark-owl-copy.safetensors';
const stalePath = '/models/loras/old-mismatch.safetensors';
function createModel(filePath, sha256, modelName = 'Aspark Owl - 2019') {
return {
file_path: filePath,
file_name: filePath.split('/').pop(),
model_name: modelName,
sha256,
preview_url: '',
preview_nsfw_level: 0,
modified: Date.now(),
civitai: { name: 'Version 1' },
};
}
function createGroup(hash = 'actual-hash') {
return {
hash,
models: [
createModel(carPath, hash),
createModel(copyPath, hash, 'Aspark Owl - 2019 Copy'),
],
};
}
async function createManager() {
document.body.innerHTML = `
<div id="modelGrid"></div>
<span id="duplicatesBadge"></span>
<span id="duplicatesSelectedCount"></span>
<button class="btn-delete-selected"></button>
`;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
statusText: 'OK',
json: async () => ({ success: true, duplicates: [] }),
});
const manager = new ModelDuplicatesManager({}, 'loras');
await Promise.resolve();
await Promise.resolve();
global.fetch.mockClear();
return manager;
}
beforeEach(() => {
vi.clearAllMocks();
state.loadingManager = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
state.loadingManager = null;
});
describe('ModelDuplicatesManager verification state', () => {
it('clears stale Different Hash state when a later verification confirms the group is duplicate', async () => {
const manager = await createManager();
const group = createGroup();
manager.duplicateGroups = [group];
manager.mismatchedFiles.set(carPath, 'old-actual-hash');
manager.renderDuplicateGroups();
expect(document.querySelector(`[data-file-path="${carPath}"]`).classList.contains('hash-mismatch')).toBe(true);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: true,
mismatched_files: [],
new_hash_map: {},
}),
});
await manager.handleVerifyHashes(group);
const carCard = document.querySelector(`[data-file-path="${carPath}"]`);
const carCheckbox = carCard.querySelector('.selector-checkbox');
expect(manager.mismatchedFiles.has(carPath)).toBe(false);
expect(carCard.classList.contains('hash-mismatch')).toBe(false);
expect(carCard.querySelector('.mismatch-badge')).toBeNull();
expect(carCheckbox.disabled).toBe(false);
});
it('keeps showing Different Hash for files returned as mismatched by the current verification', async () => {
const manager = await createManager();
const group = createGroup('metadata-hash');
manager.duplicateGroups = [group];
manager.selectedForDeletion.add(carPath);
manager.selectedForDeletion.add(copyPath);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: false,
mismatched_files: [carPath],
new_hash_map: {
[carPath]: 'actual-car-hash',
},
}),
});
await manager.handleVerifyHashes(group);
const carCard = document.querySelector(`[data-file-path="${carPath}"]`);
const carCheckbox = carCard.querySelector('.selector-checkbox');
expect(manager.mismatchedFiles.get(carPath)).toBe('actual-car-hash');
expect(manager.selectedForDeletion.has(carPath)).toBe(false);
expect(manager.selectedForDeletion.has(copyPath)).toBe(true);
expect(carCard.classList.contains('hash-mismatch')).toBe(true);
expect(carCard.querySelector('.mismatch-badge')?.textContent).toContain('Different Hash');
expect(carCheckbox.disabled).toBe(true);
});
it('refreshes selected count and delete button when selected files become mismatched', async () => {
const manager = await createManager();
const group = createGroup('metadata-hash');
manager.duplicateGroups = [group];
manager.selectedForDeletion.add(carPath);
manager.updateSelectedCount();
expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('1');
expect(document.querySelector('.btn-delete-selected').disabled).toBe(false);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: false,
mismatched_files: [carPath],
new_hash_map: {
[carPath]: 'actual-car-hash',
},
}),
});
await manager.handleVerifyHashes(group);
expect(manager.selectedForDeletion.size).toBe(0);
expect(document.getElementById('duplicatesSelectedCount').textContent).toBe('0');
expect(document.querySelector('.btn-delete-selected').disabled).toBe(true);
expect(document.querySelector('.btn-delete-selected').classList.contains('disabled')).toBe(true);
});
it('preserves valid selected deletion candidates when verification succeeds', async () => {
const manager = await createManager();
const group = createGroup();
manager.duplicateGroups = [group];
manager.selectedForDeletion.add(carPath);
manager.selectedForDeletion.add(copyPath);
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
verified_as_duplicates: true,
mismatched_files: [],
new_hash_map: {},
}),
});
await manager.handleVerifyHashes(group);
expect(manager.selectedForDeletion.has(carPath)).toBe(true);
expect(manager.selectedForDeletion.has(copyPath)).toBe(true);
expect(document.querySelector(`[data-file-path="${carPath}"] .selector-checkbox`).checked).toBe(true);
expect(document.querySelector(`[data-file-path="${copyPath}"] .selector-checkbox`).checked).toBe(true);
});
it('prunes mismatch and verified state that no longer belongs to refreshed duplicate groups', async () => {
const manager = await createManager();
const visibleGroup = createGroup('visible-hash');
manager.mismatchedFiles.set(stalePath, 'stale-hash');
manager.mismatchedFiles.set(carPath, 'visible-mismatch');
manager.verifiedGroups.add('stale-group-hash');
manager.verifiedGroups.add('visible-hash');
global.fetch.mockResolvedValueOnce({
ok: true,
statusText: 'OK',
json: async () => ({
success: true,
duplicates: [visibleGroup],
}),
});
await manager.findDuplicates();
expect(manager.mismatchedFiles.has(stalePath)).toBe(false);
expect(manager.mismatchedFiles.has(carPath)).toBe(true);
expect(manager.verifiedGroups.has('stale-group-hash')).toBe(false);
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
});
});

View File

@@ -26,6 +26,7 @@ vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitaiUrl: vi.fn(),
}));
const stateMock = {

View File

@@ -12,6 +12,7 @@ const apiClientMock = {
};
const showToastMock = vi.fn();
const openCivitaiByMetadataMock = vi.fn();
const updatePanelPositionsMock = vi.fn();
const downloadManagerMock = {
showDownloadModal: vi.fn(),
@@ -40,6 +41,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock,
}));
@@ -84,6 +86,7 @@ beforeEach(() => {
});
afterEach(() => {
delete window.bulkManager;
delete window.modelDuplicatesManager;
delete global.fetch;
vi.useRealTimers();
@@ -107,11 +110,17 @@ function renderControlsDom(pageKey) {
<div class="search-option-tag active" data-option="filename"></div>
</div>
<div id="filterPanel" class="filter-panel hidden">
<input id="baseModelSearchInput" />
<div id="baseModelTags" class="filter-tags"></div>
<div id="baseModelEmptyState" hidden></div>
<div id="filterPresets" class="filter-presets"></div>
<div id="modelTagsFilter" class="filter-tags"></div>
<button class="clear-filter"></button>
</div>
<div class="controls">
<div id="excludedViewBanner" class="excluded-view-banner hidden">
<button id="excludedViewBackBtn">Back</button>
</div>
<div class="actions">
<div class="action-buttons">
<div class="control-group">
@@ -170,6 +179,9 @@ function renderControlsDom(pageKey) {
<i class="fas fa-times-circle clear-filter"></i>
</div>
</div>
<div id="breadcrumbContainer"></div>
<div id="duplicatesBanner" style="display: none;"></div>
<div class="alphabet-bar-container"></div>
`;
}
@@ -277,6 +289,8 @@ describe('FilterManager tag and base model filters', () => {
const manager = new FilterManager({ page: pageKey });
expect(global.fetch).toHaveBeenCalledWith(`/api/lm/${pageKey}/base-models?limit=0`);
await vi.waitFor(() => {
const chip = document.querySelector('[data-base-model="SDXL"]');
expect(chip).not.toBeNull();
@@ -302,6 +316,259 @@ describe('FilterManager tag and base model filters', () => {
expect(getCurrentPageState().filters.baseModel).toEqual([]);
expect(baseModelChip.classList.contains('active')).toBe(false);
});
it('filters base model chips locally without changing selected state', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [
{ name: 'SDXL', count: 2 },
{ name: 'LTXV 2.3', count: 1 },
],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { getCurrentPageState } = stateModule;
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
new FilterManager({ page: 'loras' });
await vi.waitFor(() => {
expect(document.querySelector('[data-base-model="LTXV 2.3"]')).not.toBeNull();
});
const searchInput = document.getElementById('baseModelSearchInput');
const ltxvChip = document.querySelector('[data-base-model="LTXV 2.3"]');
ltxvChip.dispatchEvent(new Event('click', { bubbles: true }));
await vi.waitFor(() => expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledTimes(1));
expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']);
loadMoreWithVirtualScrollMock.mockClear();
searchInput.value = 'sdx';
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
expect(document.querySelector('[data-base-model="SDXL"]')).not.toBeNull();
expect(document.querySelector('[data-base-model="LTXV 2.3"]')).toBeNull();
expect(document.getElementById('baseModelEmptyState').hidden).toBe(true);
expect(getCurrentPageState().filters.baseModel).toEqual(['LTXV 2.3']);
searchInput.value = 'zzz';
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
expect(document.getElementById('baseModelEmptyState').hidden).toBe(false);
searchInput.value = 'ltx';
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
const restoredChip = document.querySelector('[data-base-model="LTXV 2.3"]');
expect(restoredChip).not.toBeNull();
expect(restoredChip.classList.contains('active')).toBe(true);
});
it('disables browser autocomplete helpers for the base model search input', async () => {
renderControlsDom('loras');
const searchInput = document.getElementById('baseModelSearchInput');
searchInput.setAttribute('autocomplete', 'off');
searchInput.setAttribute('autocorrect', 'off');
searchInput.setAttribute('autocapitalize', 'none');
searchInput.setAttribute('spellcheck', 'false');
expect(searchInput.getAttribute('autocomplete')).toBe('off');
expect(searchInput.getAttribute('autocorrect')).toBe('off');
expect(searchInput.getAttribute('autocapitalize')).toBe('none');
expect(searchInput.getAttribute('spellcheck')).toBe('false');
});
it('focuses the base model search input when opening the filter panel', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL', count: 2 }],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
const searchInput = document.getElementById('baseModelSearchInput');
expect(document.activeElement).not.toBe(searchInput);
manager.toggleFilterPanel();
expect(document.activeElement).toBe(searchInput);
});
it('does not let base model search trigger bulk shortcuts', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL', count: 2 }],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const filterManager = new FilterManager({ page: 'loras' });
const bulkManager = new BulkManager();
const searchInput = document.getElementById('baseModelSearchInput');
window.filterManager = filterManager;
searchInput.focus();
const bulkEvent = new KeyboardEvent('keydown', {
key: 'b',
bubbles: true,
cancelable: true,
});
Object.defineProperty(bulkEvent, 'target', { value: searchInput });
expect(bulkManager.handleGlobalKeyboard(bulkEvent)).toBe(false);
const selectAllEvent = new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
Object.defineProperty(selectAllEvent, 'target', { value: searchInput });
expect(bulkManager.handleGlobalKeyboard(selectAllEvent)).toBe(false);
});
it('closes the filter panel on Escape', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL', count: 2 }],
}),
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const { eventManager } = await import('../../../static/js/utils/EventManager.js');
const { initializeEventManagement } = await import('../../../static/js/utils/eventManagementInit.js');
eventManager.cleanup();
initializeEventManagement();
const manager = new FilterManager({ page: 'loras' });
window.filterManager = manager;
manager.toggleFilterPanel();
expect(manager.filterPanel.classList.contains('hidden')).toBe(false);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.filterPanel.classList.contains('hidden')).toBe(true);
eventManager.cleanup();
});
it('applies all base models from a preset using the full base model list', async () => {
global.fetch = vi.fn((url) => {
if (url.includes('/base-models?limit=0')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
base_models: [
{ name: 'SDXL 1.0', count: 5 },
{ name: 'SDXL Lightning', count: 3 },
{ name: 'SDXL Hyper', count: 2 },
],
}),
});
}
if (url.includes('/base-models')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
base_models: [{ name: 'SDXL 1.0', count: 5 }],
}),
});
}
if (url.includes('/top-tags')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
tags: [],
}),
});
}
if (url.includes('/model-types')) {
return Promise.resolve({
ok: true,
json: async () => ({
success: true,
model_types: [],
}),
});
}
return Promise.resolve({
ok: true,
json: async () => ({ success: true }),
});
});
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
stateModule.state.global.settings.filter_presets = {
loras: [
{
name: 'SDXL Family',
filters: {
baseModel: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'],
tags: {},
license: {},
modelTypes: [],
tagLogic: 'any',
},
},
],
};
const { getCurrentPageState } = stateModule;
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const manager = new FilterManager({ page: 'loras' });
await vi.waitFor(() => {
expect(document.querySelector('[data-base-model="SDXL Hyper"]')).not.toBeNull();
});
await manager.presetManager.applyPreset('SDXL Family');
expect(manager.activePreset).toBe('SDXL Family');
expect(manager.filters.baseModel).toEqual(['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper']);
expect(getCurrentPageState().filters.baseModel).toEqual(['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper']);
expect(loadMoreWithVirtualScrollMock).toHaveBeenCalledWith(true, false);
expect(showToastMock).toHaveBeenCalledWith(
'Preset "SDXL Family" applied',
{},
'success',
);
});
});
describe('PageControls favorites, sorting, and duplicates scenarios', () => {
@@ -574,4 +841,93 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
duplicateButton.click();
expect(toggleDuplicateMode).toHaveBeenCalledTimes(1);
});
it.each([
['loras', 'LorasControls'],
['checkpoints', 'CheckpointsControls'],
['embeddings', 'EmbeddingsControls'],
])('switches %s page into excluded mode and restores state', async (pageKey, exportName) => {
renderControlsDom(pageKey);
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState(pageKey);
const pageState = stateModule.getCurrentPageState();
pageState.filters.search = 'active-search';
pageState.showFavoritesOnly = true;
pageState.showUpdateAvailableOnly = true;
const controlsModule = await import('../../../static/js/components/controls/index.js');
const ControlsClass = controlsModule[exportName];
const controls = new ControlsClass();
await controls.enterExcludedView();
expect(pageState.viewMode).toBe('excluded');
expect(pageState.filters.search).toBe('');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(false);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(true);
expect(document.getElementById('filterButton').disabled).toBe(true);
pageState.filters.search = 'excluded-search';
await controls.exitExcludedView();
expect(pageState.viewMode).toBe('active');
expect(pageState.filters.search).toBe('active-search');
expect(pageState.excludedViewState.search).toBe('excluded-search');
expect(resetAndReloadMock).toHaveBeenLastCalledWith(true);
expect(document.getElementById('excludedViewBanner').classList.contains('hidden')).toBe(true);
expect(document.querySelector('[data-action="fetch"]').classList.contains('hidden')).toBe(false);
expect(document.getElementById('filterButton').disabled).toBe(false);
});
it('suspends bulk and duplicate modes for excluded view and restores custom filter banner on exit', async () => {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const pageState = stateModule.getCurrentPageState();
stateModule.state.bulkMode = true;
pageState.duplicatesMode = true;
sessionStorage.setItem('lora_manager_recipe_to_lora_filterLoraHash', 'hash-1');
sessionStorage.setItem('lora_manager_filterRecipeName', 'Recipe Filter');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
const toggleBulkMode = vi.fn(() => {
stateModule.state.bulkMode = !stateModule.state.bulkMode;
});
const exitDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = false;
});
const enterDuplicateMode = vi.fn(() => {
pageState.duplicatesMode = true;
});
window.bulkManager = { toggleBulkMode };
window.modelDuplicatesManager = {
duplicateGroups: [{ hash: 'dup-1', models: [{ file_path: 'a' }, { file_path: 'b' }] }],
exitDuplicateMode,
enterDuplicateMode,
};
const controls = new LorasControls();
const indicator = document.getElementById('customFilterIndicator');
expect(indicator.classList.contains('hidden')).toBe(false);
await controls.enterExcludedView();
expect(toggleBulkMode).toHaveBeenCalledTimes(1);
expect(exitDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(false);
expect(pageState.duplicatesMode).toBe(false);
expect(indicator.classList.contains('hidden')).toBe(true);
await controls.exitExcludedView();
expect(indicator.classList.contains('hidden')).toBe(false);
expect(toggleBulkMode).toHaveBeenCalledTimes(2);
expect(enterDuplicateMode).toHaveBeenCalledTimes(1);
expect(stateModule.state.bulkMode).toBe(true);
expect(pageState.duplicatesMode).toBe(true);
});
});

View File

@@ -49,7 +49,7 @@ describe("TriggerWords HTML Escaping", () => {
expect(html).toContain('data-word="word&#39;with&#39;quotes"');
expect(html).toContain('data-word="&lt;tag&gt;"');
// Check for the onclick handler
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
// Copy/edit handlers are attached by setupTriggerWordsEditMode, not inline HTML.
expect(html).not.toContain('onclick="copyTriggerWord');
});
});

View File

@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
TRIGGER_WORDS_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
} = vi.hoisted(() => ({
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.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,
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => ({
saveModelMetadata: vi.fn(),
})),
}));
describe("TriggerWords inline editing", () => {
let renderTriggerWords;
let setupTriggerWordsEditMode;
let showToast;
let copyToClipboard;
beforeEach(async () => {
document.body.innerHTML = '';
vi.clearAllMocks();
global.fetch = vi.fn(async () => ({
json: async () => ({
success: true,
trained_words: [],
class_tokens: null,
}),
}));
const module = await import(TRIGGER_WORDS_MODULE);
const uiHelpers = await import(UI_HELPERS_MODULE);
renderTriggerWords = module.renderTriggerWords;
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
showToast = uiHelpers.showToast;
copyToClipboard = uiHelpers.copyToClipboard;
});
async function enterEditMode(words = ["alpha", "beta"]) {
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
setupTriggerWordsEditMode();
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(document.querySelector('.metadata-suggestions-dropdown')).toBeTruthy();
});
}
function editFirstTag(nextValue, key = 'Enter') {
const firstTag = document.querySelector('.trigger-word-tag');
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const input = firstTag.querySelector('.trigger-word-edit-input');
input.value = nextValue;
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
return firstTag;
}
it("updates an existing trigger word in place", async () => {
await enterEditMode();
const firstTag = editFirstTag("gamma");
expect(firstTag.dataset.word).toBe("gamma");
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("gamma");
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
});
it("enters edit mode and edits the double-clicked tag from display mode without copying", async () => {
document.body.innerHTML = renderTriggerWords(["alpha", "beta"], "test.safetensors");
setupTriggerWordsEditMode();
const firstTag = document.querySelector('.trigger-word-tag');
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
firstTag.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(document.querySelector('.trigger-words').classList.contains('edit-mode')).toBe(true);
expect(firstTag.querySelector('.trigger-word-edit-input')).toBeTruthy();
});
await new Promise(resolve => setTimeout(resolve, 260));
expect(copyToClipboard).not.toHaveBeenCalled();
});
it("keeps the original word and shows a toast when editing to a duplicate", async () => {
await enterEditMode();
const firstTag = editFirstTag("beta");
expect(firstTag.dataset.word).toBe("alpha");
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
expect(showToast).toHaveBeenCalledWith('toast.triggerWords.alreadyExists', {}, 'error');
});
it("restores the original value when Escape is pressed", async () => {
await enterEditMode();
const firstTag = editFirstTag("gamma", "Escape");
expect(firstTag.dataset.word).toBe("alpha");
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
});
it("preserves the current tag dimensions while editing long trigger words", async () => {
await enterEditMode(["alpha beta gamma delta epsilon zeta eta theta"]);
const firstTag = document.querySelector('.trigger-word-tag');
vi.spyOn(firstTag, 'getBoundingClientRect').mockReturnValue({
width: 320,
height: 44,
top: 0,
right: 320,
bottom: 44,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const editor = firstTag.querySelector('.trigger-word-edit-input');
expect(editor.tagName).toBe('TEXTAREA');
expect(firstTag.style.getPropertyValue('--trigger-word-edit-width')).toBe('320px');
expect(firstTag.style.getPropertyValue('--trigger-word-edit-height')).toBe('44px');
});
it("restores all original trigger words when edit mode is canceled", async () => {
await enterEditMode();
editFirstTag("gamma");
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const words = Array.from(document.querySelectorAll('.trigger-word-tag'))
.map(tag => tag.dataset.word);
expect(words).toEqual(["alpha", "beta"]);
});
});

View File

@@ -136,4 +136,14 @@ describe('DownloadManager version history badges', () => {
expect(items[1].querySelector('.local-path')?.textContent).toContain('/models/still-local.safetensors');
expect(items[1].querySelector('.downloaded-badge')).toBeNull();
});
it('extracts model and version ids from civitai.red URLs', () => {
const manager = new DownloadManager();
expect(
manager.extractModelId('https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777')
).toBe('65423');
expect(manager.modelVersionId).toBe('777');
expect(manager.source).toBeNull();
});
});

View File

@@ -305,4 +305,39 @@ describe('SettingsManager library controls', () => {
'success',
);
});
it('loads download backend settings and toggles the aria2 path field', () => {
const manager = createManager();
document.body.innerHTML = `
<select id="downloadBackend">
<option value="python">Python</option>
<option value="aria2">aria2</option>
</select>
<div id="aria2PathSetting" style="display: none;"></div>
<input id="aria2cPath" />
`;
state.global.settings = {
download_backend: 'aria2',
aria2c_path: '/usr/bin/aria2c',
};
const saveSpy = vi.spyOn(manager, 'saveSelectSetting').mockResolvedValue();
manager.loadDownloadBackendSettings();
const backendSelect = document.getElementById('downloadBackend');
const aria2PathSetting = document.getElementById('aria2PathSetting');
const aria2cPath = document.getElementById('aria2cPath');
expect(backendSelect.value).toBe('aria2');
expect(aria2cPath.value).toBe('/usr/bin/aria2c');
expect(aria2PathSetting.style.display).toBe('block');
backendSelect.value = 'python';
backendSelect.onchange();
expect(aria2PathSetting.style.display).toBe('none');
expect(saveSpy).toHaveBeenCalledWith('downloadBackend', 'download_backend');
});
});

View File

@@ -7,6 +7,8 @@ const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const removeStorageItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn();
@@ -53,6 +55,8 @@ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
removeStorageItem: removeStorageItemMock,
}));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({

View File

@@ -14,6 +14,7 @@ describe('state module', () => {
expect(defaultSettings).toMatchObject({
civitai_api_key: '',
civitai_host: 'civitai.com',
language: 'en',
blur_mature_content: true,
mature_blur_level: 'R'

View File

@@ -1,10 +1,18 @@
import { describe, it, expect } from 'vitest';
import {
DEFAULT_CIVITAI_PAGE_HOST,
normalizeCivitaiPageHost,
buildCivitaiModelUrl,
buildCivitaiSearchUrl,
buildCivitaiUrl,
rewriteCivitaiUrl,
getOptimizedUrl,
getShowcaseUrl,
getThumbnailUrl,
extractCivitaiImageId,
extractCivitaiModelUrlParts,
isCivitaiUrl,
isSupportedCivitaiPageHost,
OptimizationMode
} from '../../../static/js/utils/civitaiUtils.js';
@@ -16,6 +24,47 @@ describe('civitaiUtils', () => {
});
});
describe('Civitai page URL helpers', () => {
it('normalizes invalid hosts to the default page host', () => {
expect(DEFAULT_CIVITAI_PAGE_HOST).toBe('civitai.com');
expect(normalizeCivitaiPageHost('civitai.red')).toBe('civitai.red');
expect(normalizeCivitaiPageHost(' CIVITAI.COM ')).toBe('civitai.com');
expect(normalizeCivitaiPageHost('example.com')).toBe('civitai.com');
expect(normalizeCivitaiPageHost(null)).toBe('civitai.com');
});
it('builds model URLs using the configured host', () => {
expect(buildCivitaiModelUrl(123, 456, 'civitai.red')).toBe(
'https://civitai.red/models/123?modelVersionId=456'
);
expect(buildCivitaiModelUrl(123, null, 'civitai.com')).toBe(
'https://civitai.com/models/123'
);
});
it('falls back to the model-versions endpoint when only a version id is available', () => {
expect(buildCivitaiModelUrl(null, 456, 'civitai.red')).toBe(
'https://civitai.red/model-versions/456'
);
});
it('builds search URLs using the configured host', () => {
expect(buildCivitaiSearchUrl('demo model', 'civitai.red')).toBe(
'https://civitai.red/models?query=demo%20model'
);
expect(buildCivitaiSearchUrl('', 'civitai.red')).toBe(null);
});
it('prefers model/version URLs and falls back to search URLs', () => {
expect(buildCivitaiUrl({ modelId: 321, versionId: 654, host: 'civitai.red' })).toBe(
'https://civitai.red/models/321?modelVersionId=654'
);
expect(buildCivitaiUrl({ modelName: 'search me', host: 'civitai.red' })).toBe(
'https://civitai.red/models?query=search%20me'
);
});
});
describe('rewriteCivitaiUrl', () => {
it('should rewrite image URLs with /original=true for thumbnail mode', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg';
@@ -217,4 +266,43 @@ describe('civitaiUtils', () => {
expect(isCivitaiUrl('not-a-url')).toBe(false);
});
});
describe('isSupportedCivitaiPageHost', () => {
it('accepts civitai.com and civitai.red page hosts', () => {
expect(isSupportedCivitaiPageHost('civitai.com')).toBe(true);
expect(isSupportedCivitaiPageHost('civitai.red')).toBe(true);
});
it('rejects unrelated hosts', () => {
expect(isSupportedCivitaiPageHost('www.civitai.com')).toBe(false);
expect(isSupportedCivitaiPageHost('www.civitai.red')).toBe(false);
expect(isSupportedCivitaiPageHost('example.com')).toBe(false);
expect(isSupportedCivitaiPageHost('')).toBe(false);
expect(isSupportedCivitaiPageHost(null)).toBe(false);
});
});
describe('extractCivitaiModelUrlParts', () => {
it('extracts model and version ids from civitai.red model URLs', () => {
expect(
extractCivitaiModelUrlParts('https://civitai.red/models/65423/name?modelVersionId=98765')
).toEqual({ modelId: '65423', modelVersionId: '98765' });
});
it('rejects model-like URLs from unsupported hosts', () => {
expect(
extractCivitaiModelUrlParts('https://example.com/models/65423?modelVersionId=98765')
).toEqual({ modelId: null, modelVersionId: null });
});
});
describe('extractCivitaiImageId', () => {
it('extracts image ids from civitai.red image URLs', () => {
expect(extractCivitaiImageId('https://civitai.red/images/126920345')).toBe('126920345');
});
it('rejects image-like URLs from unsupported hosts', () => {
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
});
});
});

View File

@@ -6,6 +6,8 @@ const {
STORAGE_MODULE,
CONSTANTS_MODULE,
EVENT_MANAGER_MODULE,
BANNER_SERVICE_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
} = vi.hoisted(() => ({
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
@@ -13,12 +15,16 @@ const {
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname,
EVENT_MANAGER_MODULE: new URL('../../../static/js/utils/EventManager.js', import.meta.url).pathname,
BANNER_SERVICE_MODULE: new URL('../../../static/js/managers/BannerService.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,
}));
const translateMock = vi.fn((key, _params, fallback) => fallback || key);
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const registerBannerMock = vi.fn();
const showModalMock = vi.fn();
vi.mock(I18N_MODULE, () => ({
translate: translateMock,
@@ -50,6 +56,18 @@ vi.mock(EVENT_MANAGER_MODULE, () => ({
},
}));
vi.mock(BANNER_SERVICE_MODULE, () => ({
bannerService: {
registerBanner: registerBannerMock,
},
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: showModalMock,
},
}));
describe('UI helper DOM utilities', () => {
beforeEach(() => {
document.body.innerHTML = '';
@@ -57,6 +75,8 @@ describe('UI helper DOM utilities', () => {
document.documentElement.removeAttribute('data-theme');
getStorageItemMock.mockReset();
setStorageItemMock.mockReset();
registerBannerMock.mockReset();
showModalMock.mockReset();
translateMock.mockReset();
globalThis.requestAnimationFrame = (cb) => cb();
});
@@ -156,4 +176,58 @@ describe('UI helper DOM utilities', () => {
'#2 (Character Subgraph) Nested Loader',
]);
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return false;
}
return defaultValue;
});
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(123, 456, 'Demo Model');
expect(setStorageItemMock).toHaveBeenCalledWith('civitai_host_info_banner_seen', true);
expect(registerBannerMock).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.com/models/123?modelVersionId=456',
'_blank',
'noopener,noreferrer'
);
});
it('uses the configured red host for fallback searches', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
getStorageItemMock.mockImplementation((key, defaultValue) => {
if (key === 'civitai_host_info_banner_seen') {
return true;
}
return defaultValue;
});
const stateModule = await import(STATE_MODULE);
stateModule.state.global = {
settings: {
civitai_host: 'civitai.red',
},
};
const { openCivitaiByMetadata } = await import(UI_HELPERS_MODULE);
openCivitaiByMetadata(null, null, 'Demo Model');
expect(registerBannerMock).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith(
'https://civitai.red/models?query=Demo%20Model',
'_blank',
'noopener,noreferrer'
);
});
});

View File

@@ -354,3 +354,33 @@ def test_lora_manager_cache_updates_when_loras_removed(metadata_registry):
metadata = metadata_registry.get_metadata("prompt3")
assert "lora_node" not in metadata[LORAS]
def test_lora_manager_checkpoint_and_unet_loaders_extract_models(metadata_registry):
metadata_registry.start_collection("prompt1")
metadata_registry.record_node_execution(
"checkpoint_node",
"CheckpointLoaderLM",
{"ckpt_name": ["models/checkpoint.safetensors"]},
None,
)
metadata_registry.record_node_execution(
"unet_node",
"UNETLoaderLM",
{"unet_name": ["models/diffusion_model.safetensors"], "weight_dtype": ["default"]},
None,
)
metadata = metadata_registry.get_metadata("prompt1")
assert metadata[MODELS]["checkpoint_node"] == {
"name": "models/checkpoint.safetensors",
"type": "checkpoint",
"node_id": "checkpoint_node",
}
assert metadata[MODELS]["unet_node"] == {
"name": "models/diffusion_model.safetensors",
"type": "checkpoint",
"node_id": "unet_node",
}

View File

@@ -99,6 +99,99 @@ def test_duplicate_groups_respect_active_state():
assert filtered == "A, B"
def test_group_mode_can_exclude_individual_tags_within_active_group():
node = TriggerWordToggleLM()
trigger_data = [
{
"text": "outfit red, outfit blue, smiling",
"active": True,
"strength": None,
"highlighted": False,
"items": [
{"text": "outfit red", "active": True, "highlighted": False},
{"text": "outfit blue", "active": False, "highlighted": False},
{"text": "smiling", "active": True, "highlighted": False},
],
}
]
(filtered,) = node.process_trigger_words(
id="node",
group_mode=True,
default_active=True,
allow_strength_adjustment=False,
orinalMessage="outfit red, outfit blue, smiling",
toggle_trigger_words=trigger_data,
)
assert filtered == "outfit red, smiling"
def test_group_mode_keeps_group_strength_when_individual_tags_are_excluded():
node = TriggerWordToggleLM()
trigger_data = [
{
"text": "(outfit red, outfit blue, smiling:1.15)",
"active": True,
"strength": 1.15,
"highlighted": False,
"items": [
{"text": "outfit red", "active": True, "highlighted": False},
{"text": "outfit blue", "active": False, "highlighted": False},
{"text": "smiling", "active": True, "highlighted": False},
],
}
]
(filtered,) = node.process_trigger_words(
id="node",
group_mode=True,
default_active=True,
allow_strength_adjustment=True,
orinalMessage="outfit red, outfit blue, smiling",
toggle_trigger_words=trigger_data,
)
assert filtered == "(outfit red, smiling:1.15)"
def test_group_mode_omits_group_when_all_children_are_disabled():
node = TriggerWordToggleLM()
trigger_data = [
{
"text": "A, B",
"active": True,
"strength": None,
"highlighted": False,
"items": [
{"text": "A", "active": False, "highlighted": False},
{"text": "B", "active": False, "highlighted": False},
],
},
{
"text": "C, D",
"active": True,
"strength": None,
"highlighted": False,
"items": [
{"text": "C", "active": True, "highlighted": False},
{"text": "D", "active": True, "highlighted": False},
],
},
]
(filtered,) = node.process_trigger_words(
id="node",
group_mode=True,
default_active=True,
allow_strength_adjustment=False,
orinalMessage="A, B,, C, D",
toggle_trigger_words=trigger_data,
)
assert filtered == "C, D"
def test_trigger_words_override_different_from_original():
node = TriggerWordToggleLM()
trigger_data = [

View File

@@ -94,7 +94,7 @@ class DummyDoctorScanner:
class DummyCivitaiClient:
def __init__(self, *, success=True, result=None):
self.base_url = 'https://civitai.com/api/v1'
self.base_url = 'https://civitai.red/api/v1'
self._success = success
self._result = result if result is not None else {'items': []}

View File

@@ -0,0 +1,38 @@
import json
import logging
from types import SimpleNamespace
import pytest
from py.routes.handlers.model_handlers import ModelQueryHandler
class DummyService:
def __init__(self):
self.received_limit = None
async def get_base_models(self, limit):
self.received_limit = limit
return [{"name": "SDXL", "count": 2}]
@pytest.mark.asyncio
async def test_model_query_handler_accepts_limit_zero_for_base_models():
service = DummyService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"}))
payload = json.loads(response.text)
assert payload["success"] is True
assert service.received_limit == 0
@pytest.mark.asyncio
async def test_model_query_handler_rejects_negative_limit_for_base_models():
service = DummyService()
handler = ModelQueryHandler(service=service, logger=logging.getLogger(__name__))
await handler.get_base_models(SimpleNamespace(query={"limit": "-1"}))
assert service.received_limit == 20

View File

@@ -0,0 +1,44 @@
import json
import logging
from types import SimpleNamespace
import pytest
from py.routes.handlers.recipe_handlers import RecipeQueryHandler
async def _noop():
return None
@pytest.mark.asyncio
async def test_recipe_query_handler_base_models_limit_zero_returns_all():
cache = SimpleNamespace(
raw_data=[
{"base_model": "SDXL"},
{"base_model": "LTXV 2.3"},
{"base_model": "SDXL"},
]
)
scanner = SimpleNamespace(get_cached_data=lambda: None)
async def get_cached_data():
return cache
scanner.get_cached_data = get_cached_data
handler = RecipeQueryHandler(
ensure_dependencies_ready=_noop,
recipe_scanner_getter=lambda: scanner,
format_recipe_file_url=lambda value: value,
logger=logging.getLogger(__name__),
)
response = await handler.get_base_models(SimpleNamespace(query={"limit": "0"}))
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["base_models"] == [
{"name": "SDXL", "count": 2},
{"name": "LTXV 2.3", "count": 1},
]

View File

@@ -274,7 +274,9 @@ class StubCivitaiClient:
def __init__(self) -> None:
self.image_info: Dict[str, Any] = {}
async def get_image_info(self, image_id: str) -> Optional[Dict[str, Any]]:
async def get_image_info(
self, image_id: str, source_url: str | None = None
) -> Optional[Dict[str, Any]]:
return self.image_info.get(image_id)
@@ -635,6 +637,58 @@ async def test_import_remote_video_recipe(monkeypatch, tmp_path: Path) -> None:
assert call["extension"] == ".mp4"
async def test_import_remote_recipe_supports_civitai_red(monkeypatch, tmp_path: Path) -> None:
async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.civitai.image_info["126920345"] = {
"id": 126920345,
"url": "https://image.civitai.com/x/y/original=true/sample.jpeg",
"type": "image",
}
response = await harness.client.get(
"/api/lm/recipes/import-remote",
params={
"image_url": "https://civitai.red/images/126920345",
"name": "Red Recipe",
"resources": json.dumps([]),
"base_model": "Flux",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert harness.downloader.urls
assert "width=450,optimized=true" in harness.downloader.urls[0]
async def test_analyze_remote_image_supports_civitai_red(
monkeypatch, tmp_path: Path
) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.result = SimpleNamespace(payload={"loras": []}, status=200)
response = await harness.client.post(
"/api/lm/recipes/analyze-image",
json={"url": "https://civitai.red/images/126920345"},
)
payload = await response.json()
assert response.status == 200
assert payload == {"loras": []}
assert harness.analysis.remote_calls == [
"https://civitai.red/images/126920345"
]
async def test_analyze_uploaded_image_error_path(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis.raise_for_uploaded = RecipeValidationError(

View File

@@ -0,0 +1,354 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock
import pytest
from py.services.aria2_downloader import Aria2Downloader, Aria2Error
from py.services.aria2_transfer_state import Aria2TransferStateStore
from py.services import aria2_transfer_state
@pytest.fixture(autouse=True)
def isolate_aria2_state(monkeypatch, tmp_path):
state_path = tmp_path / "cache" / "aria2" / "downloads.json"
monkeypatch.setattr(
aria2_transfer_state,
"get_aria2_state_path",
lambda: str(state_path),
)
@pytest.mark.asyncio
async def test_download_file_polls_until_complete(tmp_path, monkeypatch):
downloader = Aria2Downloader()
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
downloader._rpc_secret = "secret"
save_path = tmp_path / "downloads" / "model.safetensors"
progress_events = []
rpc_calls = []
statuses = iter(
[
{
"gid": "gid-1",
"status": "active",
"completedLength": "5",
"totalLength": "10",
"downloadSpeed": "25",
},
{
"gid": "gid-1",
"status": "complete",
"completedLength": "10",
"totalLength": "10",
"downloadSpeed": "0",
"files": [{"path": str(save_path)}],
},
]
)
async def fake_rpc_call(method, params):
rpc_calls.append((method, params))
if method == "aria2.addUri":
return "gid-1"
if method == "aria2.tellStatus":
return next(statuses)
raise AssertionError(f"Unexpected RPC method: {method}")
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
monkeypatch.setattr(
downloader,
"_resolve_authenticated_redirect_url",
AsyncMock(
return_value="https://signed.example.com/model.safetensors?token=abc"
),
)
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
async def progress_callback(progress, snapshot=None):
progress_events.append(snapshot.percent_complete if snapshot else progress)
success, result = await downloader.download_file(
"https://civitai.com/api/download/models/123",
str(save_path),
download_id="download-1",
progress_callback=progress_callback,
headers={"Authorization": "Bearer token"},
)
assert success is True
assert result == str(save_path)
assert progress_events == [50.0, 100.0]
assert downloader._transfers == {}
assert rpc_calls[0][0] == "aria2.addUri"
assert rpc_calls[0][1][0] == [
"https://signed.example.com/model.safetensors?token=abc"
]
assert rpc_calls[0][1][1]["out"] == "model.safetensors"
assert "header" not in rpc_calls[0][1][1]
@pytest.mark.asyncio
async def test_transfer_state_store_shares_lock_and_preserves_concurrent_updates(tmp_path):
state_path = tmp_path / "cache" / "aria2" / "downloads.json"
store_a = Aria2TransferStateStore(str(state_path))
store_b = Aria2TransferStateStore(str(state_path))
assert store_a._lock is store_b._lock
await asyncio.gather(
store_a.upsert("download-1", {"status": "downloading", "gid": "gid-1"}),
store_b.upsert("download-2", {"status": "paused", "gid": "gid-2"}),
)
assert await store_a.get("download-1") == {"status": "downloading", "gid": "gid-1"}
assert await store_b.get("download-2") == {"status": "paused", "gid": "gid-2"}
@pytest.mark.asyncio
async def test_download_file_keeps_auth_headers_when_civitai_does_not_redirect(
tmp_path, monkeypatch
):
downloader = Aria2Downloader()
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
downloader._rpc_secret = "secret"
save_path = tmp_path / "downloads" / "model.safetensors"
rpc_calls = []
statuses = iter(
[
{
"gid": "gid-1",
"status": "complete",
"completedLength": "10",
"totalLength": "10",
"downloadSpeed": "0",
"files": [{"path": str(save_path)}],
},
]
)
async def fake_rpc_call(method, params):
rpc_calls.append((method, params))
if method == "aria2.addUri":
return "gid-1"
if method == "aria2.tellStatus":
return next(statuses)
raise AssertionError(f"Unexpected RPC method: {method}")
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
monkeypatch.setattr(
downloader,
"_resolve_authenticated_redirect_url",
AsyncMock(return_value="https://civitai.com/api/download/models/123"),
)
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
success, result = await downloader.download_file(
"https://civitai.com/api/download/models/123",
str(save_path),
download_id="download-1",
headers={"Authorization": "Bearer token"},
)
assert success is True
assert result == str(save_path)
assert rpc_calls[0][1][0] == ["https://civitai.com/api/download/models/123"]
assert rpc_calls[0][1][1]["header"] == ["Authorization: Bearer token"]
@pytest.mark.asyncio
async def test_pause_resume_cancel_forward_to_rpc(monkeypatch):
downloader = Aria2Downloader()
downloader._transfers["download-1"] = type(
"Transfer", (), {"gid": "gid-1", "save_path": "/tmp/model.safetensors"}
)()
calls = []
async def fake_rpc_call(method, params):
calls.append((method, params))
return "gid-1"
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
pause_result = await downloader.pause_download("download-1")
resume_result = await downloader.resume_download("download-1")
cancel_result = await downloader.cancel_download("download-1")
assert pause_result["success"] is True
assert resume_result["success"] is True
assert cancel_result["success"] is True
assert calls == [
("aria2.forcePause", ["gid-1"]),
("aria2.unpause", ["gid-1"]),
("aria2.forceRemove", ["gid-1"]),
]
@pytest.mark.asyncio
async def test_download_file_reuses_existing_transfer_without_add_uri(
tmp_path, monkeypatch
):
downloader = Aria2Downloader()
downloader._rpc_url = "http://127.0.0.1/jsonrpc"
downloader._rpc_secret = "secret"
save_path = tmp_path / "downloads" / "model.safetensors"
downloader._transfers["download-1"] = type(
"Transfer", (), {"gid": "gid-1", "save_path": str(save_path)}
)()
rpc_calls = []
statuses = iter(
[
{
"gid": "gid-1",
"status": "active",
"completedLength": "5",
"totalLength": "10",
"downloadSpeed": "25",
},
{
"gid": "gid-1",
"status": "complete",
"completedLength": "10",
"totalLength": "10",
"downloadSpeed": "0",
"files": [{"path": str(save_path)}],
},
]
)
async def fake_rpc_call(method, params):
rpc_calls.append((method, params))
if method == "aria2.tellStatus":
return next(statuses)
raise AssertionError(f"Unexpected RPC method: {method}")
monkeypatch.setattr(downloader, "_ensure_process", AsyncMock())
monkeypatch.setattr(downloader, "_rpc_call", fake_rpc_call)
monkeypatch.setattr("py.services.aria2_downloader.asyncio.sleep", AsyncMock())
success, result = await downloader.download_file(
"https://example.com/model.safetensors",
str(save_path),
download_id="download-1",
)
assert success is True
assert result == str(save_path)
assert [call[0] for call in rpc_calls] == ["aria2.tellStatus", "aria2.tellStatus"]
def test_build_progress_snapshot_normalizes_numeric_fields():
downloader = Aria2Downloader()
snapshot = downloader._build_progress_snapshot(
{
"completedLength": "75",
"totalLength": "100",
"downloadSpeed": "512",
}
)
assert snapshot.percent_complete == 75.0
assert snapshot.bytes_downloaded == 75
assert snapshot.total_bytes == 100
assert snapshot.bytes_per_second == 512.0
def test_resolve_executable_raises_when_binary_missing(monkeypatch):
downloader = Aria2Downloader()
settings = type("Settings", (), {"get": lambda self, key, default=None: ""})()
monkeypatch.setattr("py.services.aria2_downloader.get_settings_manager", lambda: settings)
monkeypatch.setattr("py.services.aria2_downloader.shutil.which", lambda _: None)
with pytest.raises(Aria2Error):
downloader._resolve_executable()
@pytest.mark.asyncio
async def test_rpc_call_surfaces_json_error_on_non_200(monkeypatch):
downloader = Aria2Downloader()
downloader._rpc_url = "http://127.0.0.1:6800/jsonrpc"
downloader._rpc_secret = "secret"
class FakeResponse:
status = 400
async def text(self):
return (
'{"jsonrpc":"2.0","id":"x","error":{"code":1,"message":"Unauthorized"}}'
)
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class FakeSession:
def post(self, _url, json=None):
return FakeResponse()
monkeypatch.setattr(downloader, "_get_rpc_session", AsyncMock(return_value=FakeSession()))
with pytest.raises(Aria2Error) as exc_info:
await downloader._rpc_call("aria2.addUri", [["https://example.com/file"]])
assert "Unauthorized" in str(exc_info.value)
assert "aria2.addUri" in str(exc_info.value)
@pytest.mark.asyncio
async def test_resolve_authenticated_redirect_url_returns_location(monkeypatch):
downloader = Aria2Downloader()
class FakeResponse:
status = 307
headers = {"Location": "https://signed.example.com/file.safetensors"}
async def text(self):
return ""
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class FakeSession:
def get(self, _url, headers=None, allow_redirects=False, proxy=None):
return FakeResponse()
class FakeDownloader:
default_headers = {"User-Agent": "ComfyUI-LoRA-Manager/1.0"}
proxy_url = None
@property
def session(self):
async def _session():
return FakeSession()
return _session()
fake_downloader = FakeDownloader()
monkeypatch.setattr(
"py.services.aria2_downloader.get_downloader",
AsyncMock(return_value=fake_downloader),
)
result = await downloader._resolve_authenticated_redirect_url(
"https://civitai.com/api/download/models/123",
{"Authorization": "Bearer token"},
)
assert result == "https://signed.example.com/file.safetensors"

View File

@@ -886,3 +886,111 @@ async def test_format_response_defaults_update_flag_false(service_cls, extra_fie
assert "update_available" in formatted
assert formatted["update_available"] is False
@pytest.mark.asyncio
async def test_get_model_civitai_url_uses_default_host():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}
@pytest.mark.asyncio
async def test_get_model_civitai_url_uses_configured_host():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({"civitai_host": "civitai.red"}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.red/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}
@pytest.mark.asyncio
async def test_get_model_civitai_url_falls_back_when_host_setting_is_not_a_string():
raw_data = [
{
"file_name": "demo.safetensors",
"civitai": {"modelId": 123, "id": 456},
}
]
class CacheStub:
def __init__(self, raw_data):
self.raw_data = raw_data
class ScannerStub:
def __init__(self, cache):
self._cache = cache
async def get_cached_data(self, *_, **__):
return self._cache
service = DummyService(
model_type="stub",
scanner=ScannerStub(CacheStub(raw_data)),
metadata_class=BaseModelMetadata,
settings_provider=StubSettings({"civitai_host": True}),
)
result = await service.get_model_civitai_url("demo.safetensors")
assert result == {
"civitai_url": "https://civitai.com/models/123?modelVersionId=456",
"model_id": "123",
"version_id": "456",
}

View File

@@ -581,6 +581,7 @@ class TestInputValidation:
assert service._validate_url("https://example.com/image.png") is True
assert service._validate_url("http://example.com/image.png") is True
assert service._validate_url("https://civitai.com/images/123") is True
assert service._validate_url("https://civitai.red/images/123") is True
def test_validate_invalid_url(self, service):
assert service._validate_url("not-a-url") is False

View File

@@ -1,5 +1,6 @@
"""Tests for checkpoint lazy hash calculation feature."""
import asyncio
import json
import os
from pathlib import Path
@@ -199,6 +200,160 @@ async def test_calculate_hash_skips_if_already_completed(tmp_path: Path, monkeyp
mock_calc.assert_not_called(), "Should not recalculate if already completed"
@pytest.mark.asyncio
async def test_calculate_hash_for_model_singleflight_same_file(
tmp_path: Path, monkeypatch
):
"""Concurrent calls for the same checkpoint should share one SHA256 task."""
checkpoints_root = tmp_path / "checkpoints"
checkpoints_root.mkdir()
checkpoint_file = checkpoints_root / "test_model.safetensors"
checkpoint_file.write_text("fake content", encoding="utf-8")
normalized_root = _normalize(checkpoints_root)
normalized_file = _normalize(checkpoint_file)
real_file = os.path.realpath(normalized_file)
monkeypatch.setattr(
model_scanner.config,
"base_models_roots",
[normalized_root],
raising=False,
)
scanner = CheckpointScanner()
metadata = await scanner._create_default_metadata(normalized_file)
assert metadata is not None
calls = []
async def fake_calculate_sha256(file_path: str) -> str:
calls.append(file_path)
await asyncio.sleep(0.01)
return "a" * 64
with patch(
"py.utils.file_utils.calculate_sha256", side_effect=fake_calculate_sha256
):
results = await asyncio.gather(
*[scanner.calculate_hash_for_model(normalized_file) for _ in range(8)]
)
assert calls == [real_file]
assert results == ["a" * 64] * 8
assert scanner._hash_calculation_tasks == {}
@pytest.mark.asyncio
async def test_calculate_hash_for_model_cleans_task_after_failure_and_retries(
tmp_path: Path, monkeypatch
):
"""A failed in-flight task should be removed so later calls can retry."""
checkpoints_root = tmp_path / "checkpoints"
checkpoints_root.mkdir()
checkpoint_file = checkpoints_root / "retry_model.safetensors"
checkpoint_file.write_text("fake content", encoding="utf-8")
normalized_root = _normalize(checkpoints_root)
normalized_file = _normalize(checkpoint_file)
monkeypatch.setattr(
model_scanner.config,
"base_models_roots",
[normalized_root],
raising=False,
)
scanner = CheckpointScanner()
metadata = await scanner._create_default_metadata(normalized_file)
assert metadata is not None
attempts = 0
async def fake_calculate_sha256(_file_path: str) -> str:
nonlocal attempts
attempts += 1
if attempts == 1:
raise RuntimeError("hash failed")
return "b" * 64
with patch(
"py.utils.file_utils.calculate_sha256", side_effect=fake_calculate_sha256
):
assert await scanner.calculate_hash_for_model(normalized_file) is None
assert scanner._hash_calculation_tasks == {}
hash_result = await scanner.calculate_hash_for_model(normalized_file)
assert hash_result == "b" * 64
assert attempts == 2
assert scanner._hash_calculation_tasks == {}
@pytest.mark.asyncio
async def test_calculate_hash_for_model_uses_separate_tasks_for_different_files(
tmp_path: Path, monkeypatch
):
"""Different checkpoint files should not share the same in-flight task."""
checkpoints_root = tmp_path / "checkpoints"
checkpoints_root.mkdir()
checkpoint_files = [
checkpoints_root / "model_a.safetensors",
checkpoints_root / "model_b.safetensors",
]
for checkpoint_file in checkpoint_files:
checkpoint_file.write_text(
f"fake content for {checkpoint_file.name}", encoding="utf-8"
)
normalized_root = _normalize(checkpoints_root)
normalized_files = [
_normalize(checkpoint_file) for checkpoint_file in checkpoint_files
]
real_files = {os.path.realpath(file_path) for file_path in normalized_files}
monkeypatch.setattr(
model_scanner.config,
"base_models_roots",
[normalized_root],
raising=False,
)
scanner = CheckpointScanner()
for normalized_file in normalized_files:
metadata = await scanner._create_default_metadata(normalized_file)
assert metadata is not None
calls = []
hashes_by_path = {
os.path.realpath(normalized_files[0]): "c" * 64,
os.path.realpath(normalized_files[1]): "d" * 64,
}
async def fake_calculate_sha256(file_path: str) -> str:
calls.append(file_path)
await asyncio.sleep(0.01)
return hashes_by_path[file_path]
with patch(
"py.utils.file_utils.calculate_sha256", side_effect=fake_calculate_sha256
):
results = await asyncio.gather(
*[
scanner.calculate_hash_for_model(file_path)
for file_path in normalized_files
]
)
assert set(calls) == real_files
assert len(calls) == 2
assert set(results) == {"c" * 64, "d" * 64}
assert scanner._hash_calculation_tasks == {}
@pytest.mark.asyncio
async def test_calculate_hash_for_model_bootstraps_missing_metadata(tmp_path: Path, monkeypatch):
"""Test that calculate_hash_for_model creates pending metadata when it is missing."""

View File

@@ -5,6 +5,7 @@ import pytest
from py.services import civitai_client as civitai_client_module
from py.services.civitai_client import CivitaiClient
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
from py.services.errors import RateLimitError, ResourceNotFoundError
from py.services.model_metadata_provider import ModelMetadataProviderManager
@@ -62,6 +63,12 @@ async def test_download_file_uses_downloader(tmp_path, downloader):
assert downloader.download_calls[0]["use_auth"] is True
async def test_client_defaults_to_red_api_host(downloader):
client = await CivitaiClient.get_instance()
assert client.base_url == "https://civitai.red/api/v1"
async def test_get_model_by_hash_enriches_metadata(monkeypatch, downloader):
version_payload = {
"modelId": 123,
@@ -109,6 +116,20 @@ async def test_get_model_by_hash_handles_not_found(monkeypatch, downloader):
assert error == "Model not found"
async def test_get_model_by_hash_handles_offline_cooldown(downloader):
async def fake_make_request(method, url, use_auth=True, **kwargs):
return False, OFFLINE_COOLDOWN_ERROR
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result, error = await client.get_model_by_hash("missing")
assert result is None
assert error == OFFLINE_FRIENDLY_MESSAGE
async def test_get_model_by_hash_propagates_rate_limit(monkeypatch, downloader):
async def fake_make_request(method, url, use_auth=True, **kwargs):
return False, RateLimitError("limited", retry_after=4)
@@ -530,6 +551,69 @@ async def test_get_image_info_handles_missing(monkeypatch, downloader):
assert result is None
async def test_get_image_info_prefers_red_host_for_red_source(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return True, {"items": [{"id": 124950237, "name": "target"}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X"
]
async def test_get_image_info_uses_red_host_even_for_red_source(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return True, {"items": [{"id": 124950237, "name": "target"}]}
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result == {"id": 124950237, "name": "target"}
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
]
async def test_get_image_info_does_not_fall_back_after_request_failure(monkeypatch, downloader):
requested_urls = []
async def fake_make_request(method, url, use_auth=True, **kwargs):
requested_urls.append(url)
return False, "403 forbidden"
downloader.make_request = fake_make_request
client = await CivitaiClient.get_instance()
result = await client.get_image_info(
"124950237", source_url="https://civitai.red/images/124950237"
)
assert result is None
assert requested_urls == [
"https://civitai.red/api/v1/images?imageId=124950237&nsfw=X",
]
async def test_get_image_info_handles_invalid_id(monkeypatch, downloader, caplog):
"""When given a non-numeric image ID, return None and log error."""
client = await CivitaiClient.get_instance()

Some files were not shown because too many files have changed in this diff Show More