Compare commits

...

56 Commits

Author SHA1 Message Date
Will Miao
908464bc0a docs: remove inline release notes from README (now maintained via GitHub Releases) 2026-05-06 22:40:06 +08:00
willmiao
0ffee3a854 docs: auto-update supporters list in README 2026-05-06 10:29:43 +00:00
Will Miao
8aa9739c44 data: refresh supporters from license server (739 supporters, includes Patreon data) 2026-05-06 18:29:21 +08:00
Will Miao
50739bbb43 fix(css): remove dead CSS properties causing Biome errors
- batch-import-modal.css: add generic font family fallback to Font Awesome
- card.css: remove dead margin-left overridden by shorthand margin: 0
- shared.css: remove duplicate position: absolute overridden by position: fixed
2026-05-06 09:33:15 +08:00
Will Miao
e849303763 fix(header): eliminate search input focus layout shift and reduce focus ring size
- Remove transform: translateY(-1px) that caused layout shift on focus
- Reduce box-shadow focus ring from 2px to 1px for subtler appearance
- Tone down drop-shadow from 4px/16px to 2px/8px (matches base state)
2026-05-06 09:33:04 +08:00
Will Miao
241b2e15d2 docs: update extension image URL 2026-05-05 22:26:40 +08:00
Will Miao
88da754504 docs: migrate wiki-images to wiki repo, remove stale docs
Moved wiki-images to the wiki repo (willmiao/ComfyUI-Lora-Manager.wiki). Updated README.md image reference to use wiki raw URL. Removed docs/LM-Extension-Wiki.md (superseded by wiki pages).
2026-05-05 22:20:19 +08:00
Will Miao
b4a706651f feat(delete-model-version): add GET endpoint to delete a model version by version ID 2026-05-05 21:25:08 +08:00
pixelpaws
ff7cc6d9bb Merge pull request #921 from 1756141021/fix/drag-strength-notify-setValue
fix: commit dragged strength through options.setValue at drag end
2026-05-05 16:20:48 +08:00
hein
454210a47c fix: commit dragged strength through options.setValue at drag end
During drag, handleStrengthDrag is called with updateWidget=false, which
mutates widgetValue in-place via parseLoraValue's direct array reference,
bypassing widget.value setter and options.setValue entirely.

endDrag only called renderFunction for a DOM refresh, but never flushed the
mutation through options.setValue. Any external observer that wraps
options.setValue (e.g. ComfyUI Mirror Panel's bidirectional sync) would
therefore never see the dragged value and would treat the widget as unchanged.

Fix: replace the explicit renderFunction call with widget.value = widget.value.
This flushes the in-place mutation through the setter (options.setValue), which
re-renders the DOM internally AND notifies all setValue wrappers. Also fire
widget.callback for parity with the updateWidget=true path in handleStrengthDrag.

Applies the same fix to initHeaderDrag (proportional all-LoRA header drag).
2026-05-04 22:40:30 +08:00
Will Miao
2d7c404ebb fix(recipes): preserve scroll position on filter, search, and folder-driven reloads
Five entry points that trigger recipe page reloads were not passing
preserveScroll: true, causing the page to snap back to top after
filtering, searching, or navigating folders — especially painful with
hundreds of recipes.

- RecipePageControls.resetAndReload() → refreshVirtualScroll() now
  passes { preserveScroll: true } (sidebar folder clicks/drag moves)
- FilterManager applyFilters/clearAllFilters → loadRecipes(true)
  changed to loadRecipes({ preserveScroll: true })
- SearchManager performSearch → loadRecipes(true) changed to
  loadRecipes({ preserveScroll: true })
- SettingsManager reloadContent → loadRecipes() changed to
  loadRecipes({ preserveScroll: true })

The normalizeLoadRecipesOptions boolean path always forces
preserveScroll: false — the object form is required to pass it.
2026-05-04 20:26:13 +08:00
Will Miao
e23d803ecf fix(layout): ensure refresh split-button dropdown renders above breadcrumb nav 2026-05-03 18:14:54 +08:00
Will Miao
0cc640cfaa fix(recipe): support ComfyUI-Easy-Use nodes in runtime metadata extraction (#920)
- Add EasyComfyLoaderExtractor for comfyLoader (easy comfyLoader):
  extracts checkpoint, optional_lora_stack as LoRA apply node,
  prompt text, clip_skip, and latent dimensions
- Add EasyPreSamplingExtractor for samplerSettings (easy preSampling):
  extracts steps, cfg, sampler_name, scheduler, denoise, seed
- Add EasySeedExtractor for easySeed
- Fix clip_skip hardcoded to '1' — now searched from SAMPLING metadata
- Lora Stacker nodes intentionally excluded from extraction to
  prevent double-counting; LoRAs only recorded at apply nodes
2026-05-02 23:21:51 +08:00
Will Miao
2ac0eb0f9d fix(wanvideo): resolve lora path resolution and name truncation for extra folder paths
- Use get_lora_info_absolute to obtain correct absolute paths for loras
  in LM extra folder paths, instead of folder_paths.get_full_path which
  only searches ComfyUI's standard loras directories (returned None)
- Fix name field truncation: str.split('.')[0] stopped at the first dot,
  replaced with os.path.splitext to only strip the file extension
- Add _relpath_within_loras helper to preserve subdirectory info in the
  name field, matching WanVideoWrapper's os.path.splitext(lora)[0] format
2026-05-02 14:55:12 +08:00
Will Miao
f028625ce9 feat(check-models-exist): add batch endpoint for checking multiple model IDs
New endpoint: GET /api/lm/check-models-exist?modelIds=1,2,3,...

Accepts comma-separated modelIds, returns a results array with one
entry per modelId. Uses a single scanner lookup batch - three
service-registry calls total, regardless of model count. Skips
history checks entirely (same rationale as the singleton endpoint:
when models exist locally, history is redundant).

Expected: reduces 231 HTTP round-trips to 1 for the browser
extension's model-card indicator flow. Combined with the prior
SQLite-connection and history-skip fixes, total wall-clock time
for a 175K-lora user's page load drops from ~9.4s to <10ms.
2026-05-02 13:43:53 +08:00
Will Miao
06acc7f576 fix(trigger-word-toggle): default group children to active regardless of default_active 2026-05-02 13:33:42 +08:00
Will Miao
d324b57274 perf(check-model-exists): eliminate SQLite connection-per-query overhead and skip redundant history checks
Root cause: 231 concurrent /check-model-exists requests on 175K-lora library
caused ~9.4s wall clock time. The bottleneck was two-fold:

1. DownloadedVersionHistoryService opened a new sqlite3.connect() for every
   query under asyncio.Lock. With a large WAL from 175K entries, each
   connect() took ~8ms. Serialized by the lock across 231 requests, the
   230th request waited ~1848ms just for lock acquisition.

2. check_model_exists always queried download history even when the model
   was found locally. The history result (hasBeenDownloaded /
   downloadedVersionIds) is only used by the UI when the model is NOT
   found locally; when found, the 'in library' indicator takes priority.

Changes:
- downloaded_version_history_service.py: added persistent _get_conn() that
  creates the SQLite connection once and reuses it across all queries
- misc_handlers.py: early-return from check_model_exists when the model
  exists locally, bypassing the history service entirely (lock skipped)

Expected: per-request wait time drops from ~1912ms to <3ms, wall clock
from ~9.4s to <0.3s for the 175K-lora user's 231-card page.
2026-05-02 13:31:20 +08:00
Will Miao
502b7eab31 fix(layout): correct breadcrumb sticky behavior and controls wrapping overflow
- Extract breadcrumb from controls template into sibling component
- Fix breadcrumb sticky positioning (top: 0, z-index: calc(--z-header - 1))
- Add 1500px breakpoint to wrap controls-right and prevent overflow
- Adjust breadcrumb padding-bottom to cover controls-right area when sticky
2026-05-01 22:53:40 +08:00
Will Miao
be75ad930e feat(layout): implement responsive edge-to-edge card grid with density-aware column calculation
- Add dynamic column calculation based on container width and min card width
- Prevent tiny cards on narrow windows by respecting density-based minimums:
  - Default: 240px, Medium: 200px, Compact: 170px
- Fix edge-to-edge layout with proper CSS selector (.virtual-scroll-item.model-card)
- Add hamburger menu for mobile/small screens with proper translations
- Update all locale files with 'common.actions.menu' key

Fixes: Cards becoming too small/overlapping on narrow window widths (e.g., 1156px)
Changes: 15 files, +569/-114 lines
2026-05-01 21:34:31 +08:00
Will Miao
763c4f4dad feat(usage-control): add support for Civitai usageControl field
Handle models that are only available for on-site generation (usageControl:
"Generation" or "InternalGeneration") rather than downloadable.

Backend changes:
- Add usage_control field to ModelVersionRecord dataclass
- Extract usageControl from Civitai API responses
- Filter non-downloadable versions from update availability checks
- Add database schema migration for usage_control column
- Include usageControl in version response JSON

Frontend changes:
- Add isDownloadAllowed() helper function
- Show disabled download button for non-downloadable versions
- Add "On-Site Only" badge for restricted versions
- Update resolveUpdateAvailability() to filter non-downloadable versions
- Add CSS styling for disabled action button

Internationalization:
- Add translations for onSiteOnly badge and downloadNotAllowedTooltip
- Complete translations for all 10 supported languages
2026-05-01 13:10:15 +08:00
Will Miao
d32c492bdb feat(scripts): add legacy metadata migration tool
Add script to migrate metadata from legacy sidecar JSON files to
LoRA Manager's metadata.json format.

Features:
- Auto-discovers model folders from settings.json
- Supports LoRA and Checkpoint model types
- Migrates activation text, preferred weight (LoRA only), and notes
- Dry-run mode for safe preview
- Idempotent migration (won't duplicate existing data)
2026-05-01 08:56:00 +08:00
Will Miao
5dcfde36ea feat(doctor): add duplicate filename conflict detection and one-click resolution
Detects when multiple model files share the same basename (causing
ambiguity in LoRA resolution), logs warnings during scanning, and
provides a "Resolve Conflicts" button in the Doctor panel. Resolution
renames duplicates with hash-prefixed unique filenames, migrates all
sidecar and preview files, and updates the cache and frontend scroller
in-place so the model modal immediately reflects the new filename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:21:26 +08:00
Will Miao
1d035361a4 fix(download): accept Diffusion Model file type when selecting primary file from CivitAI metadata
CivitAI returns file type "Diffusion Model" for checkpoint files (e.g., Anima
models), but the file selection logic only accepted "Model" and "Negative",
causing "No suitable file found in metadata" errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 11:54:14 +08:00
Will Miao
25605c5e78 feat(ui): add setting to toggle version name display on model cards (#916) 2026-04-29 20:04:40 +08:00
Will Miao
f3268a6179 fix(autocomplete): prevent migrateWidgetsValues from dropping text widget values (#915)
shouldBypassAutocompleteWidgetMigration only matched inputs by widget name,
but ComfyUI's migrateWidgetsValues also matches forceInput inputs (like "seed").
This discrepancy meant the bypass never triggered for TextLM/PromptLM nodes,
causing migrateWidgetsValues to filter out real widget values by incorrectly
mapping forceInput flags onto saved autocomplete values.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:44:08 +08:00
Will Miao
055e94d77b fix(updates): chunk bulk queries to avoid SQLite variable limit (#914)
_split _get_records_bulk into 500-id batches so the WHERE IN clause
never exceeds SQLite's 999-parameter ceiling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:15:44 +08:00
Will Miao
47fcd530a0 feat(settings): add aria2 wiki help link to download backend setting 2026-04-28 18:37:59 +08:00
Will Miao
3c32b9e088 feat(example-images): add wiki help link and i18n keys for remote open mode
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:45:16 +08:00
Will Miao
ffe0670a27 feat(example-images): add remote open mode support 2026-04-27 14:05:21 +08:00
Will Miao
cc147a1795 fix(metadata): preserve workflow when recipe images convert to webp 2026-04-25 07:50:51 +08:00
Will Miao
e81409bea4 fix(i18n): shorten bulk delete labels 2026-04-25 07:21:42 +08:00
Will Miao
b31fae4e51 fix(widgets): isolate autocomplete text cleanup 2026-04-23 20:07:11 +08:00
Will Miao
c6e5467907 fix(metadata): add MyOriginalWaifu prompt extractors 2026-04-23 16:05:40 +08:00
Will Miao
df0e5797d0 fix(nodes): save recipes synchronously from save image 2026-04-23 15:46:57 +08:00
Will Miao
ebdbb36271 fix(metadata): trace conditioning provenance for prompts 2026-04-23 14:41:54 +08:00
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
121 changed files with 9052 additions and 1773 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())

136
README.md

File diff suppressed because one or more lines are too long

View File

@@ -12,33 +12,39 @@
"2018cfh", "2018cfh",
"W+K+White", "W+K+White",
"wackop", "wackop",
"Takkan", "Phil",
"Carl G.", "Carl G.",
"Arlecchino Shion",
"stone9k",
"$MetaSamsara", "$MetaSamsara",
"itismyelement", "itismyelement",
"Gingko Biloba",
"onesecondinosaur", "onesecondinosaur",
"stone9k", "Takkan",
"Charles Blakemore",
"Rob Williams",
"Rosenthal", "Rosenthal",
"Francisco Tatis", "Francisco Tatis",
"Tobi_Swagg",
"Andrew Wilson", "Andrew Wilson",
"Greybush", "Greybush",
"Gooohokrbe",
"Ricky Carter", "Ricky Carter",
"JongWon Han", "JongWon Han",
"OldBones",
"VantAI", "VantAI",
"runte3221", "runte3221",
"Illrigger",
"FreelancerZ", "FreelancerZ",
"Edgar Tejeda", "Edgar Tejeda",
"Jorge Hussni",
"Liam MacDougal", "Liam MacDougal",
"Fraser Cross", "Fraser Cross",
"Polymorphic Indeterminate", "Polymorphic Indeterminate",
"Birdy",
"Marc Whiffen", "Marc Whiffen",
"Jorge Hussni", "Birdy",
"Kiba",
"Skalabananen", "Skalabananen",
"Kiba",
"Reno Lam", "Reno Lam",
"Mozzel",
"sig", "sig",
"Christian Byrne", "Christian Byrne",
"DM", "DM",
@@ -46,39 +52,41 @@
"Estragon", "Estragon",
"J\\B/ 8r0wns0n", "J\\B/ 8r0wns0n",
"Snaggwort", "Snaggwort",
"Arlecchino Shion",
"Charles Blakemore",
"Rob Williams",
"ClockDaemon", "ClockDaemon",
"Jonathan Ross",
"KD", "KD",
"Omnidex", "Omnidex",
"Nazono_hito",
"Tyler Trebuchon", "Tyler Trebuchon",
"Release Cabrakan", "Release Cabrakan",
"Tobi_Swagg", "contrite831",
"SG", "SG",
"carozzz", "carozzz",
"James Dooley", "James Dooley",
"zenbound", "zenbound",
"Buzzard", "Buzzard",
"jmack", "jmack",
"Adam Shaw",
"Mark Corneglio", "Mark Corneglio",
"SarcasticHashtag", "SarcasticHashtag",
"Cosmosis", "Anthony Rizzo",
"iamresist", "iamresist",
"Gooohokrbe",
"RedrockVP", "RedrockVP",
"Wolffen", "Wolffen",
"FloPro4Sho",
"James Todd", "James Todd",
"OldBones",
"Steven Pfeiffer", "Steven Pfeiffer",
"Tim", "Tim",
"Timmy",
"Johnny",
"Lisster", "Lisster",
"Michael Wong", "Michael Wong",
"Illrigger", "whudunit",
"Tom Corrigan", "Tom Corrigan",
"dl0901dm",
"JackieWang", "JackieWang",
"fnkylove", "fnkylove",
"Julian V",
"Steven Owens",
"Yushio", "Yushio",
"Vik71it", "Vik71it",
"Echo", "Echo",
@@ -86,147 +94,137 @@
"Robert Stacey", "Robert Stacey",
"PM", "PM",
"Todd Keck", "Todd Keck",
"Mozzel", "Briton Heilbrun",
"Gingko Biloba", "Aleksander Wujczyk",
"Sterilized",
"BadassArabianMofo", "BadassArabianMofo",
"Sterilized",
"Pascal Dahle", "Pascal Dahle",
"quarz", "quarz",
"Greg",
"Penfore", "Penfore",
"Greg",
"JSST", "JSST",
"esthe",
"lmsupporter", "lmsupporter",
"IamAyam", "zounic",
"wfpearl", "wfpearl",
"Baekdoosixt", "Baekdoosixt",
"Jonathan Ross",
"Jack B Nimble", "Jack B Nimble",
"Nazono_hito",
"Melville Parrish", "Melville Parrish",
"daniel dove", "daniel dove",
"Lustre", "Lustre",
"JW Sin", "JW Sin",
"contrite831",
"Alex", "Alex",
"bh", "bh",
"confiscated Zyra",
"Marlon Daniels", "Marlon Daniels",
"Starkselle", "Starkselle",
"Aaron Bleuer", "Aaron Bleuer",
"LacesOut!", "LacesOut!",
"greebles", "greebles",
"Adam Shaw", "Cosmosis",
"Tee Gee",
"Anthony Rizzo",
"tarek helmi",
"M Postkasse", "M Postkasse",
"FloPro4Sho",
"ASLPro3D", "ASLPro3D",
"Jacob Hoehler", "Jacob Hoehler",
"FinalyFree", "FinalyFree",
"Weasyl", "Weasyl",
"Timmy", "Lex Song",
"Johnny",
"Cory Paza", "Cory Paza",
"Tak", "Tak",
"Gonzalo Andre Allendes Lopez", "Gonzalo Andre Allendes Lopez",
"Zach Gonser", "Zach Gonser",
"Big Red", "Big Red",
"whudunit", "Jimmy Ledbetter",
"Luc Job", "Luc Job",
"dl0901dm",
"Philip Hempel", "Philip Hempel",
"corde", "corde",
"Nick Walker", "Nick Walker",
"lh qwe", "Julian V",
"Steven Owens",
"Bishoujoker", "Bishoujoker",
"conner",
"aai", "aai",
"Briton Heilbrun",
"Tori", "Tori",
"wildnut", "wildnut",
"Princess Bright Eyes",
"AbstractAss",
"Felipe dos Santos",
"ViperC",
"jean jahren", "jean jahren",
"Aleksander Wujczyk",
"AM Kuro", "AM Kuro",
"Markus", "ViperC",
"S Sang", "Ran C",
"Sangheili460",
"MagnaInsomnia",
"Karl P.", "Karl P.",
"Akira_HentAI", "Akira_HentAI",
"MagnaInsomnia",
"Gordon Cole", "Gordon Cole",
"yuxz69", "yuxz69",
"Douglas Gaspar", "esthe",
"AlexDuKaNa",
"George",
"andrew.tappan", "andrew.tappan",
"dw",
"N/A", "N/A",
"The Spawn", "The Spawn",
"Phil",
"graysock", "graysock",
"Pozadine1",
"Greenmoustache", "Greenmoustache",
"zounic",
"fancypants", "fancypants",
"IamAyam",
"Eldithor",
"Joboshy",
"Digital", "Digital",
"JaxMax", "JaxMax",
"takyamtom", "takyamtom",
"奚明 刘", "Bohemian Corporal",
"Dan",
"confiscated Zyra",
"Jwk0205", "Jwk0205",
"Bro Xie", "Bro Xie",
"준희 김", "yer fey",
"batblue", "batblue",
"carey6409", "carey6409",
"Olive", "Olive",
"太郎 ゲーム", "太郎 ゲーム",
"Tee Gee",
"Some Guy Named Barry", "Some Guy Named Barry",
"jinxedx",
"tarek helmi",
"Max Marklund", "Max Marklund",
"Tomohiro Baba",
"David Ortega",
"AELOX", "AELOX",
"Dankin",
"Nicfit23", "Nicfit23",
"Noora",
"wamekukyouzin", "wamekukyouzin",
"drum matthieu", "drum matthieu",
"Dogmaster", "Dogmaster",
"Matt Wenzel", "Matt Wenzel",
"Mattssn", "Frank Nitty",
"Lex Song", "Pronredn",
"John Saveas",
"Christopher Michel", "Christopher Michel",
"Serge Bekenkamp", "Serge Bekenkamp",
"Jimmy Ledbetter", "DougPeterson",
"LeoZero", "LeoZero",
"Antonio Pontes", "Antonio Pontes",
"ApathyJones", "ApathyJones",
"nahinahi9", "nahinahi9",
"lh qwe",
"Kevin John Duck",
"conner",
"Dustin Chen", "Dustin Chen",
"dan", "dan",
"Yaboi", "Blackfish95",
"Mouthlessman", "Mouthlessman",
"Steam Steam", "Princess Bright Eyes",
"Damon Cunliffe", "Paul Kroll",
"CryptoTraderJK", "AbstractAss",
"Davaitamin",
"otaku fra", "otaku fra",
"Ran C", "Felipe dos Santos",
"tedcor", "Bas Imagineer",
"Fotek Design", "Markus",
"MiraiKuriyamaSy",
"Adam Taylor", "Adam Taylor",
"Douglas Gaspar",
"Weird_With_A_Beard", "Weird_With_A_Beard",
"MadSpin", "AlexDuKaNa",
"Pozadine1", "George",
"dw",
"Qarob", "Qarob",
"AIGooner", "AIGooner",
"inbijiburu",
"Luc", "Luc",
"ProtonPrince", "ProtonPrince",
"DiffDuck", "DiffDuck",
"elu3199", "elu3199",
"Nick “Loadstone” D",
"Hasturkun", "Hasturkun",
"Jon Sandman", "Jon Sandman",
"Ubivis", "Ubivis",
@@ -234,54 +232,45 @@
"thesoftwaredruid", "thesoftwaredruid",
"wundershark", "wundershark",
"mr_dinosaur", "mr_dinosaur",
"Tyrswood",
"linnfrey", "linnfrey",
"Gamalonia",
"Vir",
"Pkrsky", "Pkrsky",
"Joboshy", "奚明 刘",
"Bohemian Corporal",
"Dan",
"Josef Lanzl", "Josef Lanzl",
"Seth Christensen", "Nerezza",
"Griffin Dahlberg", "Griffin Dahlberg",
"Draven T", "준희 김",
"yer fey",
"Error_Rule34_Not_found", "Error_Rule34_Not_found",
"Gerald Welly", "Gerald Welly",
"Roslynd", "Roslynd",
"Geolog", "Geolog",
"jinxedx",
"Neco28", "Neco28",
"Aquatic Coffee", "Tomohiro Baba",
"Dankin", "David Ortega",
"ethanfel", "Noora",
"Cristian Vazquez", "Cristian Vazquez",
"Frank Nitty", "Mattssn",
"Magic Noob", "Magic Noob",
"Focuschannel",
"DougPeterson",
"Jeff", "Jeff",
"Bruce", "Bruce",
"Kevin John Duck",
"Anthony Faxlandez",
"Kevin Christopher", "Kevin Christopher",
"Ouro Boros", "Ouro Boros",
"Blackfish95", "Chad Idk",
"Yaboi",
"dd", "dd",
"Paul Kroll", "Steam Steam",
"MiraiKuriyamaSy", "CryptoTraderJK",
"semicolon drainpipe", "Davaitamin",
"Thesharingbrother", "Dušan Ryban",
"Bas Imagineer", "tedcor",
"Pat Hen", "Fotek Design",
"sjon kreutz",
"John Statham", "John Statham",
"ResidentDeviant", "MadSpin",
"Nihongasuki", "Metryman55",
"JC", "inbijiburu",
"Prompt Pirate",
"uwutismxd",
"decoy", "decoy",
"Tyrswood", "Nick “Loadstone” D",
"Ray Wing", "Ray Wing",
"Ranzitho", "Ranzitho",
"Gus", "Gus",
@@ -290,6 +279,7 @@
"David LaVallee", "David LaVallee",
"ae", "ae",
"Tr4shP4nda", "Tr4shP4nda",
"Gamalonia",
"WRL_SPR", "WRL_SPR",
"capn", "capn",
"Joseph", "Joseph",
@@ -302,77 +292,60 @@
"Moon Knight", "Moon Knight",
"몽타주", "몽타주",
"Kland", "Kland",
"zenobeus", "Hailshem",
"Jackthemind", "kudari",
"ryoma", "Naomi Hale Danchi",
"Stryker", "dc7431",
"raf8osz", "Vir",
"ElitaSSJ4",
"blikkies",
"Chris",
"Brian M", "Brian M",
"Nerezza",
"sanborondon", "sanborondon",
"Seth Christensen",
"Draven T",
"Taylor Funk", "Taylor Funk",
"aezin", "aezin",
"Thought2Form", "Thought2Form",
"jcay015", "jcay015",
"Kevin Picco", "Kevin Picco",
"Erik Lopez", "Erik Lopez",
"Shock Shockor",
"Mateo Curić", "Mateo Curić",
"Goldwaters", "Aquatic Coffee",
"Zude",
"Eris3D", "Eris3D",
"m", "m",
"ethanfel",
"Pierce McBride", "Pierce McBride",
"Joshua Gray", "Joshua Gray",
"Kyler", "Focuschannel",
"Mikko Hemilä", "Mikko Hemilä",
"aRtFuL_DodGeR",
"Jamie Ogletree", "Jamie Ogletree",
"a _", "a _",
"James Coleman", "James Coleman",
"CrimsonDX",
"Martial", "Martial",
"Anthony Faxlandez",
"battu", "battu",
"Emil Andersson", "Emil Andersson",
"Chad Idk",
"DarkSunset",
"Billy Gladky",
"Yuji Kaneko", "Yuji Kaneko",
"Probis", "Pat Hen",
"Dušan Ryban", "semicolon drainpipe",
"ItsGeneralButtNaked",
"Jordan Shaw", "Jordan Shaw",
"Rops Alot", "Rops Alot",
"Thesharingbrother",
"Sam", "Sam",
"sjon kreutz",
"Nimess",
"SRDB",
"Ace Ventura", "Ace Ventura",
"g unit", "ResidentDeviant",
"Youguang", "Nihongasuki",
"Metryman55", "JC",
"andrewzpong", "Prompt Pirate",
"FrxzenSnxw", "uwutismxd",
"BossGame",
"lrdchs",
"momokai", "momokai",
"Hailshem", "zenobeus",
"kudari",
"Naomi Hale Danchi",
"dc7431",
"ken", "ken",
"Inversity",
"AIVORY3D",
"epicgamer0020690", "epicgamer0020690",
"Joshua Porrata", "Joshua Porrata",
"keemun", "keemun",
"SuBu", "SuBu",
"RedPIXel", "RedPIXel",
"Kevinj",
"Wind", "Wind",
"Jackthemind",
"Nexus", "Nexus",
"Ramneek“Guy”Ashok", "Ramneek“Guy”Ashok",
"squid_actually", "squid_actually",
@@ -385,80 +358,81 @@
"emyth", "emyth",
"chriphost", "chriphost",
"KitKatM", "KitKatM",
"ryoma",
"socrasteeze", "socrasteeze",
"ResidentDeviant", "OrganicArtifact",
"Stryker",
"MudkipMedkitz",
"gzmzmvp", "gzmzmvp",
"Welkor", "raf8osz",
"John Martin", "ElitaSSJ4",
"Richard", "Richard",
"blikkies",
"Andrew", "Andrew",
"Chris",
"Robert Wegemund", "Robert Wegemund",
"Littlehuggy", "Littlehuggy",
"moranqianlong",
"Gregory Kozhemiak", "Gregory Kozhemiak",
"mrjuan", "mrjuan",
"Brian Buie", "Brian Buie",
"Shock Shockor",
"Sadlip", "Sadlip",
"Haru Yotu", "Goldwaters",
"Eric Whitney", "Eric Whitney",
"Joey Callahan", "Joey Callahan",
"Zude",
"Ivan Tadic", "Ivan Tadic",
"Mike Simone", "Mike Simone",
"John J Linehan",
"Kyler",
"Elliot E",
"Morgandel", "Morgandel",
"Kyron Mahan", "Theerat Jiramate",
"Matura Arbeit", "aRtFuL_DodGeR",
"Noah", "Noah",
"Jacob McDaniel", "Jacob McDaniel",
"X", "X",
"Sloan Steddy", "Sloan Steddy",
"TBitz33",
"Anonym dkjglfleeoeldldldlkf",
"Temikus", "Temikus",
"Artokun", "Artokun",
"Michael Taylor", "Michael Taylor",
"SendingRavens",
"Derek Baker", "Derek Baker",
"CrimsonDX",
"Michael Anthony Scott", "Michael Anthony Scott",
"DarkSunset",
"Atilla Berke Pekduyar", "Atilla Berke Pekduyar",
"Michael Docherty",
"Nathan", "Nathan",
"Billy Gladky",
"NICHOLAS BAXLEY",
"Decx _", "Decx _",
"Paul Hartsuyker", "Probis",
"elitassj", "Ed Wang",
"Jacob Winter", "ItsGeneralButtNaked",
"Nimess",
"SRDB",
"g unit",
"Distortik", "Distortik",
"David", "Youguang",
"Meilo",
"Pen Bouryoung",
"四糸凜音", "四糸凜音",
"shinonomeiro", "Saya",
"Snille", "andrewzpong",
"MaartenAlbers", "FrxzenSnxw",
"khanh duy", "BossGame",
"xybrightsummer", "lrdchs",
"jreedatchison",
"PhilW",
"Tree Tagger", "Tree Tagger",
"Janik", "Inversity",
"Crocket", "Crocket",
"Cruel", "AIVORY3D",
"MRBlack", "Kevinj",
"Mitchell Robson", "Mitchell Robson",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Whitepinetrader", "Whitepinetrader",
"OrganicArtifact", "ResidentDeviant",
"Scott",
"MudkipMedkitz",
"deanbrian", "deanbrian",
"POPPIN", "POPPIN",
"Alex Wortman", "Alex Wortman",
"Cody", "Cody",
"Raku", "Raku",
"smart.edge5178", "smart.edge5178",
"emadsultan",
"InformedViewz", "InformedViewz",
"CHKeeho80", "CHKeeho80",
"Bubbafett", "Bubbafett",
@@ -466,76 +440,152 @@
"Menard", "Menard",
"Skyfire83", "Skyfire83",
"Adam Rinehart", "Adam Rinehart",
"D",
"Pitpe11", "Pitpe11",
"TheD1rtyD03", "TheD1rtyD03",
"moonpetal", "moonpetal",
"SomeDude", "SomeDude",
"g9p0o", "g9p0o",
"nanana",
"TheHolySheep", "TheHolySheep",
"Monte Won", "Monte Won",
"SpringBootisTrash", "SpringBootisTrash",
"carsten", "carsten",
"ikok", "ikok",
"Nathen+Choi",
"T",
"LarsesFPC",
"cocona",
"sfasdfasfdsa",
"Buecyb99", "Buecyb99",
"4IXplr0r3r", "Welkor",
"dfklsjfkljslfjd", "David Schenck",
"hayden", "John Martin",
"ahoystan",
"Leland Saunders",
"Wolfe7D1", "Wolfe7D1",
"Ink Temptation", "Ink Temptation",
"Bob Barker", "moranqianlong",
"edk",
"Kalli Core", "Kalli Core",
"Aeternyx",
"elleshar666", "elleshar666",
"YOU SINWOO", "ACTUALLY_the_Real_Willem_Dafoe",
"ja s", "Haru Yotu",
"Doug Mason",
"Kauffy", "Kauffy",
"Jeremy Townsend",
"EpicElric", "EpicElric",
"Sean voets", "Kyron Mahan",
"Owen Gwosdz",
"John J Linehan",
"Elliot E",
"Thomas Wanner",
"Theerat Jiramate",
"Edward Kennedy", "Edward Kennedy",
"Justin Blaylock", "Justin Blaylock",
"Devil Lude", "Matura Arbeit",
"Nick Kage", "Nick Kage",
"kevin stoddard", "TBitz33",
"Jack Dole", "Anonym dkjglfleeoeldldldlkf",
"Vane Holzer", "Vane Holzer",
"psytrax", "psytrax",
"Cyrus Fett",
"Ezokewn", "Ezokewn",
"SendingRavens",
"hexxish", "hexxish",
"CptNeo",
"notedfakes", "notedfakes",
"Maso", "Michael Docherty",
"Eric Ketchum",
"NICHOLAS BAXLEY",
"Michael Scott", "Michael Scott",
"Kevin Wallace", "Paul Hartsuyker",
"Matheus Couto", "elitassj",
"Saya", "Jacob Winter",
"ChicRic",
"mercur",
"J C",
"Ed Wang",
"Ryan Presley Ng", "Ryan Presley Ng",
"Wes Sims", "Wes Sims",
"Donor4115", "Donor4115",
"Lyavph",
"David",
"Meilo",
"Filippo Ferrari",
"Pen Bouryoung",
"shinonomeiro",
"Snille",
"MaartenAlbers",
"khanh duy",
"xybrightsummer",
"jreedatchison",
"PhilW",
"Janik",
"Cruel",
"MRBlack",
"Kiyoe",
"humptynutz",
"michael.isaza",
"Kalnei",
"Scott",
"Muratoraccio",
"Ginnie",
"emadsultan",
"D",
"nanana",
"Fthehappy",
"rsamerica",
"Alan+Cano",
"FeralOpticsAI",
"Pavlaki",
"generic404",
"Doug+Rintoul",
"Noor",
"Yorunai",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"zounik",
"DarkRoast",
"letzte",
"Nasty+Hobbit",
"Sora+Yori",
"lrdchs2",
"Duk3+Rand0m",
"4IXplr0r3r",
"hayden",
"ahoystan",
"Leland Saunders",
"Bob Barker",
"edk",
"JBsuede",
"Time Valentine",
"Aeternyx",
"YOU SINWOO",
"りん あめ",
"ja s",
"Михал Михалыч",
"Matt",
"Doug Mason",
"Jeremy Townsend",
"Frogmilk",
"Sean voets",
"Owen Gwosdz",
"SPJ",
"Thomas Wanner",
"Bryan Rutkowski",
"Devil Lude",
"David Murcko",
"kevin stoddard",
"Jack Dole",
"max blo",
"Xenon Xue",
"CptNeo",
"JackJohnnyJim",
"Dmitry Ryzhov",
"Maso",
"Edward Ten Eyck",
"Eric Ketchum",
"Kevin Wallace",
"Matheus Couto",
"ChicRic",
"Henrique Faiolli",
"mercur",
"Solixer",
"J C",
"jinksta187",
"Andrew Wilkinson",
"Manu Thetug",
"Karlanx",
"Yves Poezevara", "Yves Poezevara",
"operationancut",
"Teriak47", "Teriak47",
"Just me", "Just me",
"Raf Stahelin", "Raf Stahelin",
"Вячеслав Маринин", "Вячеслав Маринин",
"Lyavph",
"Filippo Ferrari",
"Cola Matthew", "Cola Matthew",
"OniNoKen", "OniNoKen",
"Iain Wisely", "Iain Wisely",
@@ -576,98 +626,121 @@
"dg", "dg",
"Maarten Harms", "Maarten Harms",
"Israel", "Israel",
"Muratoraccio",
"SelfishMedic", "SelfishMedic",
"Ginnie",
"adderleighn", "adderleighn",
"EnragedAntelope", "EnragedAntelope",
"Alan+Cano", "lighthawke",
"FeralOpticsAI", "Terraformer",
"Pavlaki", "GDS+DEV",
"generic404", "4rt+r3d",
"low9",
"Winged",
"you+halo9",
"YassineKhaled",
"YK12",
"MatteKey",
"Flob",
"ShiroSenpai",
"Somebody",
"Inkognito",
"Somebody",
"Gramer+Gumbyte",
"Crescent~San",
"Tan+Huynh",
"AiGirlTS",
"D",
"datasl4ve",
"Somebody",
"Dark_Pest",
"Aza",
"Jacky+Ho",
"koopa990",
"Karru",
"ChaChanoKo",
"null",
"bo",
"The+Forgetful+Dev",
"redcarrot",
"powerbot99",
"Mateusz+Kosela", "Mateusz+Kosela",
"Doug+Rintoul",
"Noor",
"Yorunai",
"Bula", "Bula",
"quantenmecha",
"abattoirblues",
"Jason+Nash",
"BillyBoy84",
"DarkRoast",
"zounik",
"letzte",
"Nasty+Hobbit",
"SgtFluffles",
"lrdchs2",
"Duk3+Rand0m",
"KUJYAKU", "KUJYAKU",
"NathenChoi",
"Thomas+Reck",
"Larses",
"cocona",
"Coeur+de+cochon", "Coeur+de+cochon",
"David Schenck",
"han b", "han b",
"Nico", "Nico",
"Banana Joe", "Banana Joe",
"_ G3n", "_ G3n",
"Donovan Jenkins", "Donovan Jenkins",
"JBsuede", "Tú Nguyễn Lý Hoàng",
"Michael Eid", "Michael Eid",
"beersandbacon", "beersandbacon",
"Maximilian Pyko", "Maximilian Pyko",
"Invis", "Invis",
"Justin Houston", "Bob barker",
"Time Valentine", "Ben D",
"Garrett Wood",
"Ronan Delevacq",
"james", "james",
"Christian Schäfer",
"OrochiNights", "OrochiNights",
"Michael Zhu", "Michael Zhu",
"ACTUALLY_the_Real_Willem_Dafoe",
"gonzalo", "gonzalo",
"Seraphy", "Seraphy",
"Михал Михалыч",
"雨の心 落", "雨の心 落",
"Matt",
"AllTimeNoobie", "AllTimeNoobie",
"jumpd", "jumpd",
"John C", "John C",
"Rim", "Rim",
"Dave Abraham",
"Joaquin Hierrezuelo",
"Dismem", "Dismem",
"Frogmilk", "Locrospiel",
"SPJ", "Jairus Knudsen",
"Jarrid Lee",
"Xan Dionysus", "Xan Dionysus",
"Nathan lee", "Nathan lee",
"Kor",
"Joseph Hanson",
"Mewtora", "Mewtora",
"Middo", "Middo",
"Forbidden Atelier", "Forbidden Atelier",
"Bryan Rutkowski", "John Rednoulf",
"Spire",
"Adictedtohumping", "Adictedtohumping",
"Boba Smith",
"Towelie", "Towelie",
"Cyrus Fett", "MR.Bear",
"dsffsdfsdfsdfsdfsdf",
"Jean-françois SEMA", "Jean-françois SEMA",
"Kurt", "Kurt",
"max blo", "ivistorm",
"Xenon Xue", "Sauv",
"JackJohnnyJim", "Steven",
"Edward Ten Eyck", "TenaciousD",
"Khánh Đặng",
"Chase Kwon", "Chase Kwon",
"Ted Cart",
"Inyoshu", "Inyoshu",
"Goober719", "Goober719",
"Chad Barnes", "Chad Barnes",
"Person Y",
"David Spearing",
"James Ming", "James Ming",
"vanditking", "vanditking",
"kripitonga", "kripitonga",
"Rizzi", "Rizzi",
"nimin", "nimin",
"OMAR LUCIANO", "OMAR LUCIANO",
"Ken+Suzuki",
"hannibal", "hannibal",
"Jo+Example", "Jo+Example",
"BrentBertram", "BrentBertram",
"Tigon",
"eumelzocker", "eumelzocker",
"dxjaymz", "dxjaymz",
"L C", "L C",
"Dude" "Dude",
"CK"
], ],
"totalCount": 666 "totalCount": 739
} }

View File

@@ -1,183 +0,0 @@
## Overview
The **LoRA Manager Civitai Extension** is a Browser extension designed to work seamlessly with [LoRA Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) to significantly enhance your browsing experience on [Civitai](https://civitai.com). With this extension, you can:
✅ Instantly see which models are already present in your local library
✅ Download new models with a single click
✅ Manage downloads efficiently with queue and parallel download support
✅ Keep your downloaded models automatically organized according to your custom settings
![Civitai Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-models-page.png)
**Update:** It now also supports browsing on [CivArchive](https://civarchive.com/) (formerly CivitaiArchive).
![CivArchive Models page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civarchive-models-page.png)
---
## Why Supporter Access?
LoRA Manager is built with love for the Stable Diffusion and ComfyUI communities. Your support makes it possible for me to keep improving and maintaining the tool full-time.
Supporter-exclusive features help ensure the long-term sustainability of LoRA Manager, allowing continuous updates, new features, and better performance for everyone.
Every contribution directly fuels development and keeps the core LoRA Manager free and open-source. In addition to monthly supporters, one-time donation supporters will also receive a license key, with the duration scaling according to the contribution amount. Thank you for helping keep this project alive and growing. ❤️
---
## Installation
### Supported Browsers & Installation Methods
| Browser | Installation Method |
|--------------------|-------------------------------------------------------------------------------------|
| **Google Chrome** | [Chrome Web Store link](https://chromewebstore.google.com/detail/capigligggeijgmocnaflanlbghnamgm?utm_source=item-share-cb) |
| **Microsoft Edge** | Install via Chrome Web Store (compatible) |
| **Brave Browser** | Install via Chrome Web Store (compatible) |
| **Opera** | Install via Chrome Web Store (compatible) |
| **Firefox** | <div id="firefox-install" class="install-ok"><a href="https://github.com/willmiao/lm-civitai-extension-firefox/releases/latest/download/extension.xpi">📦 Install Firefox Extension (reviewed and verified by Mozilla)</a></div> |
For non-Chrome browsers (e.g., Microsoft Edge), you can typically install extensions from the Chrome Web Store by following these steps: open the extensions Chrome Web Store page, click 'Get extension', then click 'Allow' when prompted to enable installations from other stores, and finally click 'Add extension' to complete the installation.
---
## Privacy & Security
I understand concerns around browser extensions and privacy, and I want to be fully transparent about how the **LM Civitai Extension** works:
- **Reviewed and Verified**
This extension has been **manually reviewed and approved by the Chrome Web Store**. The Firefox version uses the **exact same code** (only the packaging format differs) and has passed **Mozillas Add-on review**.
- **Minimal Network Access**
The only external server this extension connects to is:
**`https://willmiao.shop`** — used solely for **license validation**.
It does **not collect, transmit, or store any personal or usage data**.
No browsing history, no user IDs, no analytics, no hidden trackers.
- **Local-Only Model Detection**
Model detection and LoRA Manager communication all happen **locally** within your browser, directly interacting with your local LoRA Manager backend.
I value your trust and are committed to keeping your local setup private and secure. If you have any questions, feel free to reach out!
---
## How to Use
After installing the extension, you'll automatically receive a **7-day trial** to explore all features.
When the extension is correctly installed and your license is valid:
- Open **Civitai**, and you'll see visual indicators added by the extension on model cards, showing:
- ✅ Models already present in your local library
- ⬇️ A download button for models not in your library
Clicking the download button adds the corresponding model version to the download queue, waiting to be downloaded. You can set up to **5 models to download simultaneously**.
### Visual Indicators Appear On:
- **Home Page** — Featured models
- **Models Page**
- **Creator Profiles** — If the creator has set their models to be visible
- **Recommended Resources** — On individual model pages
### Version Buttons on Model Pages
On a specific model page, visual indicators also appear on version buttons, showing which versions are already in your local library.
**Starting from v0.4.8**, model pages use a dedicated download button for better compatibility. When switching to a specific version by clicking a version button:
- The new **dedicated download button** directly triggers download via **LoRA Manager**
- The **original download button** remains unchanged for standard browser downloads
![Civitai Model Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-model-page.png)
### Hide Models Already in Library (Beta)
**New in v0.4.8**: A new **Hide models already in library (Beta)** option makes it easier to focus on models you haven't added yet. It can be enabled from Settings, or toggled quickly using **Ctrl + Shift + H** (macOS: **Command + Shift + H**).
### Resources on Image Pages — now shows in-library indicators for image resources plus one-click recipe import
- **One-Click Import Civitai Image as Recipe** — Import any Civitai image as a recipe with a single click in the Resources Used panel.
- **Auto-Queue Missing Assets** — In Settings you can decide if LoRAs or checkpoints referenced by that image should automatically be added to your download queue.
- **More Accurate Metadata** — Importing directly from the page is faster than copying inside LM and keeps on-site tags and other metadata perfectly aligned.
![Civitai Image Page](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/civitai-image-page.jpg)
[![alt](url)](https://github.com/user-attachments/assets/41fd4240-c949-4f83-bde7-8f3124c09494)
---
## Model Download Location & LoRA Manager Settings
To use the **one-click download function**, you must first set:
- Your **Default LoRAs Root**
- Your **Default Checkpoints Root**
These are set within LoRA Manager's settings.
When everything is configured, downloaded model files will be placed in:
`<Default_Models_Root>/<Base_Model_of_the_Model>/<First_Tag_of_the_Model>`
### Update: Default Path Customization (2025-07-21)
A new setting to customize the default download path has been added in the nightly version. You can now personalize where models are saved when downloading via the LM Civitai Extension.
![Default Path Customization](https://github.com/willmiao/ComfyUI-Lora-Manager/blob/main/wiki-images/default-path-customization.png)
The previous YAML path mapping file will be deprecated—settings will now be unified in settings.json to simplify configuration.
---
## Backend Port Configuration
If your **ComfyUI** or **LoRA Manager** backend is running on a port **other than the default 8188**, you must configure the backend port in the extension's settings.
After correctly setting and saving the port, you'll see in the extension's header area:
- A **Healthy** status with the tooltip: `Connected to LoRA Manager on port xxxx`
---
## Advanced Usage
### Connecting to a Remote LoRA Manager
If your LoRA Manager is running on another computer, you can still connect from your browser using port forwarding.
> **Why can't you set a remote IP directly?**
>
> For privacy and security, the extension only requests access to `http://127.0.0.1/*`. Supporting remote IPs would require much broader permissions, which may be rejected by browser stores and could raise user concerns.
**Solution: Port Forwarding with `socat`**
On your browser computer, run:
`socat TCP-LISTEN:8188,bind=127.0.0.1,fork TCP:REMOTE.IP.ADDRESS.HERE:8188`
- Replace `REMOTE.IP.ADDRESS.HERE` with the IP of the machine running LoRA Manager.
- Adjust the port if needed.
This lets the extension connect to `127.0.0.1:8188` as usual, with traffic forwarded to your remote server.
_Thanks to user **Temikus** for sharing this solution!_
---
## Roadmap
The extension will evolve alongside **LoRA Manager** improvements. Planned features include:
- [x] Support for **additional model types** (e.g., embeddings)
- [x] One-click **Recipe Import**
- [x] Display of in-library status for all resources in the **Resources Used** section of the image page
- [x] One-click **Auto-organize Models**
- [x] **Hide models already in library (Beta)** - Focus on models you haven't added yet
**Stay tuned — and thank you for your support!**
---

View File

@@ -15,7 +15,8 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"help": "Hilfe", "help": "Hilfe",
"add": "Hinzufügen", "add": "Hinzufügen",
"close": "Schließen" "close": "Schließen",
"menu": "Menü"
}, },
"status": { "status": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
@@ -276,6 +277,7 @@
"help": "Optionaler Pfad zur ausführbaren aria2c-Datei. Leer lassen, um aria2c aus dem System-PATH zu verwenden.", "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" "placeholder": "Leer lassen, um aria2c aus dem PATH zu verwenden"
}, },
"aria2HelpLink": "Erfahren Sie, wie Sie das aria2-Download-Backend einrichten",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "Civitai-Host-Einstellung verfügbar", "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.", "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.",
@@ -427,6 +429,8 @@
"hover": "Bei Hover anzeigen" "hover": "Bei Hover anzeigen"
}, },
"cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen", "cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen",
"showVersionOnCard": "Version auf Karte anzeigen",
"showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden",
"modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche", "modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "Beispielbilder öffnen", "exampleImages": "Beispielbilder öffnen",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Geben Sie den Ordnerpfad ein, wo Beispielbilder von Civitai gespeichert werden", "downloadLocationHelp": "Geben Sie den Ordnerpfad ein, wo Beispielbilder von Civitai gespeichert werden",
"autoDownload": "Beispielbilder automatisch herunterladen", "autoDownload": "Beispielbilder automatisch herunterladen",
"autoDownloadHelp": "Beispielbilder automatisch für Modelle herunterladen, die keine haben (erfordert gesetzten Download-Speicherort)", "autoDownloadHelp": "Beispielbilder automatisch für Modelle herunterladen, die keine haben (erfordert gesetzten Download-Speicherort)",
"openMode": "Aktion für Beispielbilder öffnen",
"openModeHelp": "Wählen Sie, ob die Aktion auf dem Server geöffnet, ein zugeordneter lokaler Pfad kopiert oder eine benutzerdefinierte URI gestartet werden soll.",
"openModeOptions": {
"system": "Auf Server öffnen",
"clipboard": "Lokalen Pfad kopieren",
"uriTemplate": "Benutzerdefinierte URI öffnen"
},
"localRoot": "Lokales Stammverzeichnis für Beispielbilder",
"localRootHelp": "Optionales lokales oder eingebundenes Stammverzeichnis, das das Beispielbild-Verzeichnis des Servers widerspiegelt. Wenn leer, wird der Serverpfad wiederverwendet.",
"localRootPlaceholder": "Beispiel: /Volumes/ComfyUI/example_images",
"uriTemplate": "URI-Vorlage öffnen",
"uriTemplateHelp": "Verwenden Sie einen benutzerdefinierten Deeplink wie eine Datei-URI oder einen Shortcuts-Link.",
"uriTemplatePlaceholder": "Beispiel: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Verfügbare Platzhalter: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Mehr über Remote-Open-Modi erfahren",
"optimizeImages": "Heruntergeladene Bilder optimieren", "optimizeImages": "Heruntergeladene Bilder optimieren",
"optimizeImagesHelp": "Beispielbilder optimieren, um Dateigröße zu reduzieren und Ladegeschwindigkeit zu verbessern (Metadaten bleiben erhalten)", "optimizeImagesHelp": "Beispielbilder optimieren, um Dateigröße zu reduzieren und Ladegeschwindigkeit zu verbessern (Metadaten bleiben erhalten)",
"download": "Herunterladen", "download": "Herunterladen",
@@ -668,7 +687,7 @@
"autoOrganize": "Automatisch organisieren", "autoOrganize": "Automatisch organisieren",
"skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen", "skipMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle überspringen",
"resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen", "resumeMetadataRefresh": "Metadaten-Aktualisierung für ausgewählte Modelle fortsetzen",
"deleteAll": "Alle Modelle löschen", "deleteAll": "Ausgewählte löschen",
"downloadMissingLoras": "Fehlende LoRAs herunterladen", "downloadMissingLoras": "Fehlende LoRAs herunterladen",
"clear": "Auswahl löschen", "clear": "Auswahl löschen",
"skipMetadataRefreshCount": "Überspringen{count} Modelle", "skipMetadataRefreshCount": "Überspringen{count} Modelle",
@@ -1190,6 +1209,8 @@
"cancel": "Bearbeitung abbrechen", "cancel": "Bearbeitung abbrechen",
"save": "Änderungen speichern", "save": "Änderungen speichern",
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten", "addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
"editWord": "Trigger Word bearbeiten",
"editPlaceholder": "Trigger Word bearbeiten",
"copyWord": "Trigger Word kopieren", "copyWord": "Trigger Word kopieren",
"deleteWord": "Trigger Word löschen", "deleteWord": "Trigger Word löschen",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "Früher Zugriff", "earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich", "earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"ignored": "Ignoriert", "ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert" "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
"onSiteOnlyTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar"
}, },
"actions": { "actions": {
"download": "Herunterladen", "download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen", "downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen", "downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen", "delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen", "deleteTooltip": "Diese lokale Version löschen",
"ignore": "Ignorieren", "ignore": "Ignorieren",
@@ -1440,6 +1464,10 @@
"opened": "Beispielbilder-Ordner geöffnet", "opened": "Beispielbilder-Ordner geöffnet",
"openingFolder": "Beispielbilder-Ordner wird geöffnet", "openingFolder": "Beispielbilder-Ordner wird geöffnet",
"failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners", "failedToOpen": "Fehler beim Öffnen des Beispielbilder-Ordners",
"copiedPath": "Pfad in Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}",
"copiedUri": "Link in Zwischenablage kopiert: {{uri}}",
"uriClipboardFallback": "Link: {{uri}}",
"setupRequired": "Beispielbilder-Speicher", "setupRequired": "Beispielbilder-Speicher",
"setupDescription": "Um benutzerdefinierte Beispielbilder hinzuzufügen, müssen Sie zuerst einen Download-Speicherort festlegen.", "setupDescription": "Um benutzerdefinierte Beispielbilder hinzuzufügen, müssen Sie zuerst einen Download-Speicherort festlegen.",
"setupUsage": "Dieser Pfad wird sowohl für heruntergeladene als auch für benutzerdefinierte Beispielbilder verwendet.", "setupUsage": "Dieser Pfad wird sowohl für heruntergeladene als auch für benutzerdefinierte Beispielbilder verwendet.",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "Konnte trainierte Wörter nicht laden", "loadFailed": "Konnte trainierte Wörter nicht laden",
"tooLong": "Trigger Word sollte 100 Wörter nicht überschreiten", "tooLong": "Trigger Word sollte 500 Wörter nicht überschreiten",
"tooMany": "Maximal 30 Trigger Words erlaubt", "tooMany": "Maximal 100 Trigger Words erlaubt",
"alreadyExists": "Dieses Trigger Word existiert bereits", "alreadyExists": "Dieses Trigger Word existiert bereits",
"updateSuccess": "Trigger Words erfolgreich aktualisiert", "updateSuccess": "Trigger Words erfolgreich aktualisiert",
"updateFailed": "Fehler beim Aktualisieren der Trigger Words", "updateFailed": "Fehler beim Aktualisieren der Trigger Words",
@@ -1882,7 +1910,9 @@
"repairSuccess": "Cache-Neuaufbau abgeschlossen.", "repairSuccess": "Cache-Neuaufbau abgeschlossen.",
"repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}", "repairFailed": "Cache-Neuaufbau fehlgeschlagen: {message}",
"exportSuccess": "Diagnosepaket exportiert.", "exportSuccess": "Diagnosepaket exportiert.",
"exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}" "exportFailed": "Export des Diagnosepakets fehlgeschlagen: {message}",
"conflictsResolved": "{count} Dateinamenskonflikt(e) gelöst.",
"conflictsResolveFailed": "Auflösung der Dateinamenskonflikte fehlgeschlagen: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Settings", "settings": "Settings",
"help": "Help", "help": "Help",
"add": "Add", "add": "Add",
"close": "Close" "close": "Close",
"menu": "Menu"
}, },
"status": { "status": {
"loading": "Loading...", "loading": "Loading...",
@@ -276,6 +277,7 @@
"help": "Optional path to the aria2c executable. Leave empty to use aria2c from your system 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" "placeholder": "Leave empty to use aria2c from PATH"
}, },
"aria2HelpLink": "Learn how to set up the aria2 download backend",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "Civitai host preference available", "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.", "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.",
@@ -427,6 +429,8 @@
"hover": "Reveal on Hover" "hover": "Reveal on Hover"
}, },
"cardInfoDisplayHelp": "Choose when to display model information and action buttons", "cardInfoDisplayHelp": "Choose when to display model information and action buttons",
"showVersionOnCard": "Show Version on Card",
"showVersionOnCardHelp": "Show or hide the version name on model cards",
"modelCardFooterAction": "Model Card Button Action", "modelCardFooterAction": "Model Card Button Action",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "Open Example Images", "exampleImages": "Open Example Images",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved", "downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved",
"autoDownload": "Auto Download Example Images", "autoDownload": "Auto Download Example Images",
"autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)", "autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)",
"openMode": "Open Example Images Action",
"openModeHelp": "Choose whether the action opens on the server, copies a mapped local path, or launches a custom URI.",
"openModeOptions": {
"system": "Open on server",
"clipboard": "Copy local path",
"uriTemplate": "Open custom URI"
},
"localRoot": "Local Example Images Root",
"localRootHelp": "Optional local or mounted root that mirrors the server example images directory. If blank, the server path is reused.",
"localRootPlaceholder": "Example: /Volumes/ComfyUI/example_images",
"uriTemplate": "Open URI Template",
"uriTemplateHelp": "Use a custom deep link such as a file URI or a Shortcuts link.",
"uriTemplatePlaceholder": "Example: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Available placeholders: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Learn more about remote open modes",
"optimizeImages": "Optimize Downloaded Images", "optimizeImages": "Optimize Downloaded Images",
"optimizeImagesHelp": "Optimize example images to reduce file size and improve loading speed (metadata will be preserved)", "optimizeImagesHelp": "Optimize example images to reduce file size and improve loading speed (metadata will be preserved)",
"download": "Download", "download": "Download",
@@ -668,7 +687,7 @@
"autoOrganize": "Auto-Organize Selected", "autoOrganize": "Auto-Organize Selected",
"skipMetadataRefresh": "Skip Metadata Refresh for Selected", "skipMetadataRefresh": "Skip Metadata Refresh for Selected",
"resumeMetadataRefresh": "Resume Metadata Refresh for Selected", "resumeMetadataRefresh": "Resume Metadata Refresh for Selected",
"deleteAll": "Delete Selected Models", "deleteAll": "Delete Selected",
"downloadMissingLoras": "Download Missing LoRAs", "downloadMissingLoras": "Download Missing LoRAs",
"clear": "Clear Selection", "clear": "Clear Selection",
"skipMetadataRefreshCount": "Skip ({count} models)", "skipMetadataRefreshCount": "Skip ({count} models)",
@@ -1190,6 +1209,8 @@
"cancel": "Cancel editing", "cancel": "Cancel editing",
"save": "Save changes", "save": "Save changes",
"addPlaceholder": "Type to add or click suggestions below", "addPlaceholder": "Type to add or click suggestions below",
"editWord": "Edit trigger word",
"editPlaceholder": "Edit trigger word",
"copyWord": "Copy trigger word", "copyWord": "Copy trigger word",
"deleteWord": "Delete trigger word", "deleteWord": "Delete trigger word",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "Early Access", "earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access", "earlyAccessTooltip": "This version currently requires Civitai early access",
"ignored": "Ignored", "ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version" "ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
}, },
"actions": { "actions": {
"download": "Download", "download": "Download",
"downloadTooltip": "Download this version", "downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai", "downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete", "delete": "Delete",
"deleteTooltip": "Delete this local version", "deleteTooltip": "Delete this local version",
"ignore": "Ignore", "ignore": "Ignore",
@@ -1440,6 +1464,10 @@
"opened": "Example images folder opened", "opened": "Example images folder opened",
"openingFolder": "Opening example images folder", "openingFolder": "Opening example images folder",
"failedToOpen": "Failed to open example images folder", "failedToOpen": "Failed to open example images folder",
"copiedPath": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}",
"copiedUri": "Link copied to clipboard: {{uri}}",
"uriClipboardFallback": "Link: {{uri}}",
"setupRequired": "Example Images Storage", "setupRequired": "Example Images Storage",
"setupDescription": "To add custom example images, you need to set a download location first.", "setupDescription": "To add custom example images, you need to set a download location first.",
"setupUsage": "This path is used for both downloaded and custom example images.", "setupUsage": "This path is used for both downloaded and custom example images.",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "Could not load trained words", "loadFailed": "Could not load trained words",
"tooLong": "Trigger word should not exceed 100 words", "tooLong": "Trigger word should not exceed 500 words",
"tooMany": "Maximum 30 trigger words allowed", "tooMany": "Maximum 100 trigger words allowed",
"alreadyExists": "This trigger word already exists", "alreadyExists": "This trigger word already exists",
"updateSuccess": "Trigger words updated successfully", "updateSuccess": "Trigger words updated successfully",
"updateFailed": "Failed to update trigger words", "updateFailed": "Failed to update trigger words",
@@ -1882,7 +1910,9 @@
"repairSuccess": "Cache rebuild completed.", "repairSuccess": "Cache rebuild completed.",
"repairFailed": "Cache rebuild failed: {message}", "repairFailed": "Cache rebuild failed: {message}",
"exportSuccess": "Diagnostics bundle exported.", "exportSuccess": "Diagnostics bundle exported.",
"exportFailed": "Failed to export diagnostics bundle: {message}" "exportFailed": "Failed to export diagnostics bundle: {message}",
"conflictsResolved": "{count} filename conflict(s) resolved.",
"conflictsResolveFailed": "Failed to resolve filename conflicts: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Configuración", "settings": "Configuración",
"help": "Ayuda", "help": "Ayuda",
"add": "Añadir", "add": "Añadir",
"close": "Cerrar" "close": "Cerrar",
"menu": "Menú"
}, },
"status": { "status": {
"loading": "Cargando...", "loading": "Cargando...",
@@ -276,6 +277,7 @@
"help": "Ruta opcional al ejecutable aria2c. Déjalo vacío para usar aria2c desde el PATH del sistema.", "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" "placeholder": "Déjalo vacío para usar aria2c desde el PATH"
}, },
"aria2HelpLink": "Aprende a configurar el backend de descarga aria2",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "Preferencia de host de Civitai disponible", "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.", "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.",
@@ -427,6 +429,8 @@
"hover": "Mostrar al pasar el ratón" "hover": "Mostrar al pasar el ratón"
}, },
"cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción", "cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción",
"showVersionOnCard": "Mostrar versión en la tarjeta",
"showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo",
"modelCardFooterAction": "Acción del botón de tarjeta de modelo", "modelCardFooterAction": "Acción del botón de tarjeta de modelo",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "Abrir imágenes de ejemplo", "exampleImages": "Abrir imágenes de ejemplo",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Introduce la ruta de la carpeta donde se guardarán las imágenes de ejemplo de Civitai", "downloadLocationHelp": "Introduce la ruta de la carpeta donde se guardarán las imágenes de ejemplo de Civitai",
"autoDownload": "Descargar automáticamente imágenes de ejemplo", "autoDownload": "Descargar automáticamente imágenes de ejemplo",
"autoDownloadHelp": "Descargar automáticamente imágenes de ejemplo para modelos que no las tengan (requiere que se establezca la ubicación de descarga)", "autoDownloadHelp": "Descargar automáticamente imágenes de ejemplo para modelos que no las tengan (requiere que se establezca la ubicación de descarga)",
"openMode": "Acción al abrir imágenes de ejemplo",
"openModeHelp": "Elige si la acción se abre en el servidor, copia una ruta local asignada o lanza una URI personalizada.",
"openModeOptions": {
"system": "Abrir en el servidor",
"clipboard": "Copiar ruta local",
"uriTemplate": "Abrir URI personalizada"
},
"localRoot": "Raíz local de imágenes de ejemplo",
"localRootHelp": "Raíz local u montada opcional que refleja el directorio de imágenes de ejemplo del servidor. Si se deja en blanco, se reutiliza la ruta del servidor.",
"localRootPlaceholder": "Ejemplo: /Volumes/ComfyUI/example_images",
"uriTemplate": "Abrir plantilla de URI",
"uriTemplateHelp": "Usa un enlace profundo personalizado, como un URI de archivo o un enlace de Shortcuts.",
"uriTemplatePlaceholder": "Ejemplo: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Marcadores disponibles: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Más información sobre los modos de apertura remota",
"optimizeImages": "Optimizar imágenes descargadas", "optimizeImages": "Optimizar imágenes descargadas",
"optimizeImagesHelp": "Optimizar imágenes de ejemplo para reducir el tamaño del archivo y mejorar la velocidad de carga (se preservarán los metadatos)", "optimizeImagesHelp": "Optimizar imágenes de ejemplo para reducir el tamaño del archivo y mejorar la velocidad de carga (se preservarán los metadatos)",
"download": "Descargar", "download": "Descargar",
@@ -668,7 +687,7 @@
"autoOrganize": "Auto-organizar seleccionados", "autoOrganize": "Auto-organizar seleccionados",
"skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados", "skipMetadataRefresh": "Omitir actualización de metadatos para seleccionados",
"resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados", "resumeMetadataRefresh": "Reanudar actualización de metadatos para seleccionados",
"deleteAll": "Eliminar todos los modelos", "deleteAll": "Eliminar seleccionados",
"downloadMissingLoras": "Descargar LoRAs faltantes", "downloadMissingLoras": "Descargar LoRAs faltantes",
"clear": "Limpiar selección", "clear": "Limpiar selección",
"skipMetadataRefreshCount": "Omitir{count} modelos", "skipMetadataRefreshCount": "Omitir{count} modelos",
@@ -1190,6 +1209,8 @@
"cancel": "Cancelar edición", "cancel": "Cancelar edición",
"save": "Guardar cambios", "save": "Guardar cambios",
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo", "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", "copyWord": "Copiar palabra clave",
"deleteWord": "Eliminar palabra clave", "deleteWord": "Eliminar palabra clave",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai", "earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"ignored": "Ignorada", "ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión" "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
"onSiteOnlyTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai"
}, },
"actions": { "actions": {
"download": "Descargar", "download": "Descargar",
"downloadTooltip": "Descargar esta versión", "downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai", "downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar", "delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local", "deleteTooltip": "Eliminar esta versión local",
"ignore": "Ignorar", "ignore": "Ignorar",
@@ -1440,6 +1464,10 @@
"opened": "Carpeta de imágenes de ejemplo abierta", "opened": "Carpeta de imágenes de ejemplo abierta",
"openingFolder": "Abriendo carpeta de imágenes de ejemplo", "openingFolder": "Abriendo carpeta de imágenes de ejemplo",
"failedToOpen": "Error al abrir carpeta de imágenes de ejemplo", "failedToOpen": "Error al abrir carpeta de imágenes de ejemplo",
"copiedPath": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}",
"copiedUri": "Enlace copiado al portapapeles: {{uri}}",
"uriClipboardFallback": "Enlace: {{uri}}",
"setupRequired": "Almacenamiento de imágenes de ejemplo", "setupRequired": "Almacenamiento de imágenes de ejemplo",
"setupDescription": "Para agregar imágenes de ejemplo personalizadas, primero necesita establecer una ubicación de descarga.", "setupDescription": "Para agregar imágenes de ejemplo personalizadas, primero necesita establecer una ubicación de descarga.",
"setupUsage": "Esta ruta se utiliza tanto para imágenes de ejemplo descargadas como personalizadas.", "setupUsage": "Esta ruta se utiliza tanto para imágenes de ejemplo descargadas como personalizadas.",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "No se pudieron cargar palabras entrenadas", "loadFailed": "No se pudieron cargar palabras entrenadas",
"tooLong": "La palabra clave no debe exceder 100 palabras", "tooLong": "La palabra clave no debe exceder 500 palabras",
"tooMany": "Máximo 30 palabras clave permitidas", "tooMany": "Máximo 100 palabras clave permitidas",
"alreadyExists": "Esta palabra clave ya existe", "alreadyExists": "Esta palabra clave ya existe",
"updateSuccess": "Palabras clave actualizadas exitosamente", "updateSuccess": "Palabras clave actualizadas exitosamente",
"updateFailed": "Error al actualizar palabras clave", "updateFailed": "Error al actualizar palabras clave",
@@ -1882,7 +1910,9 @@
"repairSuccess": "Reconstrucción de caché completada.", "repairSuccess": "Reconstrucción de caché completada.",
"repairFailed": "Error al reconstruir la caché: {message}", "repairFailed": "Error al reconstruir la caché: {message}",
"exportSuccess": "Paquete de diagnósticos exportado.", "exportSuccess": "Paquete de diagnósticos exportado.",
"exportFailed": "Error al exportar el paquete de diagnósticos: {message}" "exportFailed": "Error al exportar el paquete de diagnósticos: {message}",
"conflictsResolved": "{count} conflicto(s) de nombre de archivo resuelto(s).",
"conflictsResolveFailed": "Error al resolver conflictos de nombre de archivo: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Paramètres", "settings": "Paramètres",
"help": "Aide", "help": "Aide",
"add": "Ajouter", "add": "Ajouter",
"close": "Fermer" "close": "Fermer",
"menu": "Menu"
}, },
"status": { "status": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -276,6 +277,7 @@
"help": "Chemin facultatif vers lexécutable aria2c. Laissez vide pour utiliser aria2c depuis le PATH système.", "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" "placeholder": "Laisser vide pour utiliser aria2c depuis le PATH"
}, },
"aria2HelpLink": "Apprenez à configurer le backend de téléchargement aria2",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "Préférence dhôte Civitai disponible", "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.", "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.",
@@ -427,6 +429,8 @@
"hover": "Révéler au survol" "hover": "Révéler au survol"
}, },
"cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action", "cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action",
"showVersionOnCard": "Afficher la version sur la carte",
"showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle",
"modelCardFooterAction": "Action du bouton de carte de modèle", "modelCardFooterAction": "Action du bouton de carte de modèle",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "Ouvrir les images d'exemple", "exampleImages": "Ouvrir les images d'exemple",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Entrez le chemin du dossier où les images d'exemple de Civitai seront sauvegardées", "downloadLocationHelp": "Entrez le chemin du dossier où les images d'exemple de Civitai seront sauvegardées",
"autoDownload": "Téléchargement automatique des images d'exemple", "autoDownload": "Téléchargement automatique des images d'exemple",
"autoDownloadHelp": "Télécharger automatiquement les images d'exemple pour les modèles qui n'en ont pas (nécessite que l'emplacement de téléchargement soit défini)", "autoDownloadHelp": "Télécharger automatiquement les images d'exemple pour les modèles qui n'en ont pas (nécessite que l'emplacement de téléchargement soit défini)",
"openMode": "Action douverture des images dexemple",
"openModeHelp": "Choisissez si laction souvre sur le serveur, copie un chemin local mappé ou lance une URI personnalisée.",
"openModeOptions": {
"system": "Ouvrir sur le serveur",
"clipboard": "Copier le chemin local",
"uriTemplate": "Ouvrir une URI personnalisée"
},
"localRoot": "Racine locale des images dexemple",
"localRootHelp": "Racine locale ou montée facultative qui reflète le répertoire des images dexemple du serveur. Si vide, le chemin du serveur est réutilisé.",
"localRootPlaceholder": "Exemple : /Volumes/ComfyUI/example_images",
"uriTemplate": "Ouvrir le modèle dURI",
"uriTemplateHelp": "Utilisez un lien profond personnalisé, tel quune URI de fichier ou un lien Shortcuts.",
"uriTemplatePlaceholder": "Exemple : shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Paramètres disponibles : {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "En savoir plus sur les modes d'ouverture à distance",
"optimizeImages": "Optimiser les images téléchargées", "optimizeImages": "Optimiser les images téléchargées",
"optimizeImagesHelp": "Optimiser les images d'exemple pour réduire la taille du fichier et améliorer la vitesse de chargement (les métadonnées seront préservées)", "optimizeImagesHelp": "Optimiser les images d'exemple pour réduire la taille du fichier et améliorer la vitesse de chargement (les métadonnées seront préservées)",
"download": "Télécharger", "download": "Télécharger",
@@ -668,7 +687,7 @@
"autoOrganize": "Auto-organiser la sélection", "autoOrganize": "Auto-organiser la sélection",
"skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection", "skipMetadataRefresh": "Ignorer l'actualisation des métadonnées pour la sélection",
"resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection", "resumeMetadataRefresh": "Reprendre l'actualisation des métadonnées pour la sélection",
"deleteAll": "Supprimer tous les modèles", "deleteAll": "Supprimer la sélection",
"downloadMissingLoras": "Télécharger les LoRAs manquants", "downloadMissingLoras": "Télécharger les LoRAs manquants",
"clear": "Effacer la sélection", "clear": "Effacer la sélection",
"skipMetadataRefreshCount": "Ignorer{count} modèles", "skipMetadataRefreshCount": "Ignorer{count} modèles",
@@ -1190,6 +1209,8 @@
"cancel": "Annuler la modification", "cancel": "Annuler la modification",
"save": "Sauvegarder les modifications", "save": "Sauvegarder les modifications",
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous", "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é", "copyWord": "Copier le mot-clé",
"deleteWord": "Supprimer le mot-clé", "deleteWord": "Supprimer le mot-clé",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai", "earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"ignored": "Ignorée", "ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version" "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
"onSiteOnlyTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai"
}, },
"actions": { "actions": {
"download": "Télécharger", "download": "Télécharger",
"downloadTooltip": "Télécharger cette version", "downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai", "downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer", "delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale", "deleteTooltip": "Supprimer cette version locale",
"ignore": "Ignorer", "ignore": "Ignorer",
@@ -1440,6 +1464,10 @@
"opened": "Dossier d'images d'exemple ouvert", "opened": "Dossier d'images d'exemple ouvert",
"openingFolder": "Ouverture du dossier d'images d'exemple", "openingFolder": "Ouverture du dossier d'images d'exemple",
"failedToOpen": "Échec de l'ouverture du dossier d'images d'exemple", "failedToOpen": "Échec de l'ouverture du dossier d'images d'exemple",
"copiedPath": "Chemin copié dans le presse-papiers : {{path}}",
"clipboardFallback": "Chemin : {{path}}",
"copiedUri": "Lien copié dans le presse-papiers : {{uri}}",
"uriClipboardFallback": "Lien : {{uri}}",
"setupRequired": "Stockage d'images d'exemple", "setupRequired": "Stockage d'images d'exemple",
"setupDescription": "Pour ajouter des images d'exemple personnalisées, vous devez d'abord définir un emplacement de téléchargement.", "setupDescription": "Pour ajouter des images d'exemple personnalisées, vous devez d'abord définir un emplacement de téléchargement.",
"setupUsage": "Ce chemin est utilisé pour les images d'exemple téléchargées et personnalisées.", "setupUsage": "Ce chemin est utilisé pour les images d'exemple téléchargées et personnalisées.",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "Impossible de charger les mots entraînés", "loadFailed": "Impossible de charger les mots entraînés",
"tooLong": "Le mot-clé ne doit pas dépasser 100 mots", "tooLong": "Le mot-clé ne doit pas dépasser 500 mots",
"tooMany": "Maximum 30 mots-clés autorisés", "tooMany": "Maximum 100 mots-clés autorisés",
"alreadyExists": "Ce mot-clé existe déjà", "alreadyExists": "Ce mot-clé existe déjà",
"updateSuccess": "Mots-clés mis à jour avec succès", "updateSuccess": "Mots-clés mis à jour avec succès",
"updateFailed": "Échec de la mise à jour des mots-clés", "updateFailed": "Échec de la mise à jour des mots-clés",
@@ -1882,7 +1910,9 @@
"repairSuccess": "Reconstruction du cache terminée.", "repairSuccess": "Reconstruction du cache terminée.",
"repairFailed": "Échec de la reconstruction du cache : {message}", "repairFailed": "Échec de la reconstruction du cache : {message}",
"exportSuccess": "Lot de diagnostics exporté.", "exportSuccess": "Lot de diagnostics exporté.",
"exportFailed": "Échec de l'export du lot de diagnostics : {message}" "exportFailed": "Échec de l'export du lot de diagnostics : {message}",
"conflictsResolved": "{count} conflit(s) de nom de fichier résolu(s).",
"conflictsResolveFailed": "Échec de la résolution des conflits de nom de fichier : {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "הגדרות", "settings": "הגדרות",
"help": "עזרה", "help": "עזרה",
"add": "הוספה", "add": "הוספה",
"close": "סגור" "close": "סגור",
"menu": "תפריט"
}, },
"status": { "status": {
"loading": "טוען...", "loading": "טוען...",
@@ -276,6 +277,7 @@
"help": "נתיב אופציונלי לקובץ ההפעלה aria2c. השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH של המערכת.", "help": "נתיב אופציונלי לקובץ ההפעלה aria2c. השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH של המערכת.",
"placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH" "placeholder": "השאר ריק כדי להשתמש ב-aria2c מתוך ה-PATH"
}, },
"aria2HelpLink": "למד כיצד להגדיר את מנוע ההורדה aria2",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "העדפת מארח Civitai זמינה", "title": "העדפת מארח Civitai זמינה",
"content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.", "content": "Civitai משתמש כעת ב-civitai.com עבור תוכן SFW וב-civitai.red עבור תוכן ללא הגבלות. ניתן לשנות בהגדרות איזה אתר ייפתח כברירת מחדל.",
@@ -427,6 +429,8 @@
"hover": "חשוף בריחוף" "hover": "חשוף בריחוף"
}, },
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה", "cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
"showVersionOnCard": "הצג גרסה בכרטיס",
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
"modelCardFooterAction": "פעולת כפתור כרטיס מודל", "modelCardFooterAction": "פעולת כפתור כרטיס מודל",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "פתח תמונות דוגמה", "exampleImages": "פתח תמונות דוגמה",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "הזן את נתיב התיקייה שבו יישמרו תמונות דוגמה מ-Civitai", "downloadLocationHelp": "הזן את נתיב התיקייה שבו יישמרו תמונות דוגמה מ-Civitai",
"autoDownload": "הורדה אוטומטית של תמונות דוגמה", "autoDownload": "הורדה אוטומטית של תמונות דוגמה",
"autoDownloadHelp": "הורד אוטומטית תמונות דוגמה למודלים שאין להם (דורש הגדרת מיקום הורדה)", "autoDownloadHelp": "הורד אוטומטית תמונות דוגמה למודלים שאין להם (דורש הגדרת מיקום הורדה)",
"openMode": "פעולת פתיחת תמונות דוגמה",
"openModeHelp": "בחר אם הפעולה תיפתח בשרת, תעתיק נתיב מקומי ממופה או תפעיל URI מותאם אישית.",
"openModeOptions": {
"system": "פתח בשרת",
"clipboard": "העתק נתיב מקומי",
"uriTemplate": "פתח URI מותאם אישית"
},
"localRoot": "שורש מקומי לתמונות דוגמה",
"localRootHelp": "שורש מקומי או ממופה אופציונלי שמשקף את תיקיית תמונות הדוגמה בשרת. אם השדה ריק, ייעשה שימוש חוזר בנתיב השרת.",
"localRootPlaceholder": "דוגמה: /Volumes/ComfyUI/example_images",
"uriTemplate": "תבנית URI לפתיחה",
"uriTemplateHelp": "השתמש בקישור עומק מותאם אישית כמו URI של קובץ או קישור Shortcuts.",
"uriTemplatePlaceholder": "דוגמה: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "מצייני מקום זמינים: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "למידע נוסף על מצבי פתיחה מרחוק",
"optimizeImages": "מטב תמונות שהורדו", "optimizeImages": "מטב תמונות שהורדו",
"optimizeImagesHelp": "מטב תמונות דוגמה כדי להקטין את גודל הקובץ ולשפר את מהירות הטעינה (מטא-דאטה תישמר)", "optimizeImagesHelp": "מטב תמונות דוגמה כדי להקטין את גודל הקובץ ולשפר את מהירות הטעינה (מטא-דאטה תישמר)",
"download": "הורד", "download": "הורד",
@@ -668,7 +687,7 @@
"autoOrganize": "ארגן אוטומטית נבחרים", "autoOrganize": "ארגן אוטומטית נבחרים",
"skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים", "skipMetadataRefresh": "דילוג על רענון מטא-נתונים לנבחרים",
"resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים", "resumeMetadataRefresh": "המשך רענון מטא-נתונים לנבחרים",
"deleteAll": "מחק את כל המודלים", "deleteAll": "מחק נבחרים",
"downloadMissingLoras": "הורדת LoRAs חסרים", "downloadMissingLoras": "הורדת LoRAs חסרים",
"clear": "נקה בחירה", "clear": "נקה בחירה",
"skipMetadataRefreshCount": "דילוג({count} מודלים)", "skipMetadataRefreshCount": "דילוג({count} מודלים)",
@@ -1190,6 +1209,8 @@
"cancel": "בטל עריכה", "cancel": "בטל עריכה",
"save": "שמור שינויים", "save": "שמור שינויים",
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה", "addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר", "copyWord": "העתק מילת טריגר",
"deleteWord": "מחק מילת טריגר", "deleteWord": "מחק מילת טריגר",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai", "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"ignored": "התעלם", "ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו" "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
"onSiteOnlyTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai"
}, },
"actions": { "actions": {
"download": "הורדה", "download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו", "downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה", "delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו", "deleteTooltip": "מחק את הגרסה המקומית הזו",
"ignore": "התעלם", "ignore": "התעלם",
@@ -1440,6 +1464,10 @@
"opened": "תיקיית תמונות הדוגמה נפתחה", "opened": "תיקיית תמונות הדוגמה נפתחה",
"openingFolder": "פותח תיקיית תמונות דוגמה", "openingFolder": "פותח תיקיית תמונות דוגמה",
"failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה", "failedToOpen": "פתיחת תיקיית תמונות הדוגמה נכשלה",
"copiedPath": "הנתיב הועתק ללוח: {{path}}",
"clipboardFallback": "נתיב: {{path}}",
"copiedUri": "הקישור הועתק ללוח: {{uri}}",
"uriClipboardFallback": "קישור: {{uri}}",
"setupRequired": "אחסון תמונות דוגמה", "setupRequired": "אחסון תמונות דוגמה",
"setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.", "setupDescription": "כדי להוסיף תמונות דוגמה מותאמות אישית, עליך קודם להגדיר מיקום הורדה.",
"setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.", "setupUsage": "נתיב זה משמש הן עבור תמונות דוגמה שהורדו והן עבור תמונות מותאמות אישית.",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "לא ניתן היה לטעון מילים מאומנות", "loadFailed": "לא ניתן היה לטעון מילים מאומנות",
"tooLong": "מילת טריגר לא תעלה על 100 מילים", "tooLong": "מילת טריגר לא תעלה על 500 מילים",
"tooMany": "מותרות עד 30 מילות טריגר", "tooMany": "מותרות עד 100 מילות טריגר",
"alreadyExists": "מילת טריגר זו כבר קיימת", "alreadyExists": "מילת טריגר זו כבר קיימת",
"updateSuccess": "מילות הטריגר עודכנו בהצלחה", "updateSuccess": "מילות הטריגר עודכנו בהצלחה",
"updateFailed": "עדכון מילות הטריגר נכשל", "updateFailed": "עדכון מילות הטריגר נכשל",
@@ -1882,7 +1910,9 @@
"repairSuccess": "בניית המטמון מחדש הושלמה.", "repairSuccess": "בניית המטמון מחדש הושלמה.",
"repairFailed": "בניית המטמון מחדש נכשלה: {message}", "repairFailed": "בניית המטמון מחדש נכשלה: {message}",
"exportSuccess": "חבילת האבחון יוצאה.", "exportSuccess": "חבילת האבחון יוצאה.",
"exportFailed": "ייצוא חבילת האבחון נכשל: {message}" "exportFailed": "ייצוא חבילת האבחון נכשל: {message}",
"conflictsResolved": "נפתרו {count} התנגשויות בשמות קבצים.",
"conflictsResolveFailed": "פתרון התנגשויות שמות קבצים נכשל: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "設定", "settings": "設定",
"help": "ヘルプ", "help": "ヘルプ",
"add": "追加", "add": "追加",
"close": "閉じる" "close": "閉じる",
"menu": "メニュー"
}, },
"status": { "status": {
"loading": "読み込み中...", "loading": "読み込み中...",
@@ -276,6 +277,7 @@
"help": "aria2c 実行ファイルへの任意のパスです。空欄のままにすると、システム PATH 上の aria2c を使用します。", "help": "aria2c 実行ファイルへの任意のパスです。空欄のままにすると、システム PATH 上の aria2c を使用します。",
"placeholder": "空欄のままにすると PATH 上の aria2c を使用します" "placeholder": "空欄のままにすると PATH 上の aria2c を使用します"
}, },
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "Civitai ホスト設定を利用できます", "title": "Civitai ホスト設定を利用できます",
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。", "content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
@@ -427,6 +429,8 @@
"hover": "ホバー時に表示" "hover": "ホバー時に表示"
}, },
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択", "cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
"showVersionOnCard": "カードにバージョンを表示",
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
"modelCardFooterAction": "モデルカードボタンのアクション", "modelCardFooterAction": "モデルカードボタンのアクション",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "例画像を開く", "exampleImages": "例画像を開く",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください", "downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください",
"autoDownload": "例画像の自動ダウンロード", "autoDownload": "例画像の自動ダウンロード",
"autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)", "autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)",
"openMode": "サンプル画像を開く動作",
"openModeHelp": "サーバー上で開くか、対応するローカルパスをコピーするか、カスタム URI を起動するかを選択します。",
"openModeOptions": {
"system": "サーバー上で開く",
"clipboard": "ローカルパスをコピー",
"uriTemplate": "カスタム URI を開く"
},
"localRoot": "ローカルのサンプル画像ルート",
"localRootHelp": "サーバーのサンプル画像ディレクトリを反映する任意のローカルまたはマウント済みルートです。空欄の場合はサーバーのパスを再利用します。",
"localRootPlaceholder": "例: /Volumes/ComfyUI/example_images",
"uriTemplate": "URI テンプレートを開く",
"uriTemplateHelp": "ファイル URI や Shortcuts リンクなどのカスタムディープリンクを使用します。",
"uriTemplatePlaceholder": "例: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "使用可能なプレースホルダー: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "リモートオープンモードの詳細",
"optimizeImages": "ダウンロード画像の最適化", "optimizeImages": "ダウンロード画像の最適化",
"optimizeImagesHelp": "例画像を最適化してファイルサイズを縮小し、読み込み速度を向上させます(メタデータは保持されます)", "optimizeImagesHelp": "例画像を最適化してファイルサイズを縮小し、読み込み速度を向上させます(メタデータは保持されます)",
"download": "ダウンロード", "download": "ダウンロード",
@@ -668,7 +687,7 @@
"autoOrganize": "自動整理を実行", "autoOrganize": "自動整理を実行",
"skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ", "skipMetadataRefresh": "選択したモデルのメタデータ更新をスキップ",
"resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開", "resumeMetadataRefresh": "選択したモデルのメタデータ更新を再開",
"deleteAll": "すべてのモデルを削除", "deleteAll": "選択したものを削除",
"downloadMissingLoras": "不足している LoRA をダウンロード", "downloadMissingLoras": "不足している LoRA をダウンロード",
"clear": "選択をクリア", "clear": "選択をクリア",
"skipMetadataRefreshCount": "スキップ({count}モデル)", "skipMetadataRefreshCount": "スキップ({count}モデル)",
@@ -1190,6 +1209,8 @@
"cancel": "編集をキャンセル", "cancel": "編集をキャンセル",
"save": "変更を保存", "save": "変更を保存",
"addPlaceholder": "入力して追加するか、下の提案をクリック", "addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー", "copyWord": "トリガーワードをコピー",
"deleteWord": "トリガーワードを削除", "deleteWord": "トリガーワードを削除",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "早期アクセス", "earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です", "earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"ignored": "無視中", "ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です" "ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
}, },
"actions": { "actions": {
"download": "ダウンロード", "download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード", "downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除", "delete": "削除",
"deleteTooltip": "このローカルバージョンを削除", "deleteTooltip": "このローカルバージョンを削除",
"ignore": "無視", "ignore": "無視",
@@ -1440,6 +1464,10 @@
"opened": "例画像フォルダが開かれました", "opened": "例画像フォルダが開かれました",
"openingFolder": "例画像フォルダを開いています", "openingFolder": "例画像フォルダを開いています",
"failedToOpen": "例画像フォルダを開くのに失敗しました", "failedToOpen": "例画像フォルダを開くのに失敗しました",
"copiedPath": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}",
"copiedUri": "リンクをクリップボードにコピーしました: {{uri}}",
"uriClipboardFallback": "リンク: {{uri}}",
"setupRequired": "例画像ストレージ", "setupRequired": "例画像ストレージ",
"setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。", "setupDescription": "カスタム例画像を追加するには、まずダウンロード場所を設定する必要があります。",
"setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。", "setupUsage": "このパスは、ダウンロードした例画像とカスタム画像の両方に使用されます。",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "学習済みワードを読み込めませんでした", "loadFailed": "学習済みワードを読み込めませんでした",
"tooLong": "トリガーワードは100ワードを超えてはいけません", "tooLong": "トリガーワードは500ワードを超えてはいけません",
"tooMany": "最大30トリガーワードまで許可されています", "tooMany": "最大100トリガーワードまで許可されています",
"alreadyExists": "このトリガーワードは既に存在します", "alreadyExists": "このトリガーワードは既に存在します",
"updateSuccess": "トリガーワードが正常に更新されました", "updateSuccess": "トリガーワードが正常に更新されました",
"updateFailed": "トリガーワードの更新に失敗しました", "updateFailed": "トリガーワードの更新に失敗しました",
@@ -1882,7 +1910,9 @@
"repairSuccess": "キャッシュの再構築が完了しました。", "repairSuccess": "キャッシュの再構築が完了しました。",
"repairFailed": "キャッシュの再構築に失敗しました: {message}", "repairFailed": "キャッシュの再構築に失敗しました: {message}",
"exportSuccess": "診断パッケージをエクスポートしました。", "exportSuccess": "診断パッケージをエクスポートしました。",
"exportFailed": "診断パッケージのエクスポートに失敗しました: {message}" "exportFailed": "診断パッケージのエクスポートに失敗しました: {message}",
"conflictsResolved": "{count} 件のファイル名競合が解決されました。",
"conflictsResolveFailed": "ファイル名競合の解決に失敗しました: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "설정", "settings": "설정",
"help": "도움말", "help": "도움말",
"add": "추가", "add": "추가",
"close": "닫기" "close": "닫기",
"menu": "메뉴"
}, },
"status": { "status": {
"loading": "로딩 중...", "loading": "로딩 중...",
@@ -276,6 +277,7 @@
"help": "aria2c 실행 파일의 선택적 경로입니다. 비워 두면 시스템 PATH의 aria2c를 사용합니다.", "help": "aria2c 실행 파일의 선택적 경로입니다. 비워 두면 시스템 PATH의 aria2c를 사용합니다.",
"placeholder": "비워 두면 PATH의 aria2c를 사용합니다" "placeholder": "비워 두면 PATH의 aria2c를 사용합니다"
}, },
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "Civitai 호스트 기본 설정 사용 가능", "title": "Civitai 호스트 기본 설정 사용 가능",
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.", "content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
@@ -427,6 +429,8 @@
"hover": "호버 시 표시" "hover": "호버 시 표시"
}, },
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요", "cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
"showVersionOnCard": "카드에 버전 표시",
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
"modelCardFooterAction": "모델 카드 버튼 동작", "modelCardFooterAction": "모델 카드 버튼 동작",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "예시 이미지 열기", "exampleImages": "예시 이미지 열기",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Civitai의 예시 이미지가 저장될 폴더 경로를 입력하세요", "downloadLocationHelp": "Civitai의 예시 이미지가 저장될 폴더 경로를 입력하세요",
"autoDownload": "예시 이미지 자동 다운로드", "autoDownload": "예시 이미지 자동 다운로드",
"autoDownloadHelp": "예시 이미지가 없는 모델의 예시 이미지를 자동으로 다운로드합니다 (다운로드 위치 설정 필요)", "autoDownloadHelp": "예시 이미지가 없는 모델의 예시 이미지를 자동으로 다운로드합니다 (다운로드 위치 설정 필요)",
"openMode": "예시 이미지 열기 동작",
"openModeHelp": "서버에서 열지, 매핑된 로컬 경로를 복사할지, 사용자 지정 URI를 실행할지 선택합니다.",
"openModeOptions": {
"system": "서버에서 열기",
"clipboard": "로컬 경로 복사",
"uriTemplate": "사용자 지정 URI 열기"
},
"localRoot": "로컬 예시 이미지 루트",
"localRootHelp": "서버 예시 이미지 디렉터리를 반영하는 선택적 로컬 또는 마운트된 루트입니다. 비워 두면 서버 경로를 재사용합니다.",
"localRootPlaceholder": "예: /Volumes/ComfyUI/example_images",
"uriTemplate": "URI 템플릿 열기",
"uriTemplateHelp": "파일 URI 또는 Shortcuts 링크 같은 사용자 지정 딥링크를 사용합니다.",
"uriTemplatePlaceholder": "예: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "사용 가능한 플레이스홀더: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "원격 열기 모드에 대해 자세히 알아보기",
"optimizeImages": "다운로드된 이미지 최적화", "optimizeImages": "다운로드된 이미지 최적화",
"optimizeImagesHelp": "파일 크기를 줄이고 로딩 속도를 향상시키기 위해 예시 이미지를 최적화합니다 (메타데이터는 보존됨)", "optimizeImagesHelp": "파일 크기를 줄이고 로딩 속도를 향상시키기 위해 예시 이미지를 최적화합니다 (메타데이터는 보존됨)",
"download": "다운로드", "download": "다운로드",
@@ -668,7 +687,7 @@
"autoOrganize": "자동 정리 선택", "autoOrganize": "자동 정리 선택",
"skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기", "skipMetadataRefresh": "선택한 모델의 메타데이터 새로고침 건너뛰기",
"resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개", "resumeMetadataRefresh": "선택한 모델의 메타데이터 새로고침 재개",
"deleteAll": "모든 모델 삭제", "deleteAll": "선택된 항목 삭제",
"downloadMissingLoras": "누락된 LoRA 다운로드", "downloadMissingLoras": "누락된 LoRA 다운로드",
"clear": "선택 지우기", "clear": "선택 지우기",
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)", "skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
@@ -1190,6 +1209,8 @@
"cancel": "편집 취소", "cancel": "편집 취소",
"save": "변경사항 저장", "save": "변경사항 저장",
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요", "addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사", "copyWord": "트리거 단어 복사",
"deleteWord": "트리거 단어 삭제", "deleteWord": "트리거 단어 삭제",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다", "earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"ignored": "무시됨", "ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다" "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
}, },
"actions": { "actions": {
"download": "다운로드", "download": "다운로드",
"downloadTooltip": "이 버전 다운로드", "downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제", "delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제", "deleteTooltip": "이 로컬 버전 삭제",
"ignore": "무시", "ignore": "무시",
@@ -1440,6 +1464,10 @@
"opened": "예시 이미지 폴더가 열렸습니다", "opened": "예시 이미지 폴더가 열렸습니다",
"openingFolder": "예시 이미지 폴더를 여는 중", "openingFolder": "예시 이미지 폴더를 여는 중",
"failedToOpen": "예시 이미지 폴더 열기 실패", "failedToOpen": "예시 이미지 폴더 열기 실패",
"copiedPath": "경로를 클립보드에 복사했습니다: {{path}}",
"clipboardFallback": "경로: {{path}}",
"copiedUri": "링크를 클립보드에 복사했습니다: {{uri}}",
"uriClipboardFallback": "링크: {{uri}}",
"setupRequired": "예시 이미지 저장소", "setupRequired": "예시 이미지 저장소",
"setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.", "setupDescription": "사용자 지정 예시 이미지를 추가하려면 먼저 다운로드 위치를 설정해야 합니다.",
"setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.", "setupUsage": "이 경로는 다운로드한 예시 이미지와 사용자 지정 이미지 모두에 사용됩니다.",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "학습된 단어를 로딩할 수 없습니다", "loadFailed": "학습된 단어를 로딩할 수 없습니다",
"tooLong": "트리거 단어는 100단어를 초과할 수 없습니다", "tooLong": "트리거 단어는 500단어를 초과할 수 없습니다",
"tooMany": "최대 30개의 트리거 단어만 허용됩니다", "tooMany": "최대 100개의 트리거 단어만 허용됩니다",
"alreadyExists": "이 트리거 단어는 이미 존재합니다", "alreadyExists": "이 트리거 단어는 이미 존재합니다",
"updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다", "updateSuccess": "트리거 단어가 성공적으로 업데이트되었습니다",
"updateFailed": "트리거 단어 업데이트에 실패했습니다", "updateFailed": "트리거 단어 업데이트에 실패했습니다",
@@ -1882,7 +1910,9 @@
"repairSuccess": "캐시 재구성이 완료되었습니다.", "repairSuccess": "캐시 재구성이 완료되었습니다.",
"repairFailed": "캐시 재구성 실패: {message}", "repairFailed": "캐시 재구성 실패: {message}",
"exportSuccess": "진단 번들이 내보내졌습니다.", "exportSuccess": "진단 번들이 내보내졌습니다.",
"exportFailed": "진단 번들 내보내기 실패: {message}" "exportFailed": "진단 번들 내보내기 실패: {message}",
"conflictsResolved": "{count}개 파일명 충돌이 해결되었습니다.",
"conflictsResolveFailed": "파일명 충돌 해결 실패: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "Настройки", "settings": "Настройки",
"help": "Справка", "help": "Справка",
"add": "Добавить", "add": "Добавить",
"close": "Закрыть" "close": "Закрыть",
"menu": "Меню"
}, },
"status": { "status": {
"loading": "Загрузка...", "loading": "Загрузка...",
@@ -276,6 +277,7 @@
"help": "Необязательный путь к исполняемому файлу aria2c. Оставьте пустым, чтобы использовать aria2c из системного PATH.", "help": "Необязательный путь к исполняемому файлу aria2c. Оставьте пустым, чтобы использовать aria2c из системного PATH.",
"placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH" "placeholder": "Оставьте пустым, чтобы использовать aria2c из PATH"
}, },
"aria2HelpLink": "Узнайте, как настроить сервер загрузки aria2",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "Доступна настройка хоста Civitai", "title": "Доступна настройка хоста Civitai",
"content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.", "content": "Теперь Civitai использует civitai.com для контента SFW и civitai.red для контента без ограничений. В настройках можно изменить, какой сайт открывать по умолчанию.",
@@ -427,6 +429,8 @@
"hover": "Показать при наведении" "hover": "Показать при наведении"
}, },
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий", "cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
"showVersionOnCard": "Показывать версию на карточке",
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
"modelCardFooterAction": "Действие кнопки карточки модели", "modelCardFooterAction": "Действие кнопки карточки модели",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "Открыть примеры изображений", "exampleImages": "Открыть примеры изображений",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "Введите путь к папке, где будут сохраняться примеры изображений с Civitai", "downloadLocationHelp": "Введите путь к папке, где будут сохраняться примеры изображений с Civitai",
"autoDownload": "Автозагрузка примеров изображений", "autoDownload": "Автозагрузка примеров изображений",
"autoDownloadHelp": "Автоматически загружать примеры изображений для моделей, у которых их нет (требует настройки места загрузки)", "autoDownloadHelp": "Автоматически загружать примеры изображений для моделей, у которых их нет (требует настройки места загрузки)",
"openMode": "Действие открытия примеров изображений",
"openModeHelp": "Выберите, будет ли действие открывать папку на сервере, копировать сопоставленный локальный путь или запускать пользовательский URI.",
"openModeOptions": {
"system": "Открыть на сервере",
"clipboard": "Скопировать локальный путь",
"uriTemplate": "Открыть пользовательский URI"
},
"localRoot": "Локальный корень примеров изображений",
"localRootHelp": "Необязательный локальный или смонтированный корневой путь, отражающий каталог примеров изображений на сервере. Если оставить пустым, будет использован путь сервера.",
"localRootPlaceholder": "Пример: /Volumes/ComfyUI/example_images",
"uriTemplate": "Шаблон URI для открытия",
"uriTemplateHelp": "Используйте пользовательскую deep link-ссылку, например file URI или ссылку Shortcuts.",
"uriTemplatePlaceholder": "Пример: shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "Доступные плейсхолдеры: {{local_path}}, {{encoded_local_path}}, {{relative_path}}, {{encoded_relative_path}}, {{file_uri}}, {{encoded_file_uri}}",
"openModeWikiLink": "Подробнее об удаленных режимах открытия",
"optimizeImages": "Оптимизировать загруженные изображения", "optimizeImages": "Оптимизировать загруженные изображения",
"optimizeImagesHelp": "Оптимизировать примеры изображений для уменьшения размера файла и улучшения скорости загрузки (метаданные будут сохранены)", "optimizeImagesHelp": "Оптимизировать примеры изображений для уменьшения размера файла и улучшения скорости загрузки (метаданные будут сохранены)",
"download": "Загрузить", "download": "Загрузить",
@@ -668,7 +687,7 @@
"autoOrganize": "Автоматически организовать выбранные", "autoOrganize": "Автоматически организовать выбранные",
"skipMetadataRefresh": "Пропустить обновление метаданных для выбранных", "skipMetadataRefresh": "Пропустить обновление метаданных для выбранных",
"resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных", "resumeMetadataRefresh": "Возобновить обновление метаданных для выбранных",
"deleteAll": "Удалить все модели", "deleteAll": "Удалить выбранные",
"downloadMissingLoras": "Скачать отсутствующие LoRAs", "downloadMissingLoras": "Скачать отсутствующие LoRAs",
"clear": "Очистить выбор", "clear": "Очистить выбор",
"skipMetadataRefreshCount": "Пропустить({count} моделей)", "skipMetadataRefreshCount": "Пропустить({count} моделей)",
@@ -1190,6 +1209,8 @@
"cancel": "Отменить редактирование", "cancel": "Отменить редактирование",
"save": "Сохранить изменения", "save": "Сохранить изменения",
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже", "addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово", "copyWord": "Копировать триггерное слово",
"deleteWord": "Удалить триггерное слово", "deleteWord": "Удалить триггерное слово",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai", "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"ignored": "Игнорируется", "ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены" "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
"onSiteOnlyTooltip": "Эта версия доступна только для генерации на сайте Civitai"
}, },
"actions": { "actions": {
"download": "Скачать", "download": "Скачать",
"downloadTooltip": "Скачать эту версию", "downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить", "delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию", "deleteTooltip": "Удалить эту локальную версию",
"ignore": "Игнорировать", "ignore": "Игнорировать",
@@ -1440,6 +1464,10 @@
"opened": "Папка с примерами изображений открыта", "opened": "Папка с примерами изображений открыта",
"openingFolder": "Открытие папки с примерами изображений", "openingFolder": "Открытие папки с примерами изображений",
"failedToOpen": "Не удалось открыть папку с примерами изображений", "failedToOpen": "Не удалось открыть папку с примерами изображений",
"copiedPath": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}",
"copiedUri": "Ссылка скопирована в буфер обмена: {{uri}}",
"uriClipboardFallback": "Ссылка: {{uri}}",
"setupRequired": "Хранилище примеров изображений", "setupRequired": "Хранилище примеров изображений",
"setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.", "setupDescription": "Чтобы добавить собственные примеры изображений, сначала нужно установить место загрузки.",
"setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.", "setupUsage": "Этот путь используется как для загруженных, так и для пользовательских примеров изображений.",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "Не удалось загрузить обученные слова", "loadFailed": "Не удалось загрузить обученные слова",
"tooLong": "Триггерное слово не должно превышать 100 слов", "tooLong": "Триггерное слово не должно превышать 500 слов",
"tooMany": "Максимум 30 триггерных слов разрешено", "tooMany": "Максимум 100 триггерных слов разрешено",
"alreadyExists": "Это триггерное слово уже существует", "alreadyExists": "Это триггерное слово уже существует",
"updateSuccess": "Триггерные слова успешно обновлены", "updateSuccess": "Триггерные слова успешно обновлены",
"updateFailed": "Не удалось обновить триггерные слова", "updateFailed": "Не удалось обновить триггерные слова",
@@ -1882,7 +1910,9 @@
"repairSuccess": "Перестройка кэша завершена.", "repairSuccess": "Перестройка кэша завершена.",
"repairFailed": "Не удалось перестроить кэш: {message}", "repairFailed": "Не удалось перестроить кэш: {message}",
"exportSuccess": "Диагностический пакет экспортирован.", "exportSuccess": "Диагностический пакет экспортирован.",
"exportFailed": "Не удалось экспортировать диагностический пакет: {message}" "exportFailed": "Не удалось экспортировать диагностический пакет: {message}",
"conflictsResolved": "Разрешено конфликтов имён файлов: {count}.",
"conflictsResolveFailed": "Не удалось разрешить конфликты имён файлов: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "设置", "settings": "设置",
"help": "帮助", "help": "帮助",
"add": "添加", "add": "添加",
"close": "关闭" "close": "关闭",
"menu": "菜单"
}, },
"status": { "status": {
"loading": "加载中...", "loading": "加载中...",
@@ -276,6 +277,7 @@
"help": "可选的 aria2c 可执行文件路径。留空则使用系统 PATH 中的 aria2c。", "help": "可选的 aria2c 可执行文件路径。留空则使用系统 PATH 中的 aria2c。",
"placeholder": "留空则使用 PATH 中的 aria2c" "placeholder": "留空则使用 PATH 中的 aria2c"
}, },
"aria2HelpLink": "了解如何配置 aria2 下载后端",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "已提供 Civitai 站点偏好设置", "title": "已提供 Civitai 站点偏好设置",
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。", "content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
@@ -427,6 +429,8 @@
"hover": "悬停时显示" "hover": "悬停时显示"
}, },
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮", "cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
"showVersionOnCard": "在卡片上显示版本",
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
"modelCardFooterAction": "模型卡片按钮操作", "modelCardFooterAction": "模型卡片按钮操作",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "打开示例图片", "exampleImages": "打开示例图片",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径", "downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径",
"autoDownload": "自动下载示例图片", "autoDownload": "自动下载示例图片",
"autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)", "autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)",
"openMode": "打开示例图片操作",
"openModeHelp": "选择是在服务器上打开、复制映射后的本地路径,还是启动自定义 URI。",
"openModeOptions": {
"system": "在服务器上打开",
"clipboard": "复制本地路径",
"uriTemplate": "打开自定义 URI"
},
"localRoot": "本地示例图片根目录",
"localRootHelp": "可选的本地或挂载根目录,用于映射服务器上的示例图片目录。若留空,则复用服务器路径。",
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
"uriTemplate": "打开 URI 模板",
"uriTemplateHelp": "使用自定义深链接,例如文件 URI 或 Shortcuts 链接。",
"uriTemplatePlaceholder": "例如shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "可用占位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
"openModeWikiLink": "了解远程打开模式",
"optimizeImages": "优化下载图片", "optimizeImages": "优化下载图片",
"optimizeImagesHelp": "优化示例图片以减少文件大小并提升加载速度(保留元数据)", "optimizeImagesHelp": "优化示例图片以减少文件大小并提升加载速度(保留元数据)",
"download": "下载", "download": "下载",
@@ -668,7 +687,7 @@
"autoOrganize": "自动整理所选模型", "autoOrganize": "自动整理所选模型",
"skipMetadataRefresh": "跳过所选模型的元数据刷新", "skipMetadataRefresh": "跳过所选模型的元数据刷新",
"resumeMetadataRefresh": "恢复所选模型的元数据刷新", "resumeMetadataRefresh": "恢复所选模型的元数据刷新",
"deleteAll": "删除选中模型", "deleteAll": "删除选",
"downloadMissingLoras": "下载缺失的 LoRAs", "downloadMissingLoras": "下载缺失的 LoRAs",
"clear": "清除选择", "clear": "清除选择",
"skipMetadataRefreshCount": "跳过({count} 个模型)", "skipMetadataRefreshCount": "跳过({count} 个模型)",
@@ -1190,6 +1209,8 @@
"cancel": "取消编辑", "cancel": "取消编辑",
"save": "保存更改", "save": "保存更改",
"addPlaceholder": "输入或点击下方建议添加", "addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词", "copyWord": "复制触发词",
"deleteWord": "删除触发词", "deleteWord": "删除触发词",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "抢先体验", "earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限", "earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知" "ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
}, },
"actions": { "actions": {
"download": "下载", "download": "下载",
"downloadTooltip": "下载此版本", "downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除", "delete": "删除",
"deleteTooltip": "删除此本地版本", "deleteTooltip": "删除此本地版本",
"ignore": "忽略", "ignore": "忽略",
@@ -1440,6 +1464,10 @@
"opened": "示例图片文件夹已打开", "opened": "示例图片文件夹已打开",
"openingFolder": "正在打开示例图片文件夹", "openingFolder": "正在打开示例图片文件夹",
"failedToOpen": "打开示例图片文件夹失败", "failedToOpen": "打开示例图片文件夹失败",
"copiedPath": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}",
"copiedUri": "链接已复制到剪贴板:{{uri}}",
"uriClipboardFallback": "链接:{{uri}}",
"setupRequired": "示例图片存储", "setupRequired": "示例图片存储",
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。", "setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。", "setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "无法加载训练词", "loadFailed": "无法加载训练词",
"tooLong": "触发词不能超过100个词", "tooLong": "触发词不能超过500个词",
"tooMany": "最多允许30个触发词", "tooMany": "最多允许100个触发词",
"alreadyExists": "该触发词已存在", "alreadyExists": "该触发词已存在",
"updateSuccess": "触发词更新成功", "updateSuccess": "触发词更新成功",
"updateFailed": "触发词更新失败", "updateFailed": "触发词更新失败",
@@ -1882,7 +1910,9 @@
"repairSuccess": "缓存重建完成。", "repairSuccess": "缓存重建完成。",
"repairFailed": "缓存重建失败:{message}", "repairFailed": "缓存重建失败:{message}",
"exportSuccess": "诊断包已导出。", "exportSuccess": "诊断包已导出。",
"exportFailed": "导出诊断包失败:{message}" "exportFailed": "导出诊断包失败:{message}",
"conflictsResolved": "已解决 {count} 个文件名冲突。",
"conflictsResolveFailed": "解决文件名冲突失败:{message}"
} }
}, },
"banners": { "banners": {

View File

@@ -15,7 +15,8 @@
"settings": "設定", "settings": "設定",
"help": "說明", "help": "說明",
"add": "新增", "add": "新增",
"close": "關閉" "close": "關閉",
"menu": "選單"
}, },
"status": { "status": {
"loading": "載入中...", "loading": "載入中...",
@@ -276,6 +277,7 @@
"help": "可選的 aria2c 可執行檔路徑。留空則使用系統 PATH 中的 aria2c。", "help": "可選的 aria2c 可執行檔路徑。留空則使用系統 PATH 中的 aria2c。",
"placeholder": "留空則使用 PATH 中的 aria2c" "placeholder": "留空則使用 PATH 中的 aria2c"
}, },
"aria2HelpLink": "了解如何設定 aria2 下載後端",
"civitaiHostBanner": { "civitaiHostBanner": {
"title": "已提供 Civitai 站點偏好設定", "title": "已提供 Civitai 站點偏好設定",
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。", "content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
@@ -427,6 +429,8 @@
"hover": "滑鼠懸停顯示" "hover": "滑鼠懸停顯示"
}, },
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕", "cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
"showVersionOnCard": "在卡片上顯示版本",
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
"modelCardFooterAction": "模型卡片按鈕操作", "modelCardFooterAction": "模型卡片按鈕操作",
"modelCardFooterActionOptions": { "modelCardFooterActionOptions": {
"exampleImages": "開啟範例圖片", "exampleImages": "開啟範例圖片",
@@ -538,6 +542,21 @@
"downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑", "downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑",
"autoDownload": "自動下載範例圖片", "autoDownload": "自動下載範例圖片",
"autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)", "autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)",
"openMode": "開啟範例圖片動作",
"openModeHelp": "選擇是在伺服器上開啟、複製對應的本機路徑,或啟動自訂 URI。",
"openModeOptions": {
"system": "在伺服器上開啟",
"clipboard": "複製本機路徑",
"uriTemplate": "開啟自訂 URI"
},
"localRoot": "本機範例圖片根目錄",
"localRootHelp": "可選的本機或掛載根目錄,用於對應伺服器上的範例圖片目錄。若留白,則會重用伺服器路徑。",
"localRootPlaceholder": "例如:/Volumes/ComfyUI/example_images",
"uriTemplate": "開啟 URI 範本",
"uriTemplateHelp": "使用自訂深層連結,例如檔案 URI 或 Shortcuts 連結。",
"uriTemplatePlaceholder": "例如shortcuts://run-shortcut?name=Open%20Finder&input=text&text={{encoded_local_path}}",
"uriTemplatePlaceholders": "可用佔位符:{{local_path}}、{{encoded_local_path}}、{{relative_path}}、{{encoded_relative_path}}、{{file_uri}}、{{encoded_file_uri}}",
"openModeWikiLink": "了解遠端開啟模式",
"optimizeImages": "最佳化下載圖片", "optimizeImages": "最佳化下載圖片",
"optimizeImagesHelp": "最佳化範例圖片以減少檔案大小並提升載入速度(會保留原有的 metadata", "optimizeImagesHelp": "最佳化範例圖片以減少檔案大小並提升載入速度(會保留原有的 metadata",
"download": "下載", "download": "下載",
@@ -668,7 +687,7 @@
"autoOrganize": "自動整理所選模型", "autoOrganize": "自動整理所選模型",
"skipMetadataRefresh": "跳過所選模型的元數據更新", "skipMetadataRefresh": "跳過所選模型的元數據更新",
"resumeMetadataRefresh": "恢復所選模型的元數據更新", "resumeMetadataRefresh": "恢復所選模型的元數據更新",
"deleteAll": "刪除全部模型", "deleteAll": "刪除所選",
"downloadMissingLoras": "下載缺失的 LoRAs", "downloadMissingLoras": "下載缺失的 LoRAs",
"clear": "清除選取", "clear": "清除選取",
"skipMetadataRefreshCount": "跳過({count} 個模型)", "skipMetadataRefreshCount": "跳過({count} 個模型)",
@@ -1190,6 +1209,8 @@
"cancel": "取消編輯", "cancel": "取消編輯",
"save": "儲存變更", "save": "儲存變更",
"addPlaceholder": "輸入或點擊下方建議", "addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞", "copyWord": "複製觸發詞",
"deleteWord": "刪除觸發詞", "deleteWord": "刪除觸發詞",
"suggestions": { "suggestions": {
@@ -1272,12 +1293,15 @@
"earlyAccess": "搶先體驗", "earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限", "earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知" "ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
}, },
"actions": { "actions": {
"download": "下載", "download": "下載",
"downloadTooltip": "下載此版本", "downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除", "delete": "刪除",
"deleteTooltip": "刪除此本地版本", "deleteTooltip": "刪除此本地版本",
"ignore": "忽略", "ignore": "忽略",
@@ -1440,6 +1464,10 @@
"opened": "範例圖片資料夾已開啟", "opened": "範例圖片資料夾已開啟",
"openingFolder": "正在開啟範例圖片資料夾", "openingFolder": "正在開啟範例圖片資料夾",
"failedToOpen": "開啟範例圖片資料夾失敗", "failedToOpen": "開啟範例圖片資料夾失敗",
"copiedPath": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}",
"copiedUri": "連結已複製到剪貼簿:{{uri}}",
"uriClipboardFallback": "連結:{{uri}}",
"setupRequired": "範例圖片儲存", "setupRequired": "範例圖片儲存",
"setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。", "setupDescription": "要新增自訂範例圖片,您需要先設定下載位置。",
"setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。", "setupUsage": "此路徑用於儲存下載的範例圖片和自訂圖片。",
@@ -1761,8 +1789,8 @@
}, },
"triggerWords": { "triggerWords": {
"loadFailed": "無法載入訓練詞", "loadFailed": "無法載入訓練詞",
"tooLong": "觸發詞不可超過 100 個字", "tooLong": "觸發詞不可超過 500 個字",
"tooMany": "最多允許 30 個觸發詞", "tooMany": "最多允許 100 個觸發詞",
"alreadyExists": "此觸發詞已存在", "alreadyExists": "此觸發詞已存在",
"updateSuccess": "觸發詞已更新", "updateSuccess": "觸發詞已更新",
"updateFailed": "更新觸發詞失敗", "updateFailed": "更新觸發詞失敗",
@@ -1882,7 +1910,9 @@
"repairSuccess": "快取重建完成。", "repairSuccess": "快取重建完成。",
"repairFailed": "快取重建失敗:{message}", "repairFailed": "快取重建失敗:{message}",
"exportSuccess": "診斷套件已匯出。", "exportSuccess": "診斷套件已匯出。",
"exportFailed": "匯出診斷套件失敗:{message}" "exportFailed": "匯出診斷套件失敗:{message}",
"conflictsResolved": "已解決 {count} 個檔案名稱衝突。",
"conflictsResolveFailed": "解決檔案名稱衝突失敗:{message}"
} }
}, },
"banners": { "banners": {

View File

@@ -1,5 +1,6 @@
import os import os
import platform import platform
import posixpath
import threading import threading
from pathlib import Path from pathlib import Path
import folder_paths # type: ignore import folder_paths # type: ignore
@@ -25,21 +26,57 @@ standalone_mode = (
logger = logging.getLogger(__name__) 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( 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: ) -> 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()] valid_paths = [path for path in primary_paths if isinstance(path, str) and path.strip()]
if not valid_paths: fallback_paths: List[str] = []
return "" 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 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: if current:
logger.info( 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, name,
current, current,
valid_paths[0], valid_paths[0],
@@ -226,39 +263,76 @@ class Config:
default_lora_root = _resolve_valid_default_root( default_lora_root = _resolve_valid_default_root(
comfy_library.get("default_lora_root", ""), comfy_library.get("default_lora_root", ""),
list(self.loras_roots or []), list(self.loras_roots or []),
list(self.loras_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("loras", []) or []),
"default_lora_root", "default_lora_root",
) )
default_checkpoint_root = _resolve_valid_default_root( default_checkpoint_root = _resolve_valid_default_root(
comfy_library.get("default_checkpoint_root", ""), comfy_library.get("default_checkpoint_root", ""),
list(self.checkpoints_roots or []), list(self.checkpoints_roots or []),
list(self.checkpoints_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("checkpoints", []) or []),
"default_checkpoint_root", "default_checkpoint_root",
) )
default_embedding_root = _resolve_valid_default_root( default_embedding_root = _resolve_valid_default_root(
comfy_library.get("default_embedding_root", ""), comfy_library.get("default_embedding_root", ""),
list(self.embeddings_roots or []), list(self.embeddings_roots or []),
list(self.embeddings_roots or [])
+ list(comfy_library.get("extra_folder_paths", {}).get("embeddings", []) or []),
"default_embedding_root", "default_embedding_root",
) )
metadata = dict(comfy_library.get("metadata", {})) metadata = dict(comfy_library.get("metadata", {}))
metadata.setdefault("display_name", "ComfyUI") metadata.setdefault("display_name", "ComfyUI")
metadata["source"] = "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( settings_service.upsert_library(
"comfyui", "comfyui",
folder_paths=target_folder_paths, folder_paths=target_folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root, default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root, default_checkpoint_root=default_checkpoint_root,
default_embedding_root=default_embedding_root, default_embedding_root=default_embedding_root,
metadata=metadata, 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: except Exception as e:
logger.warning(f"Failed to save folder paths: {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: def _is_link(self, path: str) -> bool:
try: try:
if os.path.islink(path): if os.path.islink(path):

View File

@@ -352,50 +352,101 @@ class MetadataProcessor:
# Check if we have stored conditioning objects for this sampler # Check if we have stored conditioning objects for this sampler
if sampler_id in metadata.get(PROMPTS, {}) and ( if sampler_id in metadata.get(PROMPTS, {}) and (
"pos_conditioning" in metadata[PROMPTS][sampler_id] or "pos_conditioning" in metadata[PROMPTS][sampler_id] or
"neg_conditioning" in metadata[PROMPTS][sampler_id]): "neg_conditioning" in metadata[PROMPTS][sampler_id]
):
pos_conditioning = metadata[PROMPTS][sampler_id].get("pos_conditioning") pos_conditioning = metadata[PROMPTS][sampler_id].get("pos_conditioning")
neg_conditioning = metadata[PROMPTS][sampler_id].get("neg_conditioning") neg_conditioning = metadata[PROMPTS][sampler_id].get("neg_conditioning")
# Helper function to recursively find prompt text for a conditioning object def extend_unique(target, values):
def find_prompt_text_for_conditioning(conditioning_obj, is_positive=True): for value in values:
if value and value not in target:
target.append(value)
# Helper function to recursively find prompt texts for a conditioning object.
# Transform nodes can map one output conditioning to multiple source conditionings.
def find_prompt_texts_for_conditioning(
conditioning_obj, is_positive=True, visited=None
):
if conditioning_obj is None: if conditioning_obj is None:
return "" return []
if visited is None:
visited = set()
conditioning_id = id(conditioning_obj)
if conditioning_id in visited:
return []
visited.add(conditioning_id)
prompt_texts = []
# Try to match conditioning objects with those stored by extractors # Try to match conditioning objects with those stored by extractors
for prompt_node_id, prompt_data in metadata[PROMPTS].items(): for prompt_node_id, prompt_data in metadata[PROMPTS].items():
# For nodes with single conditioning output if not isinstance(prompt_data, dict):
if "conditioning" in prompt_data: continue
if id(prompt_data["conditioning"]) == id(conditioning_obj):
return prompt_data.get("text", "") # For CLIP text nodes with a single conditioning output.
if id(prompt_data.get("conditioning")) == conditioning_id:
# For nodes with separate pos_conditioning and neg_conditioning outputs (like TSC_EfficientLoader) text = prompt_data.get("text", "")
if is_positive and "positive_encoded" in prompt_data: if text:
if id(prompt_data["positive_encoded"]) == id(conditioning_obj): extend_unique(prompt_texts, [text])
if "positive_text" in prompt_data:
return prompt_data["positive_text"] # Generic provenance for passthrough/transform/combine nodes.
else: for source in prompt_data.get("conditioning_sources", []):
orig_conditioning = prompt_data.get("orig_pos_cond", None) if id(source.get("output")) != conditioning_id:
if orig_conditioning is not None: continue
# Recursively find the prompt text for the original conditioning for input_conditioning in source.get("inputs", []):
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=True) extend_unique(
prompt_texts,
if not is_positive and "negative_encoded" in prompt_data: find_prompt_texts_for_conditioning(
if id(prompt_data["negative_encoded"]) == id(conditioning_obj): input_conditioning, is_positive, visited
if "negative_text" in prompt_data: ),
return prompt_data["negative_text"] )
else:
orig_conditioning = prompt_data.get("orig_neg_cond", None) # For nodes with separate pos_conditioning and neg_conditioning outputs
if orig_conditioning is not None: # like TSC_EfficientLoader and existing ControlNet-style metadata.
# Recursively find the prompt text for the original conditioning if (
return find_prompt_text_for_conditioning(orig_conditioning, is_positive=False) is_positive
and id(prompt_data.get("positive_encoded")) == conditioning_id
return "" ):
if prompt_data.get("positive_text"):
extend_unique(prompt_texts, [prompt_data["positive_text"]])
else:
extend_unique(
prompt_texts,
find_prompt_texts_for_conditioning(
prompt_data.get("orig_pos_cond"),
is_positive=True,
visited=visited,
),
)
if (
not is_positive
and id(prompt_data.get("negative_encoded")) == conditioning_id
):
if prompt_data.get("negative_text"):
extend_unique(prompt_texts, [prompt_data["negative_text"]])
else:
extend_unique(
prompt_texts,
find_prompt_texts_for_conditioning(
prompt_data.get("orig_neg_cond"),
is_positive=False,
visited=visited,
),
)
return prompt_texts
# Find prompt texts using the helper function # Find prompt texts using the helper function
result["prompt"] = find_prompt_text_for_conditioning(pos_conditioning, is_positive=True) result["prompt"] = ", ".join(
result["negative_prompt"] = find_prompt_text_for_conditioning(neg_conditioning, is_positive=False) find_prompt_texts_for_conditioning(pos_conditioning, is_positive=True)
)
result["negative_prompt"] = ", ".join(
find_prompt_texts_for_conditioning(neg_conditioning, is_positive=False)
)
return result return result
@@ -509,8 +560,14 @@ class MetadataProcessor:
params["loras"] = " ".join(lora_parts) params["loras"] = " ".join(lora_parts)
# Set default clip_skip value # Extract clip_skip from any SAMPLING node that provides it
params["clip_skip"] = "1" # Common default for sampler_info in metadata.get(SAMPLING, {}).values():
clip_skip = sampler_info.get("parameters", {}).get("clip_skip")
if clip_skip is not None:
params["clip_skip"] = clip_skip
break
if params["clip_skip"] is None:
params["clip_skip"] = "1"
return params return params

View File

@@ -144,6 +144,118 @@ class TSCCheckpointLoaderExtractor(NodeMetadataExtractor):
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyComfyLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
if "ckpt_name" in inputs:
_store_checkpoint_metadata(metadata, node_id, inputs["ckpt_name"])
# Only extract from optional_lora_stack — skip the single lora_name to
# avoid double-counting LoRAs that come through the LORA_STACK path.
active_loras = []
optional_lora_stack = inputs.get("optional_lora_stack")
if optional_lora_stack is not None and isinstance(optional_lora_stack, (list, tuple)):
for item in optional_lora_stack:
if isinstance(item, (list, tuple)) and len(item) >= 2:
lora_path = item[0]
model_strength = item[1]
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
active_loras.append({
"name": lora_name,
"strength": model_strength
})
if active_loras:
metadata[LORAS][node_id] = {
"lora_list": active_loras,
"node_id": node_id
}
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
metadata[PROMPTS][node_id]["positive_text"] = positive_text
metadata[PROMPTS][node_id]["negative_text"] = negative_text
if "clip_skip" in inputs:
clip_skip = inputs["clip_skip"]
if node_id not in metadata[SAMPLING]:
metadata[SAMPLING][node_id] = {"parameters": {}, "node_id": node_id}
metadata[SAMPLING][node_id]["parameters"]["clip_skip"] = clip_skip
width = inputs.get("empty_latent_width")
height = inputs.get("empty_latent_height")
if width is not None and height is not None:
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": int(width),
"height": int(height),
"node_id": node_id
}
@staticmethod
def update(node_id, outputs, metadata):
# outputs: [(pipe_dict, model, vae), ...]
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
return
first_output = outputs[0]
if not isinstance(first_output, tuple) or len(first_output) < 1:
return
pipe = first_output[0]
if not isinstance(pipe, dict):
return
positive_conditioning = pipe.get("positive")
negative_conditioning = pipe.get("negative")
if positive_conditioning is not None or negative_conditioning is not None:
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
if positive_conditioning is not None:
metadata[PROMPTS][node_id]["positive_encoded"] = positive_conditioning
if negative_conditioning is not None:
metadata[PROMPTS][node_id]["negative_encoded"] = negative_conditioning
class EasyPreSamplingExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
sampling_params = {}
for key in ("steps", "cfg", "sampler_name", "scheduler", "denoise", "seed"):
if key in inputs:
sampling_params[key] = inputs[key]
metadata[SAMPLING][node_id] = {
"parameters": sampling_params,
"node_id": node_id,
IS_SAMPLER: True
}
class EasySeedExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs or "seed" not in inputs:
return
metadata[SAMPLING][node_id] = {
"parameters": {"seed": inputs["seed"]},
"node_id": node_id,
IS_SAMPLER: False
}
class CLIPTextEncodeExtractor(NodeMetadataExtractor): class CLIPTextEncodeExtractor(NodeMetadataExtractor):
@staticmethod @staticmethod
def extract(node_id, inputs, outputs, metadata): def extract(node_id, inputs, outputs, metadata):
@@ -163,6 +275,251 @@ class CLIPTextEncodeExtractor(NodeMetadataExtractor):
conditioning = outputs[0][0] conditioning = outputs[0][0]
metadata[PROMPTS][node_id]["conditioning"] = conditioning metadata[PROMPTS][node_id]["conditioning"] = conditioning
class MyOriginalWaifuTextExtractor(NodeMetadataExtractor):
"""Extractor for ComfyUI-MyOriginalWaifu TextProvider nodes."""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
metadata[PROMPTS][node_id] = {
"positive_text": positive_text,
"negative_text": negative_text,
"node_id": node_id,
}
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 2:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata["positive_text"] = output_tuple[0]
prompt_metadata["negative_text"] = output_tuple[1]
class MyOriginalWaifuClipExtractor(NodeMetadataExtractor):
"""Extractor for ComfyUI-MyOriginalWaifu ClipProvider nodes."""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
positive_text = inputs.get("positive", "")
negative_text = inputs.get("negative", "")
if positive_text or negative_text:
metadata[PROMPTS][node_id] = {
"positive_text": positive_text,
"negative_text": negative_text,
"node_id": node_id,
}
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 2:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata["positive_encoded"] = output_tuple[0]
prompt_metadata["negative_encoded"] = output_tuple[1]
def _ensure_prompt_metadata(metadata, node_id):
if node_id not in metadata[PROMPTS]:
metadata[PROMPTS][node_id] = {"node_id": node_id}
return metadata[PROMPTS][node_id]
def _first_output_tuple(outputs):
if not outputs or not isinstance(outputs, list) or len(outputs) == 0:
return None
first_output = outputs[0]
if isinstance(first_output, tuple):
return first_output
return None
def _record_conditioning_source(
metadata, node_id, output_conditioning, input_conditionings
):
if output_conditioning is None:
return
sources = [
conditioning for conditioning in input_conditionings if conditioning is not None
]
if not sources:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata.setdefault("conditioning_sources", []).append(
{
"output": output_conditioning,
"inputs": sources,
}
)
def _get_variable_name(inputs):
for key in ("key", "name", "variable_name", "tag", "text"):
value = inputs.get(key)
if isinstance(value, str) and value:
return value
return None
def _get_node_variable_name(metadata, node_id, inputs):
variable_name = _get_variable_name(inputs)
if variable_name:
return variable_name
prompt = metadata.get("current_prompt")
original_prompt = getattr(prompt, "original_prompt", None)
if not original_prompt or node_id not in original_prompt:
return None
node_data = original_prompt[node_id]
variable_name = _get_variable_name(node_data.get("inputs", {}))
if variable_name:
return variable_name
widgets_values = node_data.get("widgets_values", [])
if widgets_values and isinstance(widgets_values[0], str):
return widgets_values[0]
return None
class ControlNetApplyAdvancedExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
if inputs.get("positive") is not None:
prompt_metadata["orig_pos_cond"] = inputs["positive"]
if inputs.get("negative") is not None:
prompt_metadata["orig_neg_cond"] = inputs["negative"]
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
positive_input = prompt_metadata.get("orig_pos_cond")
negative_input = prompt_metadata.get("orig_neg_cond")
if len(output_tuple) >= 1:
prompt_metadata["positive_encoded"] = output_tuple[0]
_record_conditioning_source(
metadata, node_id, output_tuple[0], [positive_input]
)
if len(output_tuple) >= 2:
prompt_metadata["negative_encoded"] = output_tuple[1]
_record_conditioning_source(
metadata, node_id, output_tuple[1], [negative_input]
)
class ConditioningCombineExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
input_conditionings = []
for input_name in inputs:
if (
input_name.startswith("conditioning")
and inputs[input_name] is not None
):
input_conditionings.append(inputs[input_name])
if input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata["orig_conditionings"] = input_conditionings
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 1:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
output_conditioning = output_tuple[0]
prompt_metadata["conditioning"] = output_conditioning
_record_conditioning_source(
metadata,
node_id,
output_conditioning,
prompt_metadata.get("orig_conditionings", []),
)
class SetNodeExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
variable_name = _get_node_variable_name(metadata, node_id, inputs)
conditioning = inputs.get("CONDITIONING")
if conditioning is None:
conditioning = inputs.get("conditioning")
if conditioning is None:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata["conditioning"] = conditioning
if variable_name:
prompt_metadata["variable_name"] = variable_name
metadata[PROMPTS].setdefault("__conditioning_variables__", {})[
variable_name
] = conditioning
class GetNodeExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
variable_name = _get_node_variable_name(metadata, node_id, inputs or {})
if variable_name:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata["variable_name"] = variable_name
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 1:
return
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
output_conditioning = output_tuple[0]
prompt_metadata["conditioning"] = output_conditioning
variable_name = prompt_metadata.get("variable_name")
if not variable_name:
return
input_conditioning = metadata[PROMPTS].get("__conditioning_variables__", {}).get(
variable_name
)
_record_conditioning_source(
metadata, node_id, output_conditioning, [input_conditioning]
)
# Base Sampler Extractor to reduce code redundancy # Base Sampler Extractor to reduce code redundancy
class BaseSamplerExtractor(NodeMetadataExtractor): class BaseSamplerExtractor(NodeMetadataExtractor):
"""Base extractor for sampler nodes with common functionality""" """Base extractor for sampler nodes with common functionality"""
@@ -768,9 +1125,12 @@ NODE_EXTRACTORS = {
"KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect "KSamplerSelect": KSamplerSelectExtractor, # Add KSamplerSelect
"BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler "BasicScheduler": BasicSchedulerExtractor, # Add BasicScheduler
"AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler "AlignYourStepsScheduler": BasicSchedulerExtractor, # Add AlignYourStepsScheduler
# ComfyUI-Easy-Use pre-sampling / seed
"samplerSettings": EasyPreSamplingExtractor, # easy preSampling
"easySeed": EasySeedExtractor, # easy seed
# Loaders # Loaders
"CheckpointLoaderSimple": CheckpointLoaderExtractor, "CheckpointLoaderSimple": CheckpointLoaderExtractor,
"comfyLoader": CheckpointLoaderExtractor, # easy comfyLoader "comfyLoader": EasyComfyLoaderExtractor, # ComfyUI-Easy-Use easy comfyLoader
"CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss "CheckpointLoaderSimpleWithImages": CheckpointLoaderExtractor, # CheckpointLoader|pysssss
"TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes "TSC_EfficientLoader": TSCCheckpointLoaderExtractor, # Efficient Nodes
"NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku "NunchakuFluxDiTLoader": NunchakuFluxDiTLoaderExtractor, # ComfyUI-Nunchaku
@@ -780,8 +1140,10 @@ NODE_EXTRACTORS = {
"GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes "GGUFLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
"DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes "DiffusionModelLoaderKJ": KJNodesModelLoaderExtractor, # KJNodes
"CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes "CheckpointLoaderKJ": CheckpointLoaderExtractor, # KJNodes
"CheckpointLoaderLM": CheckpointLoaderExtractor, # LoRA Manager
"UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor "UNETLoader": UNETLoaderExtractor, # Updated to use dedicated extractor
"UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor "UnetLoaderGGUF": UNETLoaderExtractor, # Updated to use dedicated extractor
"UNETLoaderLM": UNETLoaderExtractor, # LoRA Manager
"LoraLoader": LoraLoaderExtractor, "LoraLoader": LoraLoaderExtractor,
"LoraLoaderLM": LoraLoaderManagerExtractor, "LoraLoaderLM": LoraLoaderManagerExtractor,
"RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor, "RgthreePowerLoraLoader": RgthreePowerLoraLoaderExtractor,
@@ -796,6 +1158,12 @@ NODE_EXTRACTORS = {
"smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes "smZ_CLIPTextEncode": CLIPTextEncodeExtractor, # From https://github.com/shiimizu/ComfyUI_smZNodes
"CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack "CR_ApplyControlNetStack": CR_ApplyControlNetStackExtractor, # Add CR_ApplyControlNetStack
"PCTextEncode": CLIPTextEncodeExtractor, # From https://github.com/asagi4/comfyui-prompt-control "PCTextEncode": CLIPTextEncodeExtractor, # From https://github.com/asagi4/comfyui-prompt-control
"TextProvider": MyOriginalWaifuTextExtractor, # ComfyUI-MyOriginalWaifu
"ClipProvider": MyOriginalWaifuClipExtractor, # ComfyUI-MyOriginalWaifu
"ControlNetApplyAdvanced": ControlNetApplyAdvancedExtractor,
"ConditioningCombine": ConditioningCombineExtractor,
"SetNode": SetNodeExtractor,
"GetNode": GetNodeExtractor,
# Latent # Latent
"EmptyLatentImage": ImageSizeExtractor, "EmptyLatentImage": ImageSizeExtractor,
# Flux # Flux

View File

@@ -1,12 +1,17 @@
import json import json
import os import os
import re import re
import time
import uuid
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import numpy as np import numpy as np
import folder_paths # type: ignore import folder_paths # type: ignore
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..metadata_collector.metadata_processor import MetadataProcessor from ..metadata_collector.metadata_processor import MetadataProcessor
from ..metadata_collector import get_metadata from ..metadata_collector import get_metadata
from ..utils.constants import CARD_PREVIEW_WIDTH
from ..utils.exif_utils import ExifUtils
from ..utils.utils import calculate_recipe_fingerprint
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
import piexif import piexif
import logging import logging
@@ -86,6 +91,13 @@ class SaveImageLM:
"tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.", "tooltip": "Adds an incremental counter to filenames to prevent overwriting previous images.",
}, },
), ),
"save_as_recipe": (
"BOOLEAN",
{
"default": False,
"tooltip": "Also saves each generated image as a LoRA Manager recipe.",
},
),
}, },
"hidden": { "hidden": {
"id": "UNIQUE_ID", "id": "UNIQUE_ID",
@@ -346,6 +358,203 @@ class SaveImageLM:
return filename return filename
@staticmethod
def _get_cached_model_by_name(scanner, name):
cache = getattr(scanner, "_cache", None)
if cache is None or not name:
return None
candidates = [
name,
os.path.basename(name),
os.path.splitext(os.path.basename(name))[0],
]
for model in getattr(cache, "raw_data", []):
file_name = model.get("file_name")
if file_name in candidates:
return model
return None
def _build_recipe_loras(self, recipe_scanner, lora_stack):
lora_matches = re.findall(r"<lora:([^:]+):([^>]+)>", lora_stack or "")
lora_scanner = getattr(recipe_scanner, "_lora_scanner", None)
loras_data = []
base_model_counts = {}
for name, strength in lora_matches:
lora_info = self._get_cached_model_by_name(lora_scanner, name)
civitai = (lora_info or {}).get("civitai") or {}
civitai_model = civitai.get("model") or {}
try:
parsed_strength = float(strength)
except (TypeError, ValueError):
parsed_strength = 1.0
loras_data.append(
{
"file_name": name,
"strength": parsed_strength,
"hash": ((lora_info or {}).get("sha256") or "").lower(),
"modelVersionId": civitai.get("id", 0),
"modelName": civitai_model.get("name", name) if lora_info else "",
"modelVersionName": civitai.get("name", "") if lora_info else "",
"isDeleted": False,
"exclude": False,
}
)
base_model = (lora_info or {}).get("base_model")
if base_model:
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
return lora_matches, loras_data, base_model_counts
def _build_recipe_checkpoint(self, recipe_scanner, checkpoint_raw):
if not isinstance(checkpoint_raw, str) or not checkpoint_raw.strip():
return None
checkpoint_name = checkpoint_raw.strip()
file_name = os.path.splitext(os.path.basename(checkpoint_name))[0]
checkpoint_scanner = getattr(recipe_scanner, "_checkpoint_scanner", None)
checkpoint_info = self._get_cached_model_by_name(
checkpoint_scanner, checkpoint_name
)
if not checkpoint_info:
return {
"type": "checkpoint",
"name": checkpoint_name,
"file_name": file_name,
"hash": self.get_checkpoint_hash(checkpoint_name) or "",
}
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", ""),
}
@staticmethod
def _derive_recipe_name(lora_matches):
recipe_name_parts = [
f"{name.strip()}-{float(strength):.2f}" for name, strength in lora_matches[:3]
]
return "_".join(recipe_name_parts) or "recipe"
@staticmethod
def _sync_recipe_cache(recipe_scanner, recipe_data, json_path):
cache = getattr(recipe_scanner, "_cache", None)
if cache is not None:
cache.raw_data.append(recipe_data)
cache.sorted_by_name = sorted(
cache.raw_data, key=lambda item: item.get("title", "").lower()
)
cache.sorted_by_date = sorted(
cache.raw_data,
key=lambda item: (
item.get("modified", item.get("created_date", 0)),
item.get("file_path", ""),
),
reverse=True,
)
recipe_scanner._update_folder_metadata(cache)
recipe_scanner._update_fts_index_for_recipe(recipe_data, "add")
recipe_id = str(recipe_data.get("id", ""))
if recipe_id:
recipe_scanner._json_path_map[recipe_id] = json_path
persistent_cache = getattr(recipe_scanner, "_persistent_cache", None)
if persistent_cache:
persistent_cache.update_recipe(recipe_data, json_path)
def _save_image_as_recipe(self, file_path, metadata_dict):
if not metadata_dict:
raise ValueError("No generation metadata found")
recipe_scanner = ServiceRegistry.get_service_sync("recipe_scanner")
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
recipes_dir = recipe_scanner.recipes_dir
if not recipes_dir:
raise RuntimeError("Recipes directory unavailable")
os.makedirs(recipes_dir, exist_ok=True)
recipe_id = str(uuid.uuid4())
optimized_image, extension = ExifUtils.optimize_image(
image_data=file_path,
target_width=CARD_PREVIEW_WIDTH,
format="webp",
quality=85,
preserve_metadata=True,
)
image_path = os.path.normpath(os.path.join(recipes_dir, f"{recipe_id}{extension}"))
with open(image_path, "wb") as file_obj:
file_obj.write(optimized_image)
lora_stack = metadata_dict.get("loras", "")
lora_matches, loras_data, base_model_counts = self._build_recipe_loras(
recipe_scanner, lora_stack
)
checkpoint_entry = self._build_recipe_checkpoint(
recipe_scanner, metadata_dict.get("checkpoint")
)
most_common_base_model = (
max(base_model_counts.items(), key=lambda item: item[1])[0]
if base_model_counts
else ""
)
current_time = time.time()
recipe_data = {
"id": recipe_id,
"file_path": image_path,
"title": self._derive_recipe_name(lora_matches),
"modified": current_time,
"created_date": current_time,
"base_model": most_common_base_model
or (checkpoint_entry or {}).get("baseModel", ""),
"loras": loras_data,
"gen_params": {
key: value
for key, value in metadata_dict.items()
if key not in ["checkpoint", "loras"]
},
"loras_stack": lora_stack,
"fingerprint": calculate_recipe_fingerprint(loras_data),
}
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry
json_path = os.path.normpath(
os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
)
with open(json_path, "w", encoding="utf-8") as file_obj:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
ExifUtils.append_recipe_metadata(image_path, recipe_data)
self._sync_recipe_cache(recipe_scanner, recipe_data, json_path)
def save_images( def save_images(
self, self,
images, images,
@@ -359,6 +568,7 @@ class SaveImageLM:
embed_workflow=False, embed_workflow=False,
save_with_metadata=True, save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
save_as_recipe=False,
): ):
"""Save images with metadata""" """Save images with metadata"""
results = [] results = []
@@ -477,6 +687,14 @@ class SaveImageLM:
img.save(file_path, format="WEBP", **save_kwargs) img.save(file_path, format="WEBP", **save_kwargs)
if save_as_recipe:
try:
self._save_image_as_recipe(file_path, metadata_dict)
except Exception as e:
logger.warning(
"Failed to save image as recipe: %s", e, exc_info=True
)
results.append( results.append(
{"filename": file, "subfolder": subfolder, "type": self.type} {"filename": file, "subfolder": subfolder, "type": self.type}
) )
@@ -499,6 +717,7 @@ class SaveImageLM:
embed_workflow=False, embed_workflow=False,
save_with_metadata=True, save_with_metadata=True,
add_counter_to_filename=True, add_counter_to_filename=True,
save_as_recipe=False,
): ):
"""Process and save image with metadata""" """Process and save image with metadata"""
# Make sure the output directory exists # Make sure the output directory exists
@@ -527,6 +746,7 @@ class SaveImageLM:
embed_workflow, embed_workflow,
save_with_metadata, save_with_metadata,
add_counter_to_filename, add_counter_to_filename,
save_as_recipe,
) )
return { return {

View File

@@ -76,6 +76,9 @@ class TriggerWordToggleLM:
# Filter out empty strings and return as set # Filter out empty strings and return as set
return set(word for word in words if word) 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( def process_trigger_words(
self, self,
id, id,
@@ -112,7 +115,11 @@ class TriggerWordToggleLM:
if isinstance(trigger_data, list): if isinstance(trigger_data, list):
if group_mode: 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 = [ parsed_items = [
self._parse_trigger_item( self._parse_trigger_item(
item, allow_strength_adjustment item, allow_strength_adjustment
@@ -174,6 +181,41 @@ class TriggerWordToggleLM:
return (filtered_triggers,) 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): def _parse_trigger_item(self, item, allow_strength_adjustment):
text = (item.get("text") or "").strip() text = (item.get("text") or "").strip()
active = bool(item.get("active", False)) active = bool(item.get("active", False))

View File

@@ -1,10 +1,22 @@
import folder_paths # type: ignore import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import FlexibleOptionalInputType, any_type, get_loras_list from .utils import FlexibleOptionalInputType, any_type, get_loras_list
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
class WanVideoLoraSelectLM: class WanVideoLoraSelectLM:
NAME = "WanVideo Lora Select (LoraManager)" NAME = "WanVideo Lora Select (LoraManager)"
CATEGORY = "Lora Manager/stackers" CATEGORY = "Lora Manager/stackers"
@@ -56,13 +68,13 @@ class WanVideoLoraSelectLM:
clip_strength = float(lora.get('clipStrength', model_strength)) clip_strength = float(lora.get('clipStrength', model_strength))
# Get lora path and trigger words # Get lora path and trigger words
lora_path, trigger_words = get_lora_info(lora_name) lora_path, trigger_words = get_lora_info_absolute(lora_name)
# Create lora item for WanVideo format # Create lora item for WanVideo format
lora_item = { lora_item = {
"path": folder_paths.get_full_path("loras", lora_path), "path": lora_path,
"strength": model_strength, "strength": model_strength,
"name": lora_path.split(".")[0], "name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks, "blocks": selected_blocks,
"layer_filter": layer_filter, "layer_filter": layer_filter,
"low_mem_load": low_mem_load, "low_mem_load": low_mem_load,

View File

@@ -1,11 +1,23 @@
import folder_paths # type: ignore import os
from ..utils.utils import get_lora_info from ..utils.utils import get_lora_info_absolute
from ..config import config
from .utils import any_type from .utils import any_type
import logging import logging
# 初始化日志记录器 # 初始化日志记录器
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _relpath_within_loras(abs_path):
"""Return abs_path relative to the first matching lora root, or basename as fallback."""
all_roots = list(config.loras_roots or []) + list(config.extra_loras_roots or [])
for root in all_roots:
try:
return os.path.relpath(abs_path, root)
except ValueError:
continue
return os.path.basename(abs_path)
# 定义新节点的类 # 定义新节点的类
class WanVideoLoraTextSelectLM: class WanVideoLoraTextSelectLM:
# 节点在UI中显示的名称 # 节点在UI中显示的名称
@@ -87,12 +99,12 @@ class WanVideoLoraTextSelectLM:
else: else:
continue continue
lora_path, trigger_words = get_lora_info(lora_name_raw) lora_path, trigger_words = get_lora_info_absolute(lora_name_raw)
lora_item = { lora_item = {
"path": folder_paths.get_full_path("loras", lora_path), "path": lora_path,
"strength": model_strength, "strength": model_strength,
"name": lora_path.split(".")[0], "name": os.path.splitext(_relpath_within_loras(lora_path))[0],
"blocks": selected_blocks, "blocks": selected_blocks,
"layer_filter": layer_filter, "layer_filter": layer_filter,
"low_mem_load": low_mem_load, "low_mem_load": low_mem_load,

View File

@@ -251,7 +251,7 @@ class BaseModelRoutes(ABC):
def _find_model_file(self, files): def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses.""" """Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") == "Model" and file.get("primary") is True), None) return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]: def get_handler(self, name: str) -> Callable[[web.Request], web.StreamResponse]:
"""Expose handlers for subclasses or tests.""" """Expose handlers for subclasses or tests."""

View File

@@ -33,15 +33,18 @@ from ...services.metadata_service import (
update_metadata_providers, update_metadata_providers,
) )
from ...services.service_registry import ServiceRegistry from ...services.service_registry import ServiceRegistry
from ...services.model_lifecycle_service import delete_model_artifacts
from ...services.settings_manager import get_settings_manager from ...services.settings_manager import get_settings_manager
from ...services.websocket_manager import ws_manager from ...services.websocket_manager import ws_manager
from ...services.downloader import get_downloader from ...services.downloader import get_downloader
from ...services.errors import ResourceNotFoundError from ...services.errors import ResourceNotFoundError
from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus from ...services.cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
from ...utils.models import BaseModelMetadata
from ...utils.constants import ( from ...utils.constants import (
CIVITAI_USER_MODEL_TYPES, CIVITAI_USER_MODEL_TYPES,
DEFAULT_NODE_COLOR, DEFAULT_NODE_COLOR,
NODE_TYPES, NODE_TYPES,
PREVIEW_EXTENSIONS,
SUPPORTED_MEDIA_EXTENSIONS, SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES, VALID_LORA_TYPES,
) )
@@ -617,6 +620,7 @@ class DoctorHandler:
diagnostics = [ diagnostics = [
await self._check_civitai_api_key(), await self._check_civitai_api_key(),
await self._check_cache_health(), await self._check_cache_health(),
await self._check_filename_conflicts(),
self._check_ui_version(client_version, app_version), self._check_ui_version(client_version, app_version),
] ]
@@ -681,6 +685,145 @@ class DoctorHandler:
status=status, status=status,
) )
async def resolve_filename_conflicts(self, request: web.Request) -> web.Response:
renamed: list[dict[str, Any]] = []
try:
for model_type, label, factory in self._scanner_factories:
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = {
filename: list(paths)
for filename, paths in hash_index.get_duplicate_filenames().items()
}
if not duplicates:
continue
cache = await scanner.get_cached_data()
path_to_model = {m["file_path"]: m for m in cache.raw_data}
used_basenames: set[str] = set()
for paths in duplicates.values():
if paths:
used_basenames.add(
os.path.splitext(os.path.basename(paths[0]))[0]
)
for filename, paths in duplicates.items():
for idx, path in enumerate(paths):
if idx == 0:
continue
dirname = os.path.dirname(path)
base_name = os.path.splitext(os.path.basename(path))[0]
ext = os.path.splitext(path)[1]
if not ext:
continue
model_data = path_to_model.get(path)
sha256 = (
model_data.get("sha256", "") if model_data else ""
)
hash_provider = (
lambda s=sha256: s if s else "0000"
)
new_filename = (
BaseModelMetadata.generate_unique_filename(
dirname,
base_name,
ext,
hash_provider=hash_provider,
)
)
candidate_base = os.path.splitext(new_filename)[0]
counter = 1
original_base = candidate_base
while candidate_base in used_basenames:
candidate_base = f"{original_base}-{counter}"
new_filename = f"{candidate_base}{ext}"
counter += 1
used_basenames.add(candidate_base)
new_path = os.path.join(dirname, new_filename)
if new_filename == os.path.basename(path):
continue
if not os.path.exists(path):
continue
old_base_no_ext = os.path.splitext(path)[0]
new_base_no_ext = (
os.path.splitext(new_path)[0]
)
os.rename(path, new_path)
for suffix in (".metadata.json", ".civitai.info"):
old_sidecar = old_base_no_ext + suffix
new_sidecar = new_base_no_ext + suffix
if os.path.exists(old_sidecar):
os.rename(old_sidecar, new_sidecar)
for preview_ext in PREVIEW_EXTENSIONS:
old_preview = old_base_no_ext + preview_ext
new_preview = new_base_no_ext + preview_ext
if os.path.exists(old_preview):
os.rename(old_preview, new_preview)
entry = path_to_model.get(path)
if entry:
entry = dict(entry)
entry["file_name"] = os.path.splitext(new_filename)[0]
if entry.get("preview_url"):
old_preview_url = entry["preview_url"].replace("\\", "/")
preview_ext = os.path.splitext(old_preview_url)[1]
if preview_ext:
entry["preview_url"] = (new_base_no_ext + preview_ext).replace(os.sep, "/")
await scanner.update_single_model_cache(
path, new_path, entry
)
logger.info(
"Resolved duplicate filename '%s': "
"renamed '%s' to '%s'",
filename,
path,
new_path,
)
renamed.append({
"model_type": model_type,
"label": label,
"filename": filename,
"old_path": path,
"new_path": new_path,
"new_filename": new_filename,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Failed to resolve filename conflicts for %s: %s",
model_type,
exc,
exc_info=True,
)
return web.json_response({
"success": True,
"renamed": renamed,
"count": len(renamed),
})
except Exception as exc:
logger.error(
"Error resolving filename conflicts: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": str(exc)}, status=500
)
async def export_doctor_bundle(self, request: web.Request) -> web.Response: async def export_doctor_bundle(self, request: web.Request) -> web.Response:
try: try:
payload = await request.json() payload = await request.json()
@@ -846,6 +989,79 @@ class DoctorHandler:
"actions": [{"id": "repair-cache", "label": "Rebuild Cache"}], "actions": [{"id": "repair-cache", "label": "Rebuild Cache"}],
} }
async def _check_filename_conflicts(self) -> dict[str, Any]:
all_conflicts: list[dict[str, Any]] = []
total_conflict_groups = 0
total_conflict_files = 0
for model_type, label, factory in self._scanner_factories:
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
if hash_index is None:
continue
duplicates = hash_index.get_duplicate_filenames()
if not duplicates:
continue
total_conflict_groups += len(duplicates)
for filename, paths in duplicates.items():
total_conflict_files += len(paths)
all_conflicts.append({
"model_type": model_type,
"label": label,
"filename": filename,
"paths": paths,
})
except Exception as exc: # pragma: no cover - defensive
logger.error(
"Doctor filename conflict check failed for %s: %s",
model_type,
exc,
exc_info=True,
)
if not all_conflicts:
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "ok",
"summary": "No duplicate filenames found across model directories.",
"details": [],
"actions": [],
}
summary = (
f"{total_conflict_groups} filename(s) shared by "
f"{total_conflict_files} files across your library. "
f"This causes ambiguity when loading LoRAs by name."
)
details: list[str | dict[str, Any]] = [
{
"conflict_groups": total_conflict_groups,
"total_conflict_files": total_conflict_files,
}
]
for conflict in all_conflicts:
details.append(
f"[{conflict['label']}] '{conflict['filename']}' "
f"found in {len(conflict['paths'])} locations"
)
return {
"id": "filename_conflicts",
"title": "Duplicate Filename Conflicts",
"status": "warning",
"summary": summary,
"details": details,
"actions": [
{
"id": "resolve-filename-conflicts",
"label": "Resolve Conflicts",
}
],
}
def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]: def _check_ui_version(self, client_version: str, app_version: str) -> dict[str, Any]:
if client_version and client_version != app_version: if client_version and client_version != app_version:
return { return {
@@ -1576,29 +1792,33 @@ class ModelLibraryHandler:
exists = True exists = True
model_type = "embedding" model_type = "embedding"
if exists:
return web.json_response(
{
"success": True,
"exists": True,
"modelType": model_type,
"hasBeenDownloaded": False,
}
)
history_service = await self._get_download_history_service() history_service = await self._get_download_history_service()
has_been_downloaded = False has_been_downloaded = False
history_type = model_type history_type = None
if history_type: for candidate_type in ("lora", "checkpoint", "embedding"):
has_been_downloaded = await history_service.has_been_downloaded( if await history_service.has_been_downloaded(
history_type, candidate_type,
model_version_id, model_version_id,
) ):
else: has_been_downloaded = True
for candidate_type in ("lora", "checkpoint", "embedding"): history_type = candidate_type
if await history_service.has_been_downloaded( break
candidate_type,
model_version_id,
):
has_been_downloaded = True
history_type = candidate_type
break
return web.json_response( return web.json_response(
{ {
"success": True, "success": True,
"exists": exists, "exists": False,
"modelType": model_type if exists else history_type, "modelType": history_type,
"hasBeenDownloaded": has_been_downloaded, "hasBeenDownloaded": has_been_downloaded,
} }
) )
@@ -1618,40 +1838,46 @@ class ModelLibraryHandler:
model_type = None model_type = None
versions = [] versions = []
downloaded_version_ids = [] downloaded_version_ids = []
history_service = await self._get_download_history_service()
if lora_versions: if lora_versions:
model_type = "lora" return web.json_response(
versions = self._with_downloaded_flag(lora_versions) {
downloaded_version_ids = await history_service.get_downloaded_version_ids( "success": True,
model_type, "modelType": "lora",
model_id, "versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
}
) )
elif checkpoint_versions: if checkpoint_versions:
model_type = "checkpoint" return web.json_response(
versions = self._with_downloaded_flag(checkpoint_versions) {
downloaded_version_ids = await history_service.get_downloaded_version_ids( "success": True,
model_type, "modelType": "checkpoint",
model_id, "versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
}
) )
elif embedding_versions: if embedding_versions:
model_type = "embedding" return web.json_response(
versions = self._with_downloaded_flag(embedding_versions) {
downloaded_version_ids = await history_service.get_downloaded_version_ids( "success": True,
model_type, "modelType": "embedding",
model_id, "versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
}
) )
else:
for candidate_type in ("lora", "checkpoint", "embedding"): history_service = await self._get_download_history_service()
candidate_downloaded_version_ids = ( for candidate_type in ("lora", "checkpoint", "embedding"):
await history_service.get_downloaded_version_ids( candidate_downloaded_version_ids = (
candidate_type, await history_service.get_downloaded_version_ids(
model_id, candidate_type,
) model_id,
) )
if candidate_downloaded_version_ids: )
model_type = candidate_type if candidate_downloaded_version_ids:
downloaded_version_ids = candidate_downloaded_version_ids model_type = candidate_type
break downloaded_version_ids = candidate_downloaded_version_ids
break
return web.json_response( return web.json_response(
{ {
@@ -1665,6 +1891,86 @@ class ModelLibraryHandler:
logger.error("Failed to check model existence: %s", exc, exc_info=True) logger.error("Failed to check model existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def check_models_exist(self, request: web.Request) -> web.Response:
try:
model_ids_raw = request.query.get("modelIds", "")
if not model_ids_raw:
return web.json_response(
{"success": True, "results": []}
)
raw_ids = model_ids_raw.split(",")
seen: set[int] = set()
model_ids: list[int] = []
for raw in raw_ids:
stripped = raw.strip()
if not stripped:
continue
try:
mid = int(stripped)
except ValueError:
continue
if mid not in seen:
seen.add(mid)
model_ids.append(mid)
if not model_ids:
return web.json_response(
{"success": True, "results": []}
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
results: list[dict] = []
for model_id in model_ids:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
if lora_versions:
results.append({
"modelId": model_id,
"modelType": "lora",
"versions": self._with_downloaded_flag(lora_versions),
"downloadedVersionIds": [],
})
continue
if checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(model_id)
if checkpoint_versions:
results.append({
"modelId": model_id,
"modelType": "checkpoint",
"versions": self._with_downloaded_flag(checkpoint_versions),
"downloadedVersionIds": [],
})
continue
if embedding_scanner:
embedding_versions = await embedding_scanner.get_model_versions_by_id(model_id)
if embedding_versions:
results.append({
"modelId": model_id,
"modelType": "embedding",
"versions": self._with_downloaded_flag(embedding_versions),
"downloadedVersionIds": [],
})
continue
results.append({
"modelId": model_id,
"modelType": None,
"versions": [],
"downloadedVersionIds": [],
})
return web.json_response(
{"success": True, "results": results}
)
except Exception as exc:
logger.error("Failed to check models existence: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_version_download_status( async def get_model_version_download_status(
self, request: web.Request self, request: web.Request
) -> web.Response: ) -> web.Response:
@@ -1777,6 +2083,78 @@ class ModelLibraryHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def delete_model_version(self, request: web.Request) -> web.Response:
try:
model_version_id_str = request.query.get("modelVersionId")
if not model_version_id_str:
return web.json_response(
{"success": False, "error": "Missing required parameter: modelVersionId"},
status=400,
)
try:
model_version_id = int(model_version_id_str)
except ValueError:
return web.json_response(
{"success": False, "error": "Parameter modelVersionId must be an integer"},
status=400,
)
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None
file_path = None
found_cache = None
for model_type, scanner in (
("lora", lora_scanner),
("checkpoint", checkpoint_scanner),
("embedding", embedding_scanner),
):
cache = await scanner.get_cached_data()
if cache and model_version_id in cache.version_index:
found_type = model_type
found_cache = cache
entry = cache.version_index[model_version_id]
file_path = entry.get("file_path")
break
if not file_path:
return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"},
status=404,
)
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache:
found_cache.raw_data = [
item
for item in found_cache.raw_data
if item.get("file_path") != file_path
]
await found_cache.resort()
history_service = await self._get_download_history_service()
await history_service.mark_not_downloaded(found_type, model_version_id)
return web.json_response(
{
"success": True,
"modelType": found_type,
"modelVersionId": model_version_id,
}
)
except Exception as exc:
logger.error(
"Failed to delete model version: %s", exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_model_versions_status(self, request: web.Request) -> web.Response: async def get_model_versions_status(self, request: web.Request) -> web.Response:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
@@ -2796,6 +3174,7 @@ class MiscHandlerSet:
"update_settings": self.settings.update_settings, "update_settings": self.settings.update_settings,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
"repair_doctor_cache": self.doctor.repair_doctor_cache, "repair_doctor_cache": self.doctor.repair_doctor_cache,
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
"export_doctor_bundle": self.doctor.export_doctor_bundle, "export_doctor_bundle": self.doctor.export_doctor_bundle,
"get_priority_tags": self.settings.get_priority_tags, "get_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries, "get_settings_libraries": self.settings.get_libraries,
@@ -2809,8 +3188,10 @@ class MiscHandlerSet:
"update_node_widget": self.node_registry.update_node_widget, "update_node_widget": self.node_registry.update_node_widget,
"get_registry": self.node_registry.get_registry, "get_registry": self.node_registry.get_registry,
"check_model_exists": self.model_library.check_model_exists, "check_model_exists": self.model_library.check_model_exists,
"check_models_exist": self.model_library.check_models_exist,
"get_model_version_download_status": self.model_library.get_model_version_download_status, "get_model_version_download_status": self.model_library.get_model_version_download_status,
"set_model_version_download_status": self.model_library.set_model_version_download_status, "set_model_version_download_status": self.model_library.set_model_version_download_status,
"delete_model_version": self.model_library.delete_model_version,
"get_civitai_user_models": self.model_library.get_civitai_user_models, "get_civitai_user_models": self.model_library.get_civitai_user_models,
"download_metadata_archive": self.metadata_archive.download_metadata_archive, "download_metadata_archive": self.metadata_archive.download_metadata_archive,
"remove_metadata_archive": self.metadata_archive.remove_metadata_archive, "remove_metadata_archive": self.metadata_archive.remove_metadata_archive,

View File

@@ -16,6 +16,10 @@ import jinja2
from ...config import config from ...config import config
from ...services.download_coordinator import DownloadCoordinator 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.metadata_sync_service import MetadataSyncService
from ...services.model_file_service import ModelMoveService from ...services.model_file_service import ModelMoveService
from ...services.preview_asset_service import PreviewAssetService from ...services.preview_asset_service import PreviewAssetService
@@ -504,6 +508,11 @@ class ModelManagementHandler:
formatted_metadata = await self._service.format_response(model_data) formatted_metadata = await self._service.format_response(model_data)
return web.json_response({"success": True, "metadata": formatted_metadata}) return web.json_response({"success": True, "metadata": formatted_metadata})
except Exception as exc: 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) self._logger.error("Error fetching from CivitAI: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -550,6 +559,11 @@ class ModelManagementHandler:
} }
) )
except Exception as exc: 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) self._logger.error("Error re-linking to CivitAI: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -1858,6 +1872,11 @@ class ModelUpdateHandler:
status=429, status=429,
) )
except Exception as exc: # pragma: no cover - defensive log 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) self._logger.error("Failed to fetch license info: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -1946,9 +1965,12 @@ class ModelUpdateHandler:
{"success": False, "error": str(exc) or "Rate limited"}, status=429 {"success": False, "error": str(exc) or "Rate limited"}, status=429
) )
except Exception as exc: # pragma: no cover - defensive logging except Exception as exc: # pragma: no cover - defensive logging
self._logger.error( if is_expected_offline_error(str(exc)):
"Failed to refresh model updates: %s", exc, exc_info=True 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) return web.json_response({"success": False, "error": str(exc)}, status=500)
serialized_records = [] serialized_records = []
@@ -2401,6 +2423,7 @@ class ModelUpdateHandler:
"shouldIgnore": version.should_ignore, "shouldIgnore": version.should_ignore,
"earlyAccessEndsAt": version.early_access_ends_at, "earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access, "isEarlyAccess": is_early_access,
"usageControl": version.usage_control,
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
} }

View File

@@ -24,6 +24,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/settings", "update_settings"), RouteDefinition("POST", "/api/lm/settings", "update_settings"),
RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"), RouteDefinition("GET", "/api/lm/doctor/diagnostics", "get_doctor_diagnostics"),
RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"), RouteDefinition("POST", "/api/lm/doctor/repair-cache", "repair_doctor_cache"),
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"), RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"), RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
@@ -42,6 +43,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"), RouteDefinition("POST", "/api/lm/update-node-widget", "update_node_widget"),
RouteDefinition("GET", "/api/lm/get-registry", "get_registry"), RouteDefinition("GET", "/api/lm/get-registry", "get_registry"),
RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"), RouteDefinition("GET", "/api/lm/check-model-exists", "check_model_exists"),
RouteDefinition("GET", "/api/lm/check-models-exist", "check_models_exist"),
RouteDefinition( RouteDefinition(
"GET", "GET",
"/api/lm/model-version-download-status", "/api/lm/model-version-download-status",
@@ -89,6 +91,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status" "GET", "/api/lm/base-models/cache-status", "get_base_model_cache_status"
), ),
RouteDefinition(
"GET", "/api/lm/delete-model-version", "delete_model_version"
),
) )

View File

@@ -1,3 +1,4 @@
import asyncio
import json import json
import logging import logging
import os import os
@@ -36,6 +37,9 @@ class CheckpointScanner(ModelScanner):
file_extensions=file_extensions, file_extensions=file_extensions,
hash_index=ModelHashIndex(), 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( async def _create_default_metadata(
self, file_path: str self, file_path: str
@@ -88,7 +92,7 @@ class CheckpointScanner(ModelScanner):
return None return None
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]: 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: Args:
file_path: Path to the model file file_path: Path to the model file
@@ -96,14 +100,65 @@ class CheckpointScanner(ModelScanner):
Returns: Returns:
SHA256 hash string, or None if calculation failed SHA256 hash string, or None if calculation failed
""" """
from ..utils.file_utils import calculate_sha256
try: try:
real_path = os.path.realpath(file_path) real_path = os.path.realpath(file_path)
if not os.path.exists(real_path): if not os.path.exists(real_path):
logger.error(f"File not found for hash calculation: {file_path}") logger.error(f"File not found for hash calculation: {file_path}")
return None 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 # Load current metadata
metadata, should_skip = await MetadataManager.load_metadata( metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.model_class file_path, self.model_class

View File

@@ -3,6 +3,11 @@ import copy
import logging import logging
import os import os
from typing import Any, Optional, Dict, Tuple, List, Sequence 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 ( from .model_metadata_provider import (
CivitaiModelMetadataProvider, CivitaiModelMetadataProvider,
ModelMetadataProviderManager, ModelMetadataProviderManager,
@@ -65,6 +70,8 @@ class CivitaiClient:
if result.provider is None: if result.provider is None:
result.provider = "civitai_api" result.provider = "civitai_api"
raise result raise result
if not success and is_offline_cooldown_error(result):
return False, OFFLINE_FRIENDLY_MESSAGE
return success, result return success, result
@staticmethod @staticmethod
@@ -124,6 +131,8 @@ class CivitaiClient:
) )
if not success: if not success:
message = str(version) message = str(version)
if is_expected_offline_error(message):
return None, OFFLINE_FRIENDLY_MESSAGE
if "not found" in message.lower(): if "not found" in message.lower():
return None, "Model not found" return None, "Model not found"
@@ -164,6 +173,9 @@ class CivitaiClient:
return True return True
return False return False
except Exception as e: 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)}") logger.error(f"Download Error: {str(e)}")
return False return False
@@ -207,6 +219,9 @@ class CivitaiClient:
message = self._extract_error_message(result) message = self._extract_error_message(result)
if message and "not found" in message.lower(): if message and "not found" in message.lower():
raise ResourceNotFoundError(f"Resource not found for model {model_id}") 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: if message:
raise RuntimeError(message) raise RuntimeError(message)
return None return None
@@ -357,6 +372,8 @@ class CivitaiClient:
) )
if success: if success:
return data return data
if is_expected_offline_error(data):
return None
logger.warning(f"Failed to fetch model data for model {model_id}") logger.warning(f"Failed to fetch model data for model {model_id}")
return None return None
@@ -371,6 +388,8 @@ class CivitaiClient:
) )
if success: if success:
return version return version
if is_expected_offline_error(version):
return None
logger.warning(f"Failed to fetch version by id {version_id}") logger.warning(f"Failed to fetch version by id {version_id}")
return None return None
@@ -386,6 +405,8 @@ class CivitaiClient:
) )
if success: if success:
return version return version
if is_expected_offline_error(version):
return None
logger.warning(f"Failed to fetch version by hash {model_hash}") logger.warning(f"Failed to fetch version by hash {model_hash}")
return None return None
@@ -473,6 +494,8 @@ class CivitaiClient:
return result, None return result, None
# Handle specific error cases # Handle specific error cases
if is_expected_offline_error(result):
return None, OFFLINE_FRIENDLY_MESSAGE
if "not found" in str(result): if "not found" in str(result):
error_msg = f"Model not found" error_msg = f"Model not found"
logger.warning(f"Model version not found: {version_id} - {error_msg}") logger.warning(f"Model version not found: {version_id} - {error_msg}")
@@ -507,6 +530,8 @@ class CivitaiClient:
success, result = await self._make_request("GET", url, use_auth=True) success, result = await self._make_request("GET", url, use_auth=True)
if not success: if not success:
if is_expected_offline_error(result):
return None
logger.error( logger.error(
"Failed to fetch image info for ID %s from civitai.red: %s", "Failed to fetch image info for ID %s from civitai.red: %s",
image_id, image_id,
@@ -566,6 +591,9 @@ class CivitaiClient:
) )
if not success: 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) logger.error("Failed to fetch models for %s: %s", username, result)
return None 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

View File

@@ -75,6 +75,65 @@ class DownloadManager:
backend = (get_settings_manager().get("download_backend") or "python").strip() backend = (get_settings_manager().get("download_backend") or "python").strip()
return backend.lower() or "python" return backend.lower() or "python"
async def _schedule_auto_example_images_download(
self,
*,
metadata,
model_type: str,
) -> None:
settings_manager = get_settings_manager()
if not settings_manager.get("auto_download_example_images", False):
return
if not settings_manager.get("example_images_path"):
logger.debug(
"Skipping automatic example images download; example_images_path is not configured"
)
return
raw_hash = getattr(metadata, "sha256", "") or ""
model_hash = str(raw_hash).strip().lower()
if not model_hash:
logger.debug(
"Skipping automatic example images download for %s; missing sha256",
getattr(metadata, "file_path", ""),
)
return
optimize = bool(settings_manager.get("optimize_example_images", True))
async def _run_auto_example_images_download() -> None:
try:
from ..utils.example_images_download_manager import (
DownloadInProgressError,
get_default_download_manager,
)
ws_manager = await ServiceRegistry.get_websocket_manager()
example_images_manager = get_default_download_manager(ws_manager)
await example_images_manager.start_force_download(
{
"model_hashes": [model_hash],
"optimize": optimize,
"model_types": [model_type],
"delay": 0,
}
)
except DownloadInProgressError:
logger.info(
"Skipping automatic example images download for %s; another example images download is already running",
model_hash,
)
except Exception as exc:
logger.warning(
"Automatic example images download failed for %s: %s",
model_hash,
exc,
exc_info=True,
)
asyncio.create_task(_run_auto_example_images_download())
async def _download_model_file( async def _download_model_file(
self, self,
download_url: str, download_url: str,
@@ -1305,7 +1364,7 @@ class DownloadManager:
f f
for f in files for f in files
if f.get("primary") if f.get("primary")
and f.get("type") in ("Model", "Negative") and f.get("type") in ("Model", "Negative", "Diffusion Model")
), ),
None, None,
) )
@@ -1336,7 +1395,7 @@ class DownloadManager:
( (
f f
for f in files for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative") if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model")
), ),
None, None,
) )
@@ -1458,6 +1517,10 @@ class DownloadManager:
version_info, version_info,
model_version_id, model_version_id,
) )
await self._schedule_auto_example_images_download(
metadata=metadata,
model_type=model_type,
)
# If early_access_msg exists and download failed, replace error message # If early_access_msg exists and download failed, replace error message
if "early_access_msg" in locals() and not result.get("success", False): if "early_access_msg" in locals() and not result.get("success", False):

View File

@@ -64,6 +64,7 @@ class DownloadedVersionHistoryService:
self._db_path = db_path or _resolve_database_path() self._db_path = db_path or _resolve_database_path()
self._settings = settings_manager or get_settings_manager() self._settings = settings_manager or get_settings_manager()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._conn: sqlite3.Connection | None = None
self._schema_initialized = False self._schema_initialized = False
self._ensure_directory() self._ensure_directory()
self._initialize_schema() self._initialize_schema()
@@ -78,6 +79,12 @@ class DownloadedVersionHistoryService:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return conn return conn
def _get_conn(self) -> sqlite3.Connection:
if self._conn is None:
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
return self._conn
def _initialize_schema(self) -> None: def _initialize_schema(self) -> None:
if self._schema_initialized: if self._schema_initialized:
return return
@@ -116,33 +123,33 @@ class DownloadedVersionHistoryService:
timestamp = time.time() timestamp = time.time()
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
conn.execute( conn.execute(
""" """
INSERT INTO downloaded_model_versions ( INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at, model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id), model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
source = excluded.source, source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path), last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name), last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0 is_deleted_override = 0
""", """,
( (
normalized_type, normalized_type,
normalized_version_id, normalized_version_id,
normalized_model_id, normalized_model_id,
timestamp, timestamp,
timestamp, timestamp,
source, source,
file_path, file_path,
active_library_name, active_library_name,
), ),
) )
conn.commit() conn.commit()
async def mark_downloaded_bulk( async def mark_downloaded_bulk(
self, self,
@@ -180,24 +187,24 @@ class DownloadedVersionHistoryService:
return return
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
conn.executemany( conn.executemany(
""" """
INSERT INTO downloaded_model_versions ( INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at, model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET ON CONFLICT(model_type, version_id) DO UPDATE SET
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id), model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
source = excluded.source, source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path), last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name), last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0 is_deleted_override = 0
""", """,
payload, payload,
) )
conn.commit() conn.commit()
async def mark_not_downloaded(self, model_type: str, version_id: int) -> None: async def mark_not_downloaded(self, model_type: str, version_id: int) -> None:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
@@ -208,28 +215,28 @@ class DownloadedVersionHistoryService:
timestamp = time.time() timestamp = time.time()
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
conn.execute( conn.execute(
""" """
INSERT INTO downloaded_model_versions ( INSERT INTO downloaded_model_versions (
model_type, version_id, model_id, first_seen_at, last_seen_at, model_type, version_id, model_id, first_seen_at, last_seen_at,
source, last_file_path, last_library_name, is_deleted_override source, last_file_path, last_library_name, is_deleted_override
) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1) ) VALUES (?, ?, NULL, ?, ?, 'manual', NULL, ?, 1)
ON CONFLICT(model_type, version_id) DO UPDATE SET ON CONFLICT(model_type, version_id) DO UPDATE SET
last_seen_at = excluded.last_seen_at, last_seen_at = excluded.last_seen_at,
source = excluded.source, source = excluded.source,
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name), last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 1 is_deleted_override = 1
""", """,
( (
normalized_type, normalized_type,
normalized_version_id, normalized_version_id,
timestamp, timestamp,
timestamp, timestamp,
self._get_active_library_name(), self._get_active_library_name(),
), ),
) )
conn.commit() conn.commit()
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool: async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
@@ -238,15 +245,15 @@ class DownloadedVersionHistoryService:
return False return False
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
row = conn.execute( row = conn.execute(
""" """
SELECT is_deleted_override SELECT is_deleted_override
FROM downloaded_model_versions FROM downloaded_model_versions
WHERE model_type = ? AND version_id = ? WHERE model_type = ? AND version_id = ?
""", """,
(normalized_type, normalized_version_id), (normalized_type, normalized_version_id),
).fetchone() ).fetchone()
return bool(row) and not bool(row["is_deleted_override"]) return bool(row) and not bool(row["is_deleted_override"])
async def get_downloaded_version_ids( async def get_downloaded_version_ids(
@@ -258,16 +265,16 @@ class DownloadedVersionHistoryService:
return [] return []
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
rows = conn.execute( rows = conn.execute(
""" """
SELECT version_id SELECT version_id
FROM downloaded_model_versions FROM downloaded_model_versions
WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0 WHERE model_type = ? AND model_id = ? AND is_deleted_override = 0
ORDER BY version_id ASC ORDER BY version_id ASC
""", """,
(normalized_type, normalized_model_id), (normalized_type, normalized_model_id),
).fetchall() ).fetchall()
return [int(row["version_id"]) for row in rows] return [int(row["version_id"]) for row in rows]
async def get_downloaded_version_ids_bulk( async def get_downloaded_version_ids_bulk(
@@ -291,17 +298,17 @@ class DownloadedVersionHistoryService:
params: list[object] = [normalized_type, *normalized_model_ids] params: list[object] = [normalized_type, *normalized_model_ids]
async with self._lock: async with self._lock:
with self._connect() as conn: conn = self._get_conn()
rows = conn.execute( rows = conn.execute(
f""" f"""
SELECT model_id, version_id SELECT model_id, version_id
FROM downloaded_model_versions FROM downloaded_model_versions
WHERE model_type = ? WHERE model_type = ?
AND model_id IN ({placeholders}) AND model_id IN ({placeholders})
AND is_deleted_override = 0 AND is_deleted_override = 0
""", """,
params, params,
).fetchall() ).fetchall()
result: dict[int, set[int]] = {} result: dict[int, set[int]] = {}
for row in rows: for row in rows:

View File

@@ -18,8 +18,14 @@ from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from urllib.parse import urlparse
from typing import Optional, Dict, Tuple, Callable, Union, Awaitable from typing import Optional, Dict, Tuple, Callable, Union, Awaitable
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from .connectivity_guard import (
OFFLINE_COOLDOWN_ERROR,
OFFLINE_FRIENDLY_MESSAGE,
ConnectivityGuard,
)
from .errors import RateLimitError from .errors import RateLimitError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -797,6 +803,11 @@ class Downloader:
Returns: Returns:
Tuple[bool, Union[bytes, str], Optional[Dict]]: (success, content or error message, response headers if requested) 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: try:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
@@ -819,6 +830,7 @@ class Downloader:
) as response: ) as response:
if response.status == 200: if response.status == 200:
content = await response.read() content = await response.read()
guard.register_success(destination)
if return_headers: if return_headers:
return True, content, dict(response.headers) return True, content, dict(response.headers)
else: else:
@@ -837,6 +849,12 @@ class Downloader:
return False, error_msg, None return False, error_msg, None
except Exception as e: 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}") logger.error(f"Error downloading to memory from {url}: {e}")
return False, str(e), None return False, str(e), None
@@ -857,6 +875,11 @@ class Downloader:
Returns: Returns:
Tuple[bool, Union[Dict, str]]: (success, headers dict or error message) 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: try:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
@@ -878,11 +901,18 @@ class Downloader:
url, headers=headers, proxy=self.proxy_url url, headers=headers, proxy=self.proxy_url
) as response: ) as response:
if response.status == 200: if response.status == 200:
guard.register_success(destination)
return True, dict(response.headers) return True, dict(response.headers)
else: else:
return False, f"Head request failed with status {response.status}" return False, f"Head request failed with status {response.status}"
except Exception as e: 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}") logger.error(f"Error getting headers from {url}: {e}")
return False, str(e) return False, str(e)
@@ -907,6 +937,11 @@ class Downloader:
Returns: Returns:
Tuple[bool, Union[Dict, str]]: (success, response data or error message) 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: try:
session = await self.session session = await self.session
# Debug log for proxy mode at request time # Debug log for proxy mode at request time
@@ -930,6 +965,7 @@ class Downloader:
method, url, headers=headers, **kwargs method, url, headers=headers, **kwargs
) as response: ) as response:
if response.status == 200: if response.status == 200:
guard.register_success(destination)
# Try to parse as JSON, fall back to text # Try to parse as JSON, fall back to text
try: try:
data = await response.json() data = await response.json()
@@ -960,6 +996,12 @@ class Downloader:
return False, f"Request failed with status {response.status}" return False, f"Request failed with status {response.status}"
except Exception as e: 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}") logger.error(f"Error making {method} request to {url}: {e}")
return False, str(e) return False, str(e)
@@ -1010,6 +1052,14 @@ class Downloader:
delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo) delta = retry_datetime - datetime.now(tz=retry_datetime.tzinfo)
return max(0.0, delta.total_seconds()) 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 # Global instance accessor
async def get_downloader() -> Downloader: async def get_downloader() -> Downloader:

View File

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

View File

@@ -79,6 +79,12 @@ class ModelHashIndex:
hash_val = h hash_val = h
break break
if hash_val is None:
for h, paths in self._duplicate_hashes.items():
if file_path in paths:
hash_val = h
break
# If we didn't find a hash, nothing to do # If we didn't find a hash, nothing to do
if not hash_val: if not hash_val:
return return

View File

@@ -1072,14 +1072,6 @@ class ModelScanner:
excluded_models.append(model_data['file_path']) excluded_models.append(model_data['file_path'])
return None return None
# Check for duplicate filename before adding to hash index
# filename = os.path.splitext(os.path.basename(file_path))[0]
# existing_hash = hash_index.get_hash_by_filename(filename)
# if existing_hash and existing_hash != model_data.get('sha256', '').lower():
# existing_path = hash_index.get_path(existing_hash)
# if existing_path and existing_path != file_path:
# logger.warning(f"Duplicate filename detected: '{filename}' - files: '{existing_path}' and '{file_path}'")
return model_data return model_data
async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None: async def _apply_scan_result(self, scan_result: CacheBuildResult) -> None:
@@ -1105,6 +1097,31 @@ class ModelScanner:
await self._cache.resort() await self._cache.resort()
self._log_duplicate_filename_summary()
def _log_duplicate_filename_summary(self) -> None:
"""Log a batched summary of duplicate filename conflicts once per scan."""
if self._hash_index is None:
return
duplicates = self._hash_index.get_duplicate_filenames()
if not duplicates:
return
total_files = sum(len(paths) for paths in duplicates.values())
conflict_count = len(duplicates)
model_type_label = self.model_type or "model"
logger.warning(
"Duplicate filename conflict detected: %d %s filename(s) "
"are shared by %d files total, causing ambiguity in %s resolution. "
"Open the Doctor panel to resolve one-click.",
conflict_count,
model_type_label,
total_files,
model_type_label.capitalize(),
)
async def _sync_download_history( async def _sync_download_history(
self, self,
raw_data: List[Mapping[str, Any]], raw_data: List[Mapping[str, Any]],

View File

@@ -69,6 +69,7 @@ class ModelVersionRecord:
early_access_ends_at: Optional[str] = None early_access_ends_at: Optional[str] = None
sort_index: int = 0 sort_index: int = 0
is_early_access: bool = False is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
@dataclass @dataclass
@@ -101,11 +102,14 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library] return [version.version_id for version in self.versions if version.is_in_library]
def has_update(self, hide_early_access: bool = False) -> bool: def has_update(
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available. """Return True when a non-ignored remote version newer than the newest local copy is available.
Args: Args:
hide_early_access: If True, exclude early access versions from update check. hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
""" """
if self.should_ignore_model: if self.should_ignore_model:
@@ -121,6 +125,7 @@ class ModelUpdateRecord:
not version.is_in_library not version.is_in_library
and not version.should_ignore and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version)) and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions for version in self.versions
) )
@@ -129,6 +134,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
if version.version_id > max_in_library: if version.version_id > max_in_library:
return True return True
return False return False
@@ -155,11 +162,18 @@ class ModelUpdateRecord:
# Phase 1: Basic EA flag from bulk API # Phase 1: Basic EA flag from bulk API
return version.is_early_access return version.is_early_access
@staticmethod
def _is_downloadable(version: ModelVersionRecord) -> bool:
if version.usage_control is None:
return True
return version.usage_control == "Download"
def has_update_for_base( def has_update_for_base(
self, self,
local_version_id: Optional[int], local_version_id: Optional[int],
local_base_model: Optional[str], local_base_model: Optional[str],
hide_early_access: bool = False, hide_early_access: bool = False,
hide_non_downloadable: bool = True,
) -> bool: ) -> bool:
"""Return True when a newer remote version with the same base model exists. """Return True when a newer remote version with the same base model exists.
@@ -167,6 +181,7 @@ class ModelUpdateRecord:
local_version_id: The current local version id. local_version_id: The current local version id.
local_base_model: The base model to filter by. local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check. hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
""" """
if self.should_ignore_model: if self.should_ignore_model:
@@ -197,6 +212,8 @@ class ModelUpdateRecord:
continue continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version): if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
version_base = _normalize_base_model(version.base_model) version_base = _normalize_base_model(version.base_model)
if version_base != normalized_base: if version_base != normalized_base:
continue continue
@@ -209,6 +226,8 @@ class ModelUpdateRecord:
class ModelUpdateService: class ModelUpdateService:
"""Persist and query remote model version metadata.""" """Persist and query remote model version metadata."""
_SQLITE_MAX_VARIABLES = 500
_SCHEMA = """ _SCHEMA = """
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS model_update_status ( CREATE TABLE IF NOT EXISTS model_update_status (
@@ -228,6 +247,7 @@ class ModelUpdateService:
preview_url TEXT, preview_url TEXT,
is_in_library INTEGER NOT NULL DEFAULT 0, is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0, should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
); );
@@ -463,6 +483,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0" "ADD COLUMN is_early_access INTEGER NOT NULL DEFAULT 0"
), ),
"usage_control": (
"ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT"
),
} }
for column, statement in migrations.items(): for column, statement in migrations.items():
@@ -1335,6 +1359,7 @@ class ModelUpdateService:
# Check availability field from bulk API for basic EA detection # Check availability field from bulk API for basic EA detection
availability = _normalize_string(entry.get("availability")) availability = _normalize_string(entry.get("availability"))
is_early_access = availability == "EarlyAccess" is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl"))
return ModelVersionRecord( return ModelVersionRecord(
version_id=version_id, version_id=version_id,
@@ -1348,6 +1373,7 @@ class ModelUpdateService:
early_access_ends_at=early_access_ends_at, early_access_ends_at=early_access_ends_at,
sort_index=index, sort_index=index,
is_early_access=is_early_access, is_early_access=is_early_access,
usage_control=usage_control,
) )
def _extract_size_bytes(self, files) -> Optional[int]: def _extract_size_bytes(self, files) -> Optional[int]:
@@ -1439,33 +1465,41 @@ class ModelUpdateService:
if not model_ids: if not model_ids:
return {} return {}
params = tuple(model_ids) ids = list(model_ids)
placeholders = ",".join("?" for _ in params) status_rows: list = []
version_rows: list = []
with self._connect() as conn: with self._connect() as conn:
status_rows = conn.execute( for start in range(0, len(ids), self._SQLITE_MAX_VARIABLES):
f""" chunk = tuple(ids[start : start + self._SQLITE_MAX_VARIABLES])
SELECT model_id, model_type, last_checked_at, should_ignore_model placeholders = ",".join("?" for _ in chunk)
FROM model_update_status
WHERE model_id IN ({placeholders}) chunk_status = conn.execute(
""", f"""
params, SELECT model_id, model_type, last_checked_at, should_ignore_model
).fetchall() FROM model_update_status
WHERE model_id IN ({placeholders})
""",
chunk,
).fetchall()
status_rows.extend(chunk_status)
chunk_versions = conn.execute(
f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
""",
chunk,
).fetchall()
version_rows.extend(chunk_versions)
if not status_rows: if not status_rows:
return {} return {}
version_rows = conn.execute(
f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
""",
params,
).fetchall()
versions_by_model: Dict[int, List[ModelVersionRecord]] = {} versions_by_model: Dict[int, List[ModelVersionRecord]] = {}
for row in version_rows: for row in version_rows:
model_id = int(row["model_id"]) model_id = int(row["model_id"])
@@ -1482,6 +1516,7 @@ class ModelUpdateService:
early_access_ends_at=row["early_access_ends_at"], early_access_ends_at=row["early_access_ends_at"],
sort_index=_normalize_int(row["sort_index"]) or 0, sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]), is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"],
) )
) )
@@ -1538,8 +1573,8 @@ class ModelUpdateService:
INSERT INTO model_update_versions ( INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at, version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access is_early_access, usage_control
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -1554,6 +1589,7 @@ class ModelUpdateService:
1 if version.should_ignore else 0, 1 if version.should_ignore else 0,
version.early_access_ends_at, version.early_access_ends_at,
1 if version.is_early_access else 0, 1 if version.is_early_access else 0,
version.usage_control,
), ),
) )
conn.commit() conn.commit()

View File

@@ -1815,6 +1815,15 @@ class RecipeScanner:
return await self._lora_scanner.get_model_info_by_name(name) 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( async def get_paginated_data(
self, self,
page: int, page: int,

View File

@@ -508,6 +508,10 @@ class RecipePersistenceService:
most_common_base_model = ( most_common_base_model = (
max(base_model_counts.items(), key=lambda item: item[1])[0] if base_model_counts else "" 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 = { recipe_data = {
"id": recipe_id, "id": recipe_id,
@@ -515,9 +519,8 @@ class RecipePersistenceService:
"title": recipe_name, "title": recipe_name,
"modified": time.time(), "modified": time.time(),
"created_date": 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, "loras": loras_data,
"checkpoint": self._sanitize_checkpoint_entry(metadata.get("checkpoint", "")),
"gen_params": { "gen_params": {
key: value key: value
for key, value in metadata.items() for key, value in metadata.items()
@@ -525,6 +528,8 @@ class RecipePersistenceService:
}, },
"loras_stack": lora_stack, "loras_stack": lora_stack,
} }
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry
json_filename = f"{recipe_id}.recipe.json" json_filename = f"{recipe_id}.recipe.json"
json_path = os.path.join(recipes_dir, json_filename) json_path = os.path.join(recipes_dir, json_filename)
@@ -546,6 +551,91 @@ class RecipePersistenceService:
# Helper methods --------------------------------------------------- # 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]]: def _extract_checkpoint_entry(self, metadata: dict[str, Any]) -> Optional[dict[str, Any]]:
"""Pull a checkpoint entry from various metadata locations.""" """Pull a checkpoint entry from various metadata locations."""

View File

@@ -2,6 +2,7 @@ import asyncio
import copy import copy
import json import json
import os import os
import posixpath
import shutil import shutil
import tempfile import tempfile
import logging import logging
@@ -80,6 +81,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"folder_paths": {}, "folder_paths": {},
"extra_folder_paths": {}, "extra_folder_paths": {},
"example_images_path": "", "example_images_path": "",
"example_images_open_mode": "system",
"example_images_local_root": "",
"example_images_open_uri_template": "",
"optimize_example_images": True, "optimize_example_images": True,
"auto_download_example_images": False, "auto_download_example_images": False,
"blur_mature_content": True, "blur_mature_content": True,
@@ -93,6 +97,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(),
"model_name_display": "model_name", "model_name_display": "model_name",
"model_card_footer_action": "replace_preview", "model_card_footer_action": "replace_preview",
"show_version_on_card": True,
"update_flag_strategy": "same_base", "update_flag_strategy": "same_base",
"auto_organize_exclusions": [], "auto_organize_exclusions": [],
"metadata_refresh_skip_paths": [], "metadata_refresh_skip_paths": [],
@@ -103,6 +108,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: class SettingsManager:
def __init__(self): def __init__(self):
self.settings_file = ensure_settings_file(logger) self.settings_file = ensure_settings_file(logger)
@@ -763,34 +777,29 @@ class SettingsManager:
if self._preserve_disk_template: if self._preserve_disk_template:
return return
folder_paths = self.settings.get("folder_paths", {})
updated = False updated = False
def _check_and_auto_set(key: str, setting_key: str) -> bool: def _check_and_auto_set(key: str, setting_key: str) -> bool:
"""Repair default roots when empty or no longer present.""" """Repair default roots when empty or no longer present."""
current = self.settings.get(setting_key, "") current = self.settings.get(setting_key, "")
candidates = folder_paths.get(key, []) primary_candidates = self._get_valid_root_candidates(key)
if not isinstance(candidates, list) or not candidates: if not primary_candidates:
return False return False
# Filter valid path strings allowed_roots = self._get_allowed_roots(key)
valid_paths = [p for p in candidates if isinstance(p, str) and p.strip()] if current and _normalize_root_identity(current) in allowed_roots:
if not valid_paths:
return False return False
if current in valid_paths: self.settings[setting_key] = primary_candidates[0]
return False
self.settings[setting_key] = valid_paths[0]
if current: if current:
logger.info( 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, setting_key,
current, current,
valid_paths[0], primary_candidates[0],
) )
else: 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 return True
# Process all model types # Process all model types
@@ -813,6 +822,36 @@ class SettingsManager:
else: else:
self._save_settings() 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: def _check_environment_variables(self) -> None:
"""Check for environment variables and update settings if needed""" """Check for environment variables and update settings if needed"""
env_api_key = os.environ.get("CIVITAI_API_KEY") env_api_key = os.environ.get("CIVITAI_API_KEY")

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 # These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
DIFFUSION_MODEL_BASE_MODELS = frozenset( DIFFUSION_MODEL_BASE_MODELS = frozenset(
[ [
"Anima",
"ZImageTurbo", "ZImageTurbo",
"ZImageBase", "ZImageBase",
"Wan Video 1.3B t2v", "Wan Video 1.3B t2v",

View File

@@ -1,17 +1,81 @@
import logging import logging
import os import os
import sys import re
import subprocess import subprocess
import sys
from urllib.parse import quote
from aiohttp import web from aiohttp import web
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
from ..utils.example_images_paths import ( from ..utils.example_images_paths import (
get_model_folder, get_model_folder,
get_model_relative_path,
) )
from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS from ..utils.constants import SUPPORTED_MEDIA_EXTENSIONS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:/")
def _is_within_root(path: str, root: str) -> bool:
try:
return os.path.commonpath([os.path.abspath(path), os.path.abspath(root)]) == os.path.abspath(root)
except ValueError:
return False
def _join_local_example_path(local_root: str, relative_path: str) -> str:
separator = "\\" if "\\" in local_root and "/" not in local_root else "/"
normalized_root = local_root.rstrip("\\/")
normalized_relative = relative_path.replace("/", separator)
if not normalized_root:
return normalized_relative
return f"{normalized_root}{separator}{normalized_relative}"
def _build_file_uri(path: str) -> str:
normalized = path.replace("\\", "/")
if _WINDOWS_DRIVE_PATTERN.match(normalized):
return f"file:///{quote(normalized, safe='/:')}"
if normalized.startswith("/"):
return f"file://{quote(normalized, safe='/:')}"
return f"file:///{quote(normalized.lstrip('/'), safe='/:')}"
def _render_open_uri_template(template: str, local_path: str, relative_path: str) -> str:
file_uri = _build_file_uri(local_path)
replacements = {
"{{local_path}}": local_path,
"{{encoded_local_path}}": quote(local_path, safe=""),
"{{relative_path}}": relative_path,
"{{encoded_relative_path}}": quote(relative_path, safe=""),
"{{file_uri}}": file_uri,
"{{encoded_file_uri}}": quote(file_uri, safe=""),
}
rendered = template
for placeholder, value in replacements.items():
rendered = rendered.replace(placeholder, value)
return rendered
def _open_system_folder(model_folder: str) -> dict[str, object]:
if os.name == "nt": # Windows
os.startfile(model_folder)
elif os.name == "posix": # macOS and Linux
if sys.platform == "darwin": # macOS
subprocess.Popen(["open", model_folder])
else: # Linux
subprocess.Popen(["xdg-open", model_folder])
return {
"success": True,
"message": f"Opened example images folder for {model_folder}",
"path": model_folder,
}
class ExampleImagesFileManager: class ExampleImagesFileManager:
"""Manages access and operations for example image files""" """Manages access and operations for example image files"""
@@ -54,7 +118,7 @@ class ExampleImagesFileManager:
}, status=500) }, status=500)
# Path validation: ensure model_folder is under example_images_path # Path validation: ensure model_folder is under example_images_path
if not model_folder.startswith(os.path.abspath(example_images_path)): if not _is_within_root(model_folder, example_images_path):
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': 'Invalid model folder path' 'error': 'Invalid model folder path'
@@ -66,20 +130,40 @@ class ExampleImagesFileManager:
'success': False, 'success': False,
'error': 'No example images found for this model. Download example images first.' 'error': 'No example images found for this model. Download example images first.'
}, status=404) }, status=404)
# Open folder in file explorer root_path = os.path.abspath(example_images_path)
if os.name == 'nt': # Windows relative_path = os.path.relpath(model_folder, root_path).replace("\\", "/")
os.startfile(model_folder) open_mode = settings_manager.get("example_images_open_mode") or "system"
elif os.name == 'posix': # macOS and Linux
if sys.platform == 'darwin': # macOS if open_mode == "clipboard":
subprocess.Popen(['open', model_folder]) local_root = settings_manager.get("example_images_local_root") or root_path
else: # Linux local_path = _join_local_example_path(local_root, relative_path)
subprocess.Popen(['xdg-open', model_folder]) return web.json_response({
'success': True,
return web.json_response({ 'mode': 'clipboard',
'success': True, 'path': local_path,
'message': f'Opened example images folder for model {model_hash}' 'relative_path': relative_path,
}) })
if open_mode == "uri_template":
local_root = settings_manager.get("example_images_local_root") or root_path
uri_template = settings_manager.get("example_images_open_uri_template") or ""
if not uri_template.strip():
return web.json_response({
'success': False,
'error': 'No example image open URI template configured.'
}, status=400)
local_path = _join_local_example_path(local_root, relative_path)
return web.json_response({
'success': True,
'mode': 'uri',
'path': local_path,
'relative_path': relative_path,
'uri': _render_open_uri_template(uri_template, local_path, relative_path),
})
return web.json_response(_open_system_folder(model_folder))
except Exception as e: except Exception as e:
logger.error(f"Failed to open example images folder: {e}", exc_info=True) logger.error(f"Failed to open example images folder: {e}", exc_info=True)
@@ -143,7 +227,7 @@ class ExampleImagesFileManager:
file_ext = os.path.splitext(file)[1].lower() file_ext = os.path.splitext(file)[1].lower()
if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or if (file_ext in SUPPORTED_MEDIA_EXTENSIONS['images'] or
file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']): file_ext in SUPPORTED_MEDIA_EXTENSIONS['videos']):
relative_path = get_model_relative_path(model_hash) relative_path = os.path.relpath(model_folder, os.path.abspath(example_images_path)).replace("\\", "/")
files.append({ files.append({
'name': file, 'name': file,
'path': f'/example_images_static/{relative_path}/{file}', 'path': f'/example_images_static/{relative_path}/{file}',
@@ -227,4 +311,4 @@ class ExampleImagesFileManager:
return web.json_response({ return web.json_response({
'has_images': False, 'has_images': False,
'error': str(e) 'error': str(e)
}) })

View File

@@ -1,15 +1,142 @@
import piexif
import json import json
import logging import logging
from typing import Optional
from io import BytesIO
import os import os
from io import BytesIO
from typing import Any, Optional
import piexif
from PIL import Image, PngImagePlugin from PIL import Image, PngImagePlugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ExifUtils: class ExifUtils:
"""Utility functions for working with EXIF data in images""" """Utility functions for working with EXIF data in images"""
@staticmethod
def _decode_user_comment(user_comment: Any) -> Optional[str]:
if user_comment is None:
return None
if isinstance(user_comment, bytes):
if user_comment.startswith(b"UNICODE\0"):
return user_comment[8:].decode("utf-16be", errors="ignore")
return user_comment.decode("utf-8", errors="ignore")
if isinstance(user_comment, str):
return user_comment
return str(user_comment)
@staticmethod
def _decode_exif_text(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="ignore")
if isinstance(value, str):
return value
return str(value)
@staticmethod
def _load_structured_metadata(image_path: str) -> dict[str, Optional[str]]:
metadata = {
"parameters": None,
"prompt": None,
"workflow": None,
"comment": None,
}
with Image.open(image_path) as img:
info = getattr(img, "info", {}) or {}
if "parameters" in info:
metadata["parameters"] = info["parameters"]
if "prompt" in info:
metadata["prompt"] = info["prompt"]
if "workflow" in info:
metadata["workflow"] = info["workflow"]
if img.format not in ["JPEG", "TIFF", "WEBP"]:
exif = img.getexif()
if exif and piexif.ExifIFD.UserComment in exif:
metadata["comment"] = ExifUtils._decode_user_comment(
exif[piexif.ExifIFD.UserComment]
)
try:
exif_dict = piexif.load(image_path)
except Exception as e:
logger.debug(f"Error loading EXIF data: {e}")
exif_dict = {}
if piexif.ExifIFD.UserComment in exif_dict.get("Exif", {}):
metadata["comment"] = ExifUtils._decode_user_comment(
exif_dict["Exif"][piexif.ExifIFD.UserComment]
)
image_description = ExifUtils._decode_exif_text(
exif_dict.get("0th", {}).get(piexif.ImageIFD.ImageDescription)
)
if image_description:
if image_description.startswith("Workflow:"):
metadata["workflow"] = image_description[len("Workflow:") :]
elif not metadata["prompt"]:
metadata["prompt"] = image_description
if not metadata["parameters"] and metadata["comment"]:
metadata["parameters"] = metadata["comment"]
return metadata
@staticmethod
def _build_pnginfo(img: Image.Image, metadata_fields: dict[str, Optional[str]]) -> PngImagePlugin.PngInfo:
png_info = PngImagePlugin.PngInfo()
existing_info = getattr(img, "info", {}) or {}
managed_keys = {"parameters", "prompt", "workflow"}
for key, value in existing_info.items():
if key in {"exif", "dpi", "transparency", "gamma", "aspect"}:
continue
if key in managed_keys:
continue
if isinstance(value, str):
png_info.add_text(key, value)
for key in managed_keys:
value = metadata_fields.get(key)
if value:
png_info.add_text(key, value)
return png_info
@staticmethod
def _build_exif_bytes(
metadata_fields: dict[str, Optional[str]], existing_exif: bytes | None = None
) -> bytes:
try:
exif_dict = piexif.load(existing_exif or b"")
except Exception:
exif_dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}}
exif_dict.setdefault("0th", {})
exif_dict.setdefault("Exif", {})
parameters = metadata_fields.get("parameters")
workflow = metadata_fields.get("workflow")
prompt = metadata_fields.get("prompt")
if parameters:
exif_dict["Exif"][piexif.ExifIFD.UserComment] = (
b"UNICODE\0" + parameters.encode("utf-16be")
)
else:
exif_dict["Exif"].pop(piexif.ExifIFD.UserComment, None)
if workflow:
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = f"Workflow:{workflow}"
elif prompt:
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = prompt
else:
exif_dict["0th"].pop(piexif.ImageIFD.ImageDescription, None)
return piexif.dump(exif_dict)
@staticmethod @staticmethod
def extract_image_metadata(image_path: str) -> Optional[str]: def extract_image_metadata(image_path: str) -> Optional[str]:
@@ -28,48 +155,12 @@ class ExifUtils:
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm']:
return None return None
# First try to open the image metadata = ExifUtils._load_structured_metadata(image_path)
with Image.open(image_path) as img: return (
# Method 1: Check for parameters in image info metadata.get("parameters")
if hasattr(img, 'info') and 'parameters' in img.info: or metadata.get("prompt")
return img.info['parameters'] or metadata.get("workflow")
)
# Method 2: Check EXIF UserComment field
if img.format not in ['JPEG', 'TIFF', 'WEBP']:
# For non-JPEG/TIFF/WEBP images, try to get EXIF through PIL
exif = img.getexif()
if exif and piexif.ExifIFD.UserComment in exif:
user_comment = exif[piexif.ExifIFD.UserComment]
if isinstance(user_comment, bytes):
if user_comment.startswith(b'UNICODE\0'):
return user_comment[8:].decode('utf-16be')
return user_comment.decode('utf-8', errors='ignore')
return user_comment
# For JPEG/TIFF/WEBP, use piexif
try:
exif_dict = piexif.load(image_path)
if piexif.ExifIFD.UserComment in exif_dict.get('Exif', {}):
user_comment = exif_dict['Exif'][piexif.ExifIFD.UserComment]
if isinstance(user_comment, bytes):
if user_comment.startswith(b'UNICODE\0'):
user_comment = user_comment[8:].decode('utf-16be')
else:
user_comment = user_comment.decode('utf-8', errors='ignore')
return user_comment
except Exception as e:
logger.debug(f"Error loading EXIF data: {e}")
# Method 3: Check PNG metadata for workflow info (for ComfyUI images)
if img.format == 'PNG':
# Look for workflow or prompt metadata in PNG chunks
for key in img.info:
if key in ['workflow', 'prompt', 'parameters']:
return img.info[key]
return None
except Exception as e: except Exception as e:
logger.error(f"Error extracting image metadata: {e}", exc_info=True) logger.error(f"Error extracting image metadata: {e}", exc_info=True)
return None return None
@@ -92,50 +183,26 @@ class ExifUtils:
if ext in ['.mp4', '.webm']: if ext in ['.mp4', '.webm']:
return image_path return image_path
# Load the image and check its format metadata_fields = ExifUtils._load_structured_metadata(image_path)
metadata_fields["parameters"] = metadata
with Image.open(image_path) as img: with Image.open(image_path) as img:
img_format = img.format img_format = img.format
# For PNG, try to update parameters directly if img_format == "PNG":
if img_format == 'PNG': png_info = ExifUtils._build_pnginfo(img, metadata_fields)
# Use PngInfo instead of plain dictionary img.save(image_path, format="PNG", pnginfo=png_info)
png_info = PngImagePlugin.PngInfo()
png_info.add_text("parameters", metadata)
img.save(image_path, format='PNG', pnginfo=png_info)
return image_path return image_path
# For WebP format, use PIL's exif parameter directly exif_bytes = ExifUtils._build_exif_bytes(
elif img_format == 'WEBP': metadata_fields, img.info.get("exif")
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}} )
exif_bytes = piexif.dump(exif_dict) save_kwargs = {"exif": exif_bytes}
if img_format == "WEBP":
# Save with the exif data save_kwargs["quality"] = 85
img.save(image_path, format='WEBP', exif=exif_bytes, quality=85)
return image_path img.save(image_path, format=img_format, **save_kwargs)
# For other formats, use standard EXIF approach
else:
try:
exif_dict = piexif.load(img.info.get('exif', b''))
except:
exif_dict = {'0th':{}, 'Exif':{}, 'GPS':{}, 'Interop':{}, '1st':{}}
# If no Exif dictionary exists, create one
if 'Exif' not in exif_dict:
exif_dict['Exif'] = {}
# Update the UserComment field - use UNICODE format
unicode_bytes = metadata.encode('utf-16be')
metadata_bytes = b'UNICODE\0' + unicode_bytes
exif_dict['Exif'][piexif.ExifIFD.UserComment] = metadata_bytes
# Convert EXIF dict back to bytes
exif_bytes = piexif.dump(exif_dict)
# Save the image with updated EXIF data
img.save(image_path, exif=exif_bytes)
return image_path return image_path
except Exception as e: except Exception as e:
logger.error(f"Error updating metadata in {image_path}: {e}") logger.error(f"Error updating metadata in {image_path}: {e}")
@@ -297,12 +364,12 @@ class ExifUtils:
raise ValueError(f"Cannot process corrupt image data: {e}") raise ValueError(f"Cannot process corrupt image data: {e}")
# Extract metadata if needed and valid # Extract metadata if needed and valid
metadata = None metadata_fields = None
if preserve_metadata: if preserve_metadata:
try: try:
if isinstance(image_data, str) and os.path.exists(image_data): if isinstance(image_data, str) and os.path.exists(image_data):
# For file path, extract directly # For file path, extract directly
metadata = ExifUtils.extract_image_metadata(image_data) metadata_fields = ExifUtils._load_structured_metadata(image_data)
else: else:
# For binary data, save to temp file first # For binary data, save to temp file first
import tempfile import tempfile
@@ -310,7 +377,7 @@ class ExifUtils:
temp_path = temp_file.name temp_path = temp_file.name
temp_file.write(image_data) temp_file.write(image_data)
try: try:
metadata = ExifUtils.extract_image_metadata(temp_path) metadata_fields = ExifUtils._load_structured_metadata(temp_path)
except Exception as e: except Exception as e:
logger.warning(f"Failed to extract metadata from temp file: {e}") logger.warning(f"Failed to extract metadata from temp file: {e}")
finally: finally:
@@ -363,14 +430,13 @@ class ExifUtils:
optimized_data = output.getvalue() optimized_data = output.getvalue()
# Handle metadata preservation if requested and available # Handle metadata preservation if requested and available
if preserve_metadata and metadata: if preserve_metadata and metadata_fields:
try: try:
if save_format == 'WEBP': if save_format == 'WEBP':
# For WebP format, directly save with metadata # For WebP format, directly save with metadata
try: try:
output_with_metadata = BytesIO() output_with_metadata = BytesIO()
exif_dict = {'Exif': {piexif.ExifIFD.UserComment: b'UNICODE\0' + metadata.encode('utf-16be')}} exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
exif_bytes = piexif.dump(exif_dict)
resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality) resized_img.save(output_with_metadata, format='WEBP', exif=exif_bytes, quality=quality)
optimized_data = output_with_metadata.getvalue() optimized_data = output_with_metadata.getvalue()
except Exception as e: except Exception as e:
@@ -383,8 +449,9 @@ class ExifUtils:
temp_file.write(optimized_data) temp_file.write(optimized_data)
try: try:
# Add metadata ExifUtils.update_image_metadata(
ExifUtils.update_image_metadata(temp_path, metadata) temp_path, metadata_fields.get("parameters") or ""
)
# Read back the file # Read back the file
with open(temp_path, 'rb') as f: with open(temp_path, 'rb') as f:
optimized_data = f.read() optimized_data = f.read()

View File

@@ -0,0 +1,354 @@
#!/usr/bin/env python3
"""
Migrate metadata from old sidecar JSON format to LoRA Manager's metadata.json format.
This script automatically discovers model folders from LoRA Manager's settings.json,
finds JSON files with the same basename as model files (e.g., `model.json` for
`model.safetensors`), and migrates their content to the corresponding `.metadata.json` files.
Fields migrated:
- "activation text" → civitai.trainedWords (array of trigger words)
- "preferred weight" → usage_tips.strength (LoRA only, skipped for Checkpoint)
- "notes" → notes (user-defined notes)
Supported model types: LoRA, Checkpoint
Usage:
python scripts/migrate_legacy_metadata.py [--dry-run] [--verbose]
The script will:
1. Read settings.json to find all configured model folders
2. Recursively scan for model files (.safetensors, .ckpt, .pt, .pth, .bin)
3. Find corresponding legacy metadata JSON files
4. Migrate data to .metadata.json files
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
APP_NAME = "ComfyUI-LoRA-Manager"
MODEL_EXTENSIONS = {".safetensors", ".ckpt", ".pt", ".pth", ".bin"}
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
def resolve_settings_path() -> Path:
repo_root = Path(__file__).parent.parent.resolve()
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 load_json(path: Path) -> dict[str, Any]:
try:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return {}
except json.JSONDecodeError as exc:
logger.error(f"Invalid JSON in {path}: {exc}")
return {}
except OSError as exc:
logger.error(f"Cannot read {path}: {exc}")
return {}
def expand_path(value: str) -> str:
return str(Path(value).expanduser().resolve(strict=False))
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 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 get_model_roots(settings: dict[str, Any]) -> dict[str, list[str]]:
roots: dict[str, list[str]] = {}
active_library = settings.get("active_library") or "default"
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 find_model_files(directory: Path) -> list[Path]:
model_files = []
for ext in MODEL_EXTENSIONS:
model_files.extend(directory.rglob(f"*{ext}"))
return model_files
def find_legacy_metadata(model_path: Path) -> Path | None:
base_name = model_path.stem
legacy_path = model_path.with_name(f"{base_name}.json")
if legacy_path.exists() and legacy_path.is_file():
return legacy_path
return None
def load_legacy_metadata(legacy_path: Path) -> dict[str, Any] | None:
try:
with open(legacy_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in legacy file {legacy_path}: {e}")
return None
except Exception as e:
logger.error(f"Error reading legacy file {legacy_path}: {e}")
return None
def load_metadata(metadata_path: Path) -> dict[str, Any]:
if not metadata_path.exists():
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.warning(f"Invalid JSON in metadata file {metadata_path}: {e}. Starting fresh.")
return {}
except Exception as e:
logger.error(f"Error reading metadata file {metadata_path}: {e}")
return {}
def save_metadata(metadata_path: Path, data: dict[str, Any], dry_run: bool = False) -> bool:
if dry_run:
logger.info(f"[DRY RUN] Would save metadata to: {metadata_path}")
return True
temp_path = metadata_path.with_suffix(".tmp")
try:
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(temp_path, metadata_path)
return True
except Exception as e:
logger.error(f"Error saving metadata to {metadata_path}: {e}")
if temp_path.exists():
try:
temp_path.unlink()
except:
pass
return False
def migrate_metadata(
legacy_data: dict[str, Any],
existing_metadata: dict[str, Any],
model_type: str
) -> dict[str, Any] | None:
metadata = existing_metadata.copy()
changes_made = False
if "civitai" not in metadata:
metadata["civitai"] = {}
activation_text = legacy_data.get("activation text")
if activation_text and isinstance(activation_text, str):
trigger_words = [
word.strip()
for word in activation_text.replace("\n", ",").split(",")
if word.strip()
]
if trigger_words:
existing_trained = metadata["civitai"].get("trainedWords", [])
if not isinstance(existing_trained, list):
existing_trained = []
merged = list(dict.fromkeys(existing_trained + trigger_words))
if merged != existing_trained:
metadata["civitai"]["trainedWords"] = merged
changes_made = True
logger.debug(f" Migrated activation text: {trigger_words}")
if model_type == "lora":
preferred_weight = legacy_data.get("preferred weight")
if preferred_weight is not None:
try:
weight_value = float(preferred_weight)
usage_tips_str = metadata.get("usage_tips", "{}")
if isinstance(usage_tips_str, str):
try:
usage_tips = json.loads(usage_tips_str)
except json.JSONDecodeError:
usage_tips = {}
elif isinstance(usage_tips_str, dict):
usage_tips = usage_tips_str
else:
usage_tips = {}
if "strength" not in usage_tips:
usage_tips["strength"] = weight_value
metadata["usage_tips"] = json.dumps(usage_tips, ensure_ascii=False)
changes_made = True
logger.debug(f" Migrated preferred weight: {weight_value}")
except (ValueError, TypeError) as e:
logger.warning(f" Could not parse preferred weight '{preferred_weight}': {e}")
else:
if legacy_data.get("preferred weight") is not None:
logger.debug(" Skipping 'preferred weight' for non-LoRA model")
notes = legacy_data.get("notes")
if notes and isinstance(notes, str) and notes.strip():
existing_notes = metadata.get("notes", "")
if not existing_notes:
metadata["notes"] = notes.strip()
changes_made = True
logger.debug(" Migrated notes")
elif notes.strip() not in existing_notes:
metadata["notes"] = f"{existing_notes}\n\n{notes.strip()}".strip()
changes_made = True
logger.debug(" Appended notes")
return metadata if changes_made else None
def process_model(model_path: Path, model_type: str, dry_run: bool = False) -> bool:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
return True
logger.info(f"Processing: {model_path.name} ({model_type})")
logger.info(f" Found legacy metadata: {legacy_path.name}")
legacy_data = load_legacy_metadata(legacy_path)
if legacy_data is None:
return False
metadata_path = model_path.with_suffix(".metadata.json")
existing_metadata = load_metadata(metadata_path)
migrated = migrate_metadata(legacy_data, existing_metadata, model_type)
if migrated is None:
logger.info(" No changes needed (fields already exist or no migratable data)")
return True
if save_metadata(metadata_path, migrated, dry_run):
logger.info(f" ✓ Successfully migrated metadata to: {metadata_path.name}")
return True
else:
logger.error(" ✗ Failed to save metadata")
return False
def main() -> int:
parser = argparse.ArgumentParser(
description="Migrate legacy metadata JSON files to LoRA Manager's metadata.json format.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python scripts/migrate_legacy_metadata.py
python scripts/migrate_legacy_metadata.py --dry-run
python scripts/migrate_legacy_metadata.py --verbose
"""
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Preview changes without modifying any files"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Enable verbose output"
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
settings_path = resolve_settings_path()
logger.info(f"Using settings: {settings_path}")
settings = load_json(settings_path)
if not settings:
logger.error("Could not load settings.json. Please ensure LoRA Manager is configured.")
return 1
roots = get_model_roots(settings)
if not roots:
logger.error("No model folders configured in settings.json.")
return 1
lora_roots = roots.get("loras", [])
checkpoint_roots = roots.get("checkpoints", []) + roots.get("unet", [])
all_roots = []
for root_list in [lora_roots, checkpoint_roots]:
for root in root_list:
path = Path(root)
if path.exists() and path.is_dir():
all_roots.append((path, "lora" if root in lora_roots else "checkpoint"))
if not all_roots:
logger.error("No valid model folders found.")
return 1
logger.info(f"Found {len(lora_roots)} LoRA root(s), {len(checkpoint_roots)} Checkpoint root(s)")
processed = 0
migrated = 0
errors = 0
skipped = 0
lora_count = 0
checkpoint_count = 0
for root_path, model_type in all_roots:
logger.info(f"Scanning: {root_path} ({model_type})")
model_files = find_model_files(root_path)
logger.debug(f" Found {len(model_files)} model files")
for model_path in model_files:
legacy_path = find_legacy_metadata(model_path)
if not legacy_path:
skipped += 1
continue
processed += 1
if process_model(model_path, model_type, dry_run=args.dry_run):
migrated += 1
if model_type == "lora":
lora_count += 1
else:
checkpoint_count += 1
else:
errors += 1
logger.info("\n" + "=" * 50)
logger.info("Migration Summary:")
logger.info(f" Models with legacy metadata: {processed}")
logger.info(f" Successfully migrated: {migrated}")
logger.info(f" - LoRA models: {lora_count}")
logger.info(f" - Checkpoint models: {checkpoint_count}")
logger.info(f" Errors: {errors}")
logger.info(f" Skipped (no legacy file): {skipped}")
if args.dry_run:
logger.info("\n [DRY RUN MODE - No files were modified]")
return 0 if errors == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -15,5 +15,8 @@
"C:/path/to/another/embeddings_folder" "C:/path/to/another/embeddings_folder"
] ]
}, },
"example_images_open_mode": "system",
"example_images_local_root": "",
"example_images_open_uri_template": "",
"auto_organize_exclusions": [] "auto_organize_exclusions": []
} }

View File

@@ -87,7 +87,7 @@
.checkbox-label input[type="checkbox"]:checked + .checkmark::after { .checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c'; content: '\f00c';
font-family: 'Font Awesome 6 Free'; font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900; font-weight: 900;
color: var(--lora-text); color: var(--lora-text);
font-size: 12px; font-size: 12px;

View File

@@ -22,6 +22,7 @@
transition: transform 160ms ease-out; transition: transform 160ms ease-out;
aspect-ratio: 896/1152; /* Preserve aspect ratio */ aspect-ratio: 896/1152; /* Preserve aspect ratio */
max-width: 260px; /* Base size */ max-width: 260px; /* Base size */
min-width: 200px; /* Prevent cards from becoming too narrow */
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
cursor: pointer; cursor: pointer;
@@ -328,7 +329,6 @@
} }
.card-actions i { .card-actions i {
margin-left: var(--space-1);
cursor: pointer; cursor: pointer;
color: white; color: white;
transition: opacity 0.2s, transform 0.15s ease; transition: opacity 0.2s, transform 0.15s ease;
@@ -370,7 +370,16 @@
text-shadow: 0 0 5px rgba(255, 193, 7, 0.5); text-shadow: 0 0 5px rgba(255, 193, 7, 0.5);
} }
/* 响应式设计 */ @media (max-width: 1200px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.model-card {
max-width: 240px;
min-width: 180px;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.card-grid { .card-grid {
grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */ grid-template-columns: minmax(260px, 1fr); /* Adjusted minimum size for mobile */
@@ -378,6 +387,7 @@
.model-card { .model-card {
max-width: 100%; /* Allow cards to fill available space on mobile */ max-width: 100%; /* Allow cards to fill available space on mobile */
min-width: 200px;
} }
} }
@@ -507,6 +517,11 @@
font-size: 0.75em; font-size: 0.75em;
} }
/* Hide civitai version name when setting is disabled */
body.hide-card-version .civitai-version {
display: none;
}
/* Prevent text selection on cards and interactive elements */ /* Prevent text selection on cards and interactive elements */
.model-card, .model-card,
.model-card *, .model-card *,
@@ -558,8 +573,13 @@
position: absolute; position: absolute;
box-sizing: border-box; box-sizing: border-box;
transition: transform 160ms ease-out; transition: transform 160ms ease-out;
margin: 0; /* Remove margins, positioning is handled by VirtualScroller */ margin: 0;
width: 100%; /* Allow width to be set by the VirtualScroller */ width: 100%;
}
/* Allow cards to grow beyond 260px in virtual scroll mode */
.virtual-scroll-item.model-card {
max-width: none;
} }
.virtual-scroll-item:hover { .virtual-scroll-item:hover {
@@ -571,11 +591,11 @@
.card-grid.virtual-scroll { .card-grid.virtual-scroll {
display: block; display: block;
position: relative; position: relative;
margin: 0 auto; margin: 0; /* Remove auto margins - positioning handled by VirtualScroller leftOffset */
padding: 4px 0; /* Add top/bottom padding equivalent to card padding */ padding: 4px 0; /* Add top/bottom padding equivalent to card padding */
height: auto; height: auto;
width: 100%; width: 100%;
max-width: 1400px; /* Keep the max-width from original grid */ max-width: none; /* Remove max-width constraint - handled by VirtualScroller */
box-sizing: border-box; /* Include padding in width calculation */ box-sizing: border-box; /* Include padding in width calculation */
overflow-x: hidden; /* Prevent horizontal overflow */ overflow-x: hidden; /* Prevent horizontal overflow */
} }

View File

@@ -22,6 +22,22 @@
gap: 1rem; gap: 1rem;
} }
/* Left section: Logo + Navigation */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Right section: Controls */
.header-right {
display: flex;
align-items: center;
gap: 1rem;
flex-shrink: 0;
}
/* Responsive header container for larger screens */ /* Responsive header container for larger screens */
@media (min-width: 2150px) { @media (min-width: 2150px) {
.header-container { .header-container {
@@ -77,6 +93,7 @@
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.9rem; font-size: 0.9rem;
white-space: nowrap;
} }
.nav-item:hover, .nav-item:hover,
@@ -97,13 +114,99 @@
color: white; color: white;
} }
/* Header search */ /* Header search - Centered with VS Code command palette style */
.header-search { .header-search {
flex: 1; flex: 1;
max-width: 400px; display: flex;
justify-content: center;
max-width: 600px;
margin: 0 auto;
transition: opacity 0.2s ease; transition: opacity 0.2s ease;
} }
/* VS Code command palette style search container */
.header-search .search-container {
width: 100%;
max-width: 600px;
position: relative;
display: flex;
align-items: center;
background: var(--input-bg, var(--card-bg));
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.header-search .search-container:focus-within {
border-color: var(--lora-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px var(--lora-accent);
}
.header-search input {
flex: 1;
width: 100%;
padding: 0.5rem 0.75rem;
padding-left: 2.25rem !important;
padding-right: 5rem !important;
border: none;
background: transparent;
color: var(--text-color);
font-size: 0.95rem;
outline: none;
}
.header-search input::placeholder {
color: var(--text-muted);
}
.header-search .search-icon {
position: absolute;
left: 0.75rem;
color: var(--text-muted);
font-size: 0.9rem;
pointer-events: none;
}
.header-search .search-options-toggle,
.header-search .search-filter-toggle {
position: absolute;
right: 0.5rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--border-radius-xs, 4px);
transition: all 0.2s ease;
}
.header-search .search-options-toggle {
right: 2.25rem;
}
.header-search .search-options-toggle:hover,
.header-search .search-filter-toggle:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.header-search .filter-badge {
position: absolute;
top: 2px;
right: 2px;
width: 8px;
height: 8px;
background: var(--lora-accent);
border-radius: 50%;
font-size: 0;
}
/* Disabled state for header search */ /* Disabled state for header search */
.header-search.disabled { .header-search.disabled {
opacity: 0.5; opacity: 0.5;
@@ -247,44 +350,207 @@
opacity: 1; opacity: 1;
} }
/* Mobile adjustments */ /* Hamburger menu button - hidden by default */
@media (max-width: 768px) { .hamburger-menu-btn {
.app-title { display: none;
display: none; width: 32px;
/* Hide text title on mobile */ height: 32px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.hamburger-menu-btn:hover {
background: var(--lora-accent);
color: white;
}
/* Hamburger dropdown menu */
.hamburger-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm, 6px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 0.5rem;
min-width: 160px;
z-index: var(--z-dropdown, 200);
}
.hamburger-dropdown.active {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.hamburger-dropdown .dropdown-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--border-radius-xs, 4px);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.9rem;
white-space: nowrap;
}
.hamburger-dropdown .dropdown-item:hover {
background: var(--lora-surface-hover, oklch(95% 0.02 256));
color: var(--lora-accent);
}
.hamburger-dropdown .dropdown-item i {
width: 20px;
text-align: center;
}
.hamburger-dropdown .dropdown-divider {
height: 1px;
background: var(--border-color);
margin: 0.25rem 0;
}
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
@media (max-width: 1200px) {
.header-container {
gap: 0.75rem;
padding: 0 12px;
}
.main-nav {
gap: 0.25rem;
}
.nav-item {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
} }
.header-controls { .header-controls {
gap: 4px; gap: 6px;
} }
.header-controls>div { .header-controls > div {
width: 28px; width: 30px;
height: 28px; height: 30px;
}
}
/* Responsive: Hide nav icons at 1100px to save space */
@media (max-width: 1100px) {
.nav-item {
gap: 0;
padding: 0.25rem 0.4rem;
}
.nav-item i {
display: none;
}
.header-search {
max-width: 450px;
}
}
@media (max-width: 950px) {
.app-title {
display: none !important;
}
.header-container {
padding: 0 10px;
gap: 0.5rem;
}
.header-controls {
display: none !important;
}
.hamburger-menu-btn {
display: flex !important;
}
.hamburger-dropdown {
display: none;
}
.hamburger-dropdown.active {
display: flex;
} }
.header-search { .header-search {
max-width: none; max-width: none;
margin: 0 0.5rem; margin: 0;
flex: 1;
min-width: 200px;
} }
.main-nav { .main-nav {
margin-right: 0.5rem; gap: 0.25rem;
margin-right: 0;
}
.nav-item {
padding: 0.25rem 0.35rem;
font-size: 0.8rem;
} }
} }
/* For very small screens */ /* Responsive: Compact mode at 768px */
@media (max-width: 768px) {
.header-search input {
padding: 0.4rem 0.6rem;
padding-left: 2rem !important;
padding-right: 4.5rem !important;
font-size: 0.9rem;
}
.header-search .search-container {
border-radius: var(--border-radius-xs, 4px);
}
}
/* For very small screens - switch nav to icons only */
@media (max-width: 600px) { @media (max-width: 600px) {
.header-container { .header-container {
padding: 0 8px; padding: 0 8px;
gap: 0.4rem;
} }
.main-nav { .main-nav {
display: none; display: flex;
/* Hide navigation on very small screens */ gap: 0.15rem;
margin-right: 0;
} }
.header-search { .nav-item {
flex: 1; padding: 0.25rem;
font-size: 0.75rem;
}
.nav-item span {
display: none;
}
.nav-item i {
display: block;
font-size: 1rem;
} }
} }
/* Position relative for hamburger menu positioning */
.header-right {
position: relative;
}

View File

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

View File

@@ -53,6 +53,10 @@
position: relative; position: relative;
} }
.trigger-word-tag:not(.is-editing) {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.trigger-word-content { .trigger-word-content {
color: var(--lora-accent) !important; color: var(--lora-accent) !important;
font-size: 0.85em; font-size: 0.85em;
@@ -65,6 +69,38 @@
border-color: var(--lora-accent); 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 { .trigger-word-copy {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -109,4 +145,4 @@
padding: 2px 5px; padding: 2px 5px;
border-radius: 8px; border-radius: 8px;
white-space: nowrap; white-space: nowrap;
} }

View File

@@ -374,6 +374,14 @@
background: color-mix(in oklch, var(--lora-surface) 35%, transparent); background: color-mix(in oklch, var(--lora-surface) 35%, transparent);
} }
.version-action-disabled {
background: transparent;
border-color: var(--border-color);
color: var(--text-muted);
opacity: 0.6;
cursor: not-allowed;
}
.version-action:disabled { .version-action:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;

View File

@@ -67,7 +67,6 @@
.early-access-info { .early-access-info {
display: none; display: none;
position: absolute;
top: 100%; top: 100%;
right: 0; right: 0;
background: var(--card-bg); background: var(--card-bg);
@@ -97,7 +96,6 @@
.local-path { .local-path {
display: none; display: none;
position: absolute;
top: 100%; top: 100%;
right: 0; right: 0;
background: var(--card-bg); background: var(--card-bg);

View File

@@ -271,11 +271,16 @@
/* Enhanced Sidebar Breadcrumb Styles */ /* Enhanced Sidebar Breadcrumb Styles */
.sidebar-breadcrumb-container { .sidebar-breadcrumb-container {
margin-top: 8px;
padding: 8px 0; padding: 8px 0;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
background: var(--bg-color); background: var(--bg-color);
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
/* Sticky positioning to stick below header when scrolling
top: 0 means stick at the top of the scroll container (page-content)
which is at header height (48px) from the viewport */
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
} }
.sidebar-breadcrumb-nav { .sidebar-breadcrumb-nav {
@@ -284,7 +289,6 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
font-size: 0.85em; font-size: 0.85em;
padding: 0 8px;
} }
.sidebar-breadcrumb-item { .sidebar-breadcrumb-item {

View File

@@ -21,7 +21,7 @@
top: -54px; top: -54px;
z-index: calc(var(--z-header) - 1); z-index: calc(var(--z-header) - 1);
background: var(--bg-color); background: var(--bg-color);
padding: var(--space-2) 0; padding: var(--space-1) 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05); box-shadow: 0 1px 3px rgba(0,0,0,0.05);
} }
@@ -371,6 +371,14 @@
display: block; display: block;
} }
/* Elevate the controls stacking context above breadcrumb nav when a dropdown is open,
so the dropdown menu isn't obscured. Only active when dropdown is shown to avoid
the entire controls bar (which can wrap to 2 rows on narrow viewports) covering
the sticky breadcrumb. */
.controls:has(.dropdown-group.active) {
z-index: var(--z-header);
}
.dropdown-item { .dropdown-item {
display: block; display: block;
padding: 6px 15px; padding: 6px 15px;
@@ -397,6 +405,33 @@
text-align: center; text-align: center;
} }
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
@media (max-width: 1500px) {
.actions {
flex-wrap: wrap;
gap: var(--space-2);
}
.action-buttons {
flex-wrap: wrap;
gap: var(--space-1);
}
.controls-right {
width: 100%;
justify-content: flex-end;
margin-top: 8px;
padding-left: 0;
}
/* Reduce button sizes to fit better */
.control-group button {
min-width: 80px;
padding: 4px 8px;
font-size: 0.8em;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.actions { .actions {
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -129,6 +129,126 @@ export class HeaderManager {
// Hide search functionality on Statistics page // Hide search functionality on Statistics page
this.updateHeaderForPage(); this.updateHeaderForPage();
// Initialize hamburger menu for mobile
this.initializeHamburgerMenu();
}
initializeHamburgerMenu() {
const hamburgerBtn = document.getElementById('hamburgerMenuBtn');
const hamburgerDropdown = document.getElementById('hamburgerDropdown');
if (!hamburgerBtn || !hamburgerDropdown) return;
// Toggle dropdown on hamburger button click
hamburgerBtn.addEventListener('click', (e) => {
e.stopPropagation();
hamburgerDropdown.classList.toggle('active');
const icon = hamburgerBtn.querySelector('i');
if (hamburgerDropdown.classList.contains('active')) {
icon.classList.remove('fa-bars');
icon.classList.add('fa-times');
} else {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
});
// Handle dropdown item clicks
const dropdownItems = hamburgerDropdown.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
item.addEventListener('click', (e) => {
const action = item.dataset.action;
this.handleHamburgerAction(action);
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
});
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!hamburgerDropdown.contains(e.target) && !hamburgerBtn.contains(e.target)) {
hamburgerDropdown.classList.remove('active');
const icon = hamburgerBtn.querySelector('i');
if (icon) {
icon.classList.remove('fa-times');
icon.classList.add('fa-bars');
}
}
});
// Update theme icon in hamburger menu based on current theme
this.updateHamburgerThemeIcon();
}
handleHamburgerAction(action) {
switch (action) {
case 'theme':
if (typeof toggleTheme === 'function') {
const newTheme = toggleTheme();
// Update theme toggle in header if it exists
const themeToggle = document.querySelector('.theme-toggle');
if (themeToggle) {
themeToggle.classList.remove('theme-light', 'theme-dark', 'theme-auto');
themeToggle.classList.add(`theme-${newTheme}`);
this.updateThemeTooltip(themeToggle, newTheme);
}
this.updateHamburgerThemeIcon();
}
break;
case 'settings':
if (window.settingsManager) {
window.settingsManager.toggleSettings();
}
break;
case 'help':
const helpToggle = document.getElementById('helpToggleBtn');
if (helpToggle) {
helpToggle.click();
}
break;
case 'notifications':
updateService.toggleUpdateModal();
break;
case 'support':
if (window.modalManager) {
window.modalManager.toggleModal('supportModal');
renderSupporters().catch(error => {
console.error('Error loading supporters:', error);
});
}
break;
}
}
updateHamburgerThemeIcon() {
const themeItem = document.querySelector('.dropdown-item[data-action="theme"]');
if (!themeItem) return;
const currentTheme = getStorageItem('theme') || 'auto';
const icon = themeItem.querySelector('i');
const text = themeItem.querySelector('span');
if (icon) {
icon.classList.remove('fa-moon', 'fa-sun', 'fa-adjust');
if (currentTheme === 'light') {
icon.classList.add('fa-sun');
} else if (currentTheme === 'dark') {
icon.classList.add('fa-moon');
} else {
icon.classList.add('fa-adjust');
}
}
// Update text based on current theme
if (text) {
const key = currentTheme === 'light' ? 'header.theme.switchToDark' :
currentTheme === 'dark' ? 'header.theme.switchToLight' :
'header.theme.toggle';
updateElementAttribute(themeItem, 'aria-label', key, {}, '');
}
} }
updateHeaderForPage() { updateHeaderForPage() {

View File

@@ -121,6 +121,7 @@ export class ModelDuplicatesManager {
} }
this.duplicateGroups = data.duplicates || []; this.duplicateGroups = data.duplicates || [];
this._pruneVerificationState();
// Update the badge with the current count // Update the badge with the current count
this.updateDuplicatesBadge(this.duplicateGroups.length); 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) { renderModelCard(model, groupHash) {
// Create basic card structure // Create basic card structure
@@ -619,10 +658,11 @@ export class ModelDuplicatesManager {
toggleSelectAllInGroup(hash) { toggleSelectAllInGroup(hash) {
const checkboxes = document.querySelectorAll(`.selector-checkbox[data-group-hash="${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 // If all are selected, deselect all; otherwise select all
checkboxes.forEach(checkbox => { selectableCheckboxes.forEach(checkbox => {
checkbox.checked = !allSelected; checkbox.checked = !allSelected;
const filePath = checkbox.dataset.filePath; const filePath = checkbox.dataset.filePath;
const card = checkbox.closest('.model-card'); const card = checkbox.closest('.model-card');
@@ -830,11 +870,14 @@ export class ModelDuplicatesManager {
// Process verification results // Process verification results
const verifiedAsDuplicates = data.verified_as_duplicates; const verifiedAsDuplicates = data.verified_as_duplicates;
const mismatchedFiles = data.mismatched_files || []; const mismatchedFiles = data.mismatched_files || [];
this._clearMismatchStateForGroup(group);
// Update mismatchedFiles map // Update mismatchedFiles map
if (data.new_hash_map) { if (data.new_hash_map) {
Object.entries(data.new_hash_map).forEach(([path, hash]) => { Object.entries(data.new_hash_map).forEach(([path, hash]) => {
this.mismatchedFiles.set(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 // Re-render the duplicate groups to show verification status
this.renderDuplicateGroups(); this.renderDuplicateGroups();
this.updateSelectedCount();
// Show appropriate toast message // Show appropriate toast message
if (mismatchedFiles.length > 0) { if (mismatchedFiles.length > 0) {

View File

@@ -645,7 +645,7 @@ export function createModelCard(model, modelType) {
<div class="model-info"> <div class="model-info">
<span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span> <span class="model-name" title="${getDisplayName(model).replace(/"/g, '&quot;')}">${getDisplayName(model)}</span>
<div> <div>
${model.civitai?.name ? `<span class="version-name">${model.civitai.name}</span>` : ''} ${model.civitai?.name ? `<span class="version-name civitai-version">${model.civitai.name}</span>` : ''}
${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''} ${hasUsageCount ? `<span class="version-name" title="${translate('modelCard.usage.timesUsed', {}, 'Times used')}">${model.usage_count}×</span>` : ''}
</div> </div>
</div> </div>

View File

@@ -181,6 +181,13 @@ function isEarlyAccessActive(version) {
} }
} }
function isDownloadAllowed(version) {
if (!version.usageControl) {
return true;
}
return version.usageControl === 'Download';
}
function buildMetaMarkup(version, options = {}) { function buildMetaMarkup(version, options = {}) {
const segments = []; const segments = [];
if (version.baseModel) { if (version.baseModel) {
@@ -230,12 +237,17 @@ function buildBadge(label, tone, options = {}) {
function buildActionButton(label, variant, action, options = {}) { function buildActionButton(label, variant, action, options = {}) {
const attributes = [ const attributes = [
`class="version-action ${variant}"`, `class="version-action ${variant}"`,
`data-version-action="${escapeHtml(action)}"`,
]; ];
if (action) {
attributes.push(`data-version-action="${escapeHtml(action)}"`);
}
if (options.title) { if (options.title) {
attributes.push(`title="${escapeHtml(options.title)}"`); attributes.push(`title="${escapeHtml(options.title)}"`);
attributes.push(`aria-label="${escapeHtml(options.title)}"`); attributes.push(`aria-label="${escapeHtml(options.title)}"`);
} }
if (options.disabled) {
attributes.push('disabled');
}
if (options.extraAttributes) { if (options.extraAttributes) {
attributes.push(options.extraAttributes); attributes.push(options.extraAttributes);
} }
@@ -371,6 +383,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) { if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false; return false;
} }
if (!isDownloadAllowed(version)) {
return false;
}
const versionBase = normalizeBaseModelName(version.baseModel); const versionBase = normalizeBaseModelName(version.baseModel);
if (versionBase !== normalizedBase) { if (versionBase !== normalizedBase) {
return false; return false;
@@ -502,6 +517,17 @@ function renderRow(version, options) {
})); }));
} }
if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
title: translate(
'modals.model.versions.badges.onSiteOnlyTooltip',
{},
'This version is only available for on-site generation on Civitai'
),
}));
}
if (version.shouldIgnore) { if (version.shouldIgnore) {
badges.push(buildBadge(ignoredBadgeLabel, 'muted', { badges.push(buildBadge(ignoredBadgeLabel, 'muted', {
title: translate( title: translate(
@@ -524,25 +550,36 @@ function renderRow(version, options) {
const actions = []; const actions = [];
if (!version.isInLibrary) { if (!version.isInLibrary) {
// Download button with optional EA bolt icon const canDownload = isDownloadAllowed(version);
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : ''; const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
let downloadTitle;
if (!canDownload) {
downloadTitle = translate(
'modals.model.versions.actions.downloadNotAllowedTooltip',
{},
'This version is only available for on-site generation on Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
);
} else {
downloadTitle = translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
);
}
actions.push(buildActionButton( actions.push(buildActionButton(
downloadLabel, downloadLabel,
'version-action-primary', canDownload ? 'version-action-primary' : 'version-action-disabled',
'download', canDownload ? 'download' : '',
{ {
title: isEarlyAccess title: downloadTitle,
? translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
)
: translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
),
iconMarkup: downloadIcon, iconMarkup: downloadIcon,
disabled: !canDownload,
} }
)); ));
} else if (version.filePath) { } else if (version.filePath) {

View File

@@ -8,6 +8,10 @@ import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute, escapeHtml } from './utils.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 * Fetch trained words for a model
* @param {string} filePath - Path to the model file * @param {string} filePath - Path to the model file
@@ -223,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
const escapedWord = escapeHtml(word); const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word); const escapedAttr = escapeAttribute(word);
return ` 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-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -261,6 +265,8 @@ export function setupTriggerWordsEditMode() {
const editBtn = document.querySelector('.edit-trigger-words-btn'); const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return; if (!editBtn) return;
document.querySelectorAll('.trigger-word-tag').forEach(setupDisplayTriggerWordTag);
editBtn.addEventListener('click', async function () { editBtn.addEventListener('click', async function () {
const triggerWordsSection = this.closest('.trigger-words'); const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode'); const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
@@ -293,7 +299,9 @@ export function setupTriggerWordsEditMode() {
// Disable click-to-copy and show delete buttons // Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => { 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 copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn'); const deleteBtn = tag.querySelector('.metadata-delete-btn');
@@ -337,6 +345,12 @@ export function setupTriggerWordsEditMode() {
// Focus the input // Focus the input
addForm.querySelector('input').focus(); addForm.querySelector('input').focus();
const pendingEditTag = triggerWordsSection._pendingTriggerWordEditTag;
delete triggerWordsSection._pendingTriggerWordEditTag;
if (pendingEditTag && document.contains(pendingEditTag)) {
startEditTriggerWord.call(pendingEditTag, { target: pendingEditTag, preventDefault() { }, stopPropagation() { } });
}
} else { } else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = translate('modals.model.triggerWords.edit'); this.title = translate('modals.model.triggerWords.edit');
@@ -350,6 +364,7 @@ export function setupTriggerWordsEditMode() {
// If canceling, restore original trigger words // If canceling, restore original trigger words
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords); restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
} else { } else {
commitActiveTriggerWordEdit(triggerWordsSection);
// If saving, reset UI state on current trigger words // If saving, reset UI state on current trigger words
resetTriggerWordsUIState(triggerWordsSection); resetTriggerWordsUIState(triggerWordsSection);
// Reset the skip restore flag // Reset the skip restore flag
@@ -429,15 +444,18 @@ function deleteTriggerWord(e) {
* @param {HTMLElement} section - The trigger words section * @param {HTMLElement} section - The trigger words section
*/ */
function resetTriggerWordsUIState(section) { function resetTriggerWordsUIState(section) {
commitActiveTriggerWordEdit(section);
const triggerWordTags = section.querySelectorAll('.trigger-word-tag'); const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
triggerWordTags.forEach(tag => { triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy'); const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn'); const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality // 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 // Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = ''; if (copyIcon) copyIcon.style.display = '';
@@ -471,25 +489,236 @@ function restoreOriginalTriggerWords(section, originalWords) {
// Recreate original tags // Recreate original tags
originalWords.forEach(word => { originalWords.forEach(word => {
const tag = document.createElement('div'); tagsContainer.appendChild(createTriggerWordTag(word, false));
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);
}); });
} }
/**
* 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 * Add a new trigger word
* @param {string} word - Trigger word to add * @param {string} word - Trigger word to add
@@ -522,46 +751,16 @@ function addNewTriggerWord(word) {
noTriggerWordsMsg.style.display = 'none'; noTriggerWordsMsg.style.display = 'none';
} }
// Validation: Check length
if (word.split(/\s+/).length > 100) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
// Validation: Check total number // Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag'); const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 100) { if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
showToast('toast.triggerWords.tooMany', {}, 'error'); showToast('toast.triggerWords.tooMany', {}, 'error');
return; return;
} }
// Validation: Check for duplicates if (!validateTriggerWord(word, tagsContainer)) return;
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);
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
tagsContainer.appendChild(newTag); tagsContainer.appendChild(newTag);
// Update status of items in the trained words dropdown // Update status of items in the trained words dropdown
@@ -633,6 +832,8 @@ async function saveTriggerWords() {
const filePath = editBtn.dataset.filePath; const filePath = editBtn.dataset.filePath;
const triggerWordsSection = editBtn.closest('.trigger-words'); const triggerWordsSection = editBtn.closest('.trigger-words');
commitActiveTriggerWordEdit(triggerWordsSection);
// Auto-commit any pending input to prevent data loss // Auto-commit any pending input to prevent data loss
const input = triggerWordsSection.querySelector('.metadata-input'); const input = triggerWordsSection.querySelector('.metadata-input');
if (input && input.value.trim()) { if (input && input.value.trim()) {

View File

@@ -2,6 +2,7 @@ import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { escapeHtml } from '../components/shared/utils.js'; import { escapeHtml } from '../components/shared/utils.js';
import { state } from '../state/index.js';
const MAX_CONSOLE_ENTRIES = 200; const MAX_CONSOLE_ENTRIES = 200;
@@ -258,6 +259,15 @@ export class DoctorManager {
} }
renderInlineDetail(detail) { renderInlineDetail(detail) {
if (detail.conflict_groups || detail.total_conflict_files) {
return `
<div class="doctor-inline-detail">
<strong>${escapeHtml(translate('doctor.status.warning', {}, 'Conflicts'))}</strong>
<div>${escapeHtml(`${detail.conflict_groups || 0} filenames, ${detail.total_conflict_files || 0} files`)}</div>
</div>
`;
}
if (detail.client_version || detail.server_version) { if (detail.client_version || detail.server_version) {
return ` return `
<div class="doctor-inline-detail"> <div class="doctor-inline-detail">
@@ -317,6 +327,9 @@ export class DoctorManager {
case 'repair-cache': case 'repair-cache':
await this.repairCache(); await this.repairCache();
break; break;
case 'resolve-filename-conflicts':
await this.resolveFilenameConflicts();
break;
case 'reload-page': case 'reload-page':
this.reloadUi(); this.reloadUi();
break; break;
@@ -345,6 +358,47 @@ export class DoctorManager {
} }
} }
async resolveFilenameConflicts() {
try {
this.setLoading(true);
const response = await fetch('/api/lm/doctor/resolve-filename-conflicts', { method: 'POST' });
const payload = await response.json();
if (!response.ok || payload.success === false) {
throw new Error(payload.error || 'Failed to resolve filename conflicts.');
}
const renamedCount = payload.count || 0;
showToast(
'doctor.toast.conflictsResolved',
{ count: renamedCount },
'success'
);
// Update scroller items so model cards reflect new filenames immediately
if (state.virtualScroller && payload.renamed) {
for (const renamed of payload.renamed) {
const baseName = renamed.new_filename.replace(/\.[^.]+$/, '');
state.virtualScroller.updateSingleItem(renamed.old_path, {
file_name: baseName,
file_path: renamed.new_path,
});
}
}
await this.refreshDiagnostics({ silent: true });
} catch (error) {
console.error('Doctor filename conflict resolution failed:', error);
showToast(
'doctor.toast.conflictsResolveFailed',
{ message: error.message },
'error'
);
} finally {
this.setLoading(false);
}
}
async exportBundle() { async exportBundle() {
try { try {
this.setLoading(true); this.setLoading(true);

View File

@@ -599,7 +599,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true); await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload // For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false); await getModelApiClient().loadMoreWithVirtualScroll(true, false);
@@ -682,7 +682,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page // Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true); await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true); await getModelApiClient().loadMoreWithVirtualScroll(true, true);
} }

View File

@@ -301,7 +301,7 @@ export class SearchManager {
// Call the appropriate manager's load method based on page type // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true); // true to reset pagination window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
// For models page, reset the page and reload // For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false); getModelApiClient().loadMoreWithVirtualScroll(true, false);

View File

@@ -879,6 +879,12 @@ export class SettingsManager {
modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images'; modelCardFooterActionSelect.value = state.global.settings.model_card_footer_action || 'example_images';
} }
// Set show version on card
const showVersionOnCardCheckbox = document.getElementById('showVersionOnCard');
if (showVersionOnCardCheckbox) {
showVersionOnCardCheckbox.checked = state.global.settings.show_version_on_card !== false;
}
// Set model name display setting // Set model name display setting
const modelNameDisplaySelect = document.getElementById('modelNameDisplay'); const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) { if (modelNameDisplaySelect) {
@@ -914,6 +920,23 @@ export class SettingsManager {
autoDownloadExampleImagesCheckbox.checked = state.global.settings.auto_download_example_images || false; autoDownloadExampleImagesCheckbox.checked = state.global.settings.auto_download_example_images || false;
} }
const exampleImagesOpenModeSelect = document.getElementById('exampleImagesOpenMode');
if (exampleImagesOpenModeSelect) {
exampleImagesOpenModeSelect.value = state.global.settings.example_images_open_mode || 'system';
}
const exampleImagesLocalRootInput = document.getElementById('exampleImagesLocalRoot');
if (exampleImagesLocalRootInput) {
exampleImagesLocalRootInput.value = state.global.settings.example_images_local_root || '';
}
const exampleImagesOpenUriTemplateInput = document.getElementById('exampleImagesOpenUriTemplate');
if (exampleImagesOpenUriTemplateInput) {
exampleImagesOpenUriTemplateInput.value = state.global.settings.example_images_open_uri_template || '';
}
this.updateExampleImagesOpenSettingsVisibility();
// Load download path templates // Load download path templates
this.loadDownloadPathTemplates(); this.loadDownloadPathTemplates();
@@ -2015,6 +2038,25 @@ export class SettingsManager {
} }
} }
updateExampleImagesOpenSettingsVisibility() {
const openMode = state.global.settings.example_images_open_mode || 'system';
const localRootSetting = document.getElementById('exampleImagesLocalRootSetting');
const uriTemplateSetting = document.getElementById('exampleImagesUriTemplateSetting');
if (localRootSetting) {
localRootSetting.style.display = openMode === 'system' ? 'none' : 'block';
}
if (uriTemplateSetting) {
uriTemplateSetting.style.display = openMode === 'uri_template' ? 'block' : 'none';
}
}
async handleExampleImagesOpenModeChange() {
await this.saveSelectSetting('exampleImagesOpenMode', 'example_images_open_mode');
this.updateExampleImagesOpenSettingsVisibility();
}
async loadMetadataArchiveSettings() { async loadMetadataArchiveSettings() {
try { try {
// Load current settings from state // Load current settings from state
@@ -2821,7 +2863,7 @@ export class SettingsManager {
await resetAndReload(false); await resetAndReload(false);
} else if (this.currentPage === 'recipes') { } else if (this.currentPage === 'recipes') {
// Reload the recipes without updating folders // Reload the recipes without updating folders
await window.recipeManager.loadRecipes(); await window.recipeManager.loadRecipes({ preserveScroll: true });
} else if (this.currentPage === 'checkpoints') { } else if (this.currentPage === 'checkpoints') {
// Reload the checkpoints without updating folders // Reload the checkpoints without updating folders
await resetAndReload(false); await resetAndReload(false);
@@ -2854,6 +2896,10 @@ export class SettingsManager {
const cardInfoDisplay = state.global.settings.card_info_display || 'always'; const cardInfoDisplay = state.global.settings.card_info_display || 'always';
document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover'); document.body.classList.toggle('hover-reveal', cardInfoDisplay === 'hover');
// Apply show version on card setting
const showVersionOnCard = state.global.settings.show_version_on_card !== false;
document.body.classList.toggle('hide-card-version', !showVersionOnCard);
const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false; const shouldShowSidebar = state.global.settings.show_folder_sidebar !== false;
if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') { if (sidebarManager && typeof sidebarManager.setSidebarEnabled === 'function') {
sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => { sidebarManager.setSidebarEnabled(shouldShowSidebar).catch((error) => {

View File

@@ -19,7 +19,7 @@ class RecipePageControls {
} }
async resetAndReload() { async resetAndReload() {
refreshVirtualScroll(); await refreshVirtualScroll({ preserveScroll: true });
} }
async refreshModels(fullRebuild = false) { async refreshModels(fullRebuild = false) {

View File

@@ -25,6 +25,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
base_model_path_mappings: {}, base_model_path_mappings: {},
download_path_templates: {}, download_path_templates: {},
example_images_path: '', example_images_path: '',
example_images_open_mode: 'system',
example_images_local_root: '',
example_images_open_uri_template: '',
optimize_example_images: true, optimize_example_images: true,
auto_download_example_images: false, auto_download_example_images: false,
blur_mature_content: true, blur_mature_content: true,
@@ -35,6 +38,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
show_folder_sidebar: true, show_folder_sidebar: true,
model_name_display: 'model_name', model_name_display: 'model_name',
model_card_footer_action: 'example_images', model_card_footer_action: 'example_images',
show_version_on_card: true,
include_trigger_words: false, include_trigger_words: false,
compact_mode: false, compact_mode: false,
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },

View File

@@ -104,69 +104,74 @@ export class VirtualScroller {
// Get display density setting // Get display density setting
const displayDensity = state.global.settings?.display_density || 'default'; const displayDensity = state.global.settings?.display_density || 'default';
// Set exact column counts and grid widths to match CSS container widths // Base gap between cards
let maxColumns, maxGridWidth; const baseGap = 12;
this.columnGap = baseGap;
// Match exact column counts and CSS container width values based on density // Define minimum card width based on density setting to ensure usability
// Cards smaller than this become hard to interact with and view
const minCardWidths = {
'default': 240, // Default: comfortable minimum
'medium': 200, // Medium: slightly smaller
'compact': 170 // Compact: smallest usable size
};
const minCardWidth = minCardWidths[displayDensity] || 240;
// Calculate maximum possible columns that fit in available width
// Formula: maxColumns = floor((availableWidth + gap) / (minCardWidth + gap))
const maxPossibleColumns = Math.floor((availableContentWidth + this.columnGap) / (minCardWidth + this.columnGap));
// Ensure at least 1 column
const maxColumns = Math.max(1, maxPossibleColumns);
// Define preferred maximum columns based on display density and screen size
// These are upper limits to prevent too many columns on ultra-wide screens
let preferredMaxColumns;
if (window.innerWidth >= 3000) { // 4K if (window.innerWidth >= 3000) { // 4K
if (displayDensity === 'default') { if (displayDensity === 'default') {
maxColumns = 8; preferredMaxColumns = 8;
} else if (displayDensity === 'medium') { } else if (displayDensity === 'medium') {
maxColumns = 9; preferredMaxColumns = 10;
} else { // compact } else { // compact
maxColumns = 10; preferredMaxColumns = 12;
} }
maxGridWidth = 2400; // Match exact CSS container width for 4K
} else if (window.innerWidth >= 2150) { // 2K/1440p } else if (window.innerWidth >= 2150) { // 2K/1440p
if (displayDensity === 'default') { if (displayDensity === 'default') {
maxColumns = 6; preferredMaxColumns = 6;
} else if (displayDensity === 'medium') { } else if (displayDensity === 'medium') {
maxColumns = 7; preferredMaxColumns = 8;
} else { // compact } else { // compact
maxColumns = 8; preferredMaxColumns = 10;
} }
maxGridWidth = 1800; // Match exact CSS container width for 2K } else { // 1080p and smaller
} else {
// 1080p
if (displayDensity === 'default') { if (displayDensity === 'default') {
maxColumns = 5; preferredMaxColumns = 5;
} else if (displayDensity === 'medium') { } else if (displayDensity === 'medium') {
maxColumns = 6; preferredMaxColumns = 6;
} else { // compact } else { // compact
maxColumns = 7; preferredMaxColumns = 8;
} }
maxGridWidth = 1400; // Match exact CSS container width for 1080p
} }
// Calculate baseCardWidth based on desired column count and available space // Use the smaller of: max columns that fit, or preferred max
// Formula: (maxGridWidth - (columns-1)*gap) / columns // This ensures cards are never smaller than minCardWidth
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns; this.columnsCount = Math.min(maxColumns, preferredMaxColumns);
// Use the smaller of available content width or max grid width // Calculate card width to perfectly fill available space
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth); // Formula: (availableWidth - totalGap) / columns
const totalGap = (this.columnsCount - 1) * this.columnGap;
this.itemWidth = (availableContentWidth - totalGap) / this.columnsCount;
// Set exact column count based on screen size and mode // Calculate height based on aspect ratio (896/1152)
this.columnsCount = maxColumns;
// When available width is smaller than maxGridWidth, recalculate columns
if (availableContentWidth < maxGridWidth) {
// Calculate how many columns can fit in the available space
this.columnsCount = Math.max(1, Math.floor(
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
));
}
// Calculate actual item width
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
// Calculate height based on aspect ratio
this.itemHeight = this.itemWidth / this.itemAspectRatio; this.itemHeight = this.itemWidth / this.itemAspectRatio;
// Calculate the left offset to center the grid within the content area // Edge-to-edge layout: no offset, grid fills container
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); this.leftOffset = 0;
const actualGridWidth = this.itemWidth * this.columnsCount + totalGap;
// Update grid element max-width to match available width // Update grid element to fill available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`; this.gridElement.style.maxWidth = `${actualGridWidth}px`;
this.gridElement.style.width = `${actualGridWidth}px`;
// Add or remove density classes for style adjustments // Add or remove density classes for style adjustments
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density'); this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
@@ -478,6 +483,12 @@ export class VirtualScroller {
element.style.width = `${this.itemWidth}px`; element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`; element.style.height = `${this.itemHeight}px`;
// Remove max-width constraint from model-card to allow dynamic sizing
const modelCard = element.querySelector('.model-card');
if (modelCard) {
modelCard.style.maxWidth = 'none';
}
return element; return element;
} }

View File

@@ -64,6 +64,33 @@ export function openCivitaiUrl(url) {
return window.open(url, '_blank', 'noopener,noreferrer'); return window.open(url, '_blank', 'noopener,noreferrer');
} }
async function copyExampleImagesValue(value, successKey, fallbackKey, paramsKey = 'path') {
if (!value) {
return false;
}
const params = { [paramsKey]: value };
try {
await navigator.clipboard.writeText(value);
showToast(successKey, params, 'success');
return true;
} catch (clipboardErr) {
console.warn('Clipboard API not available:', clipboardErr);
showToast(fallbackKey, params, 'info');
return false;
}
}
function tryOpenExternalUri(uri) {
try {
const openedWindow = window.open(uri, '_blank', 'noopener,noreferrer');
return openedWindow !== null;
} catch (error) {
console.warn('Failed to open external URI:', error);
return false;
}
}
/** /**
* Utility function to copy text to clipboard with fallback for older browsers * Utility function to copy text to clipboard with fallback for older browsers
* @param {string} text - The text to copy to clipboard * @param {string} text - The text to copy to clipboard
@@ -1088,7 +1115,31 @@ export async function openExampleImagesFolder(modelHash) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const message = translate('uiHelpers.exampleImages.openingFolder', {}, 'Opening example images folder'); if (result.mode === 'clipboard' && result.path) {
await copyExampleImagesValue(
result.path,
'uiHelpers.exampleImages.copiedPath',
'uiHelpers.exampleImages.clipboardFallback',
'path'
);
return true;
}
if (result.mode === 'uri' && result.uri) {
const opened = tryOpenExternalUri(result.uri);
if (!opened) {
await copyExampleImagesValue(
result.uri,
'uiHelpers.exampleImages.copiedUri',
'uiHelpers.exampleImages.uriClipboardFallback',
'uri'
);
} else {
showToast('uiHelpers.exampleImages.opened', {}, 'success');
}
return true;
}
showToast('uiHelpers.exampleImages.opened', {}, 'success'); showToast('uiHelpers.exampleImages.opened', {}, 'success');
return true; return true;
} else { } else {

View File

@@ -28,6 +28,7 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %} {% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %} {% include 'components/folder_sidebar.html' %}

View File

@@ -0,0 +1,5 @@
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>

View File

@@ -129,11 +129,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Breadcrumb Navigation -->
<div id="breadcrumbContainer" class="sidebar-breadcrumb-container">
<nav class="sidebar-breadcrumb-nav" id="sidebarBreadcrumbNav">
<!-- Breadcrumbs will be populated by JavaScript -->
</nav>
</div>
</div> </div>

View File

@@ -1,50 +1,53 @@
<header class="app-header"> <header class="app-header">
<div class="header-container"> <div class="header-container">
<div class="header-branding"> <!-- Left section: Logo + Navigation -->
<a href="/loras" class="logo-link"> <div class="header-left">
<img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo"> <div class="header-branding">
<span class="app-title">{{ t('header.appTitle') }}</span> <a href="/loras" class="logo-link">
</a> <img src="/loras_static/images/favicon-32x32.png" alt="LoRA Manager" class="app-logo">
<span class="app-title">{{ t('header.appTitle') }}</span>
</a>
</div>
{% set current_path = request.path %}
{% if current_path.startswith('/loras/recipes') %}
{% set current_page = 'recipes' %}
{% elif current_path.startswith('/checkpoints') %}
{% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %}
{% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %}
{% else %}
{% set current_page = 'loras' %}
{% endif %}
<nav class="main-nav">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
</a>
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
id="recipesNavItem">
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
</a>
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
</a>
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a>
</nav>
</div> </div>
{% set current_path = request.path %}
{% if current_path.startswith('/loras/recipes') %} <!-- Center section: Search -->
{% set current_page = 'recipes' %}
{% elif current_path.startswith('/checkpoints') %}
{% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %}
{% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %}
{% else %}
{% set current_page = 'loras' %}
{% endif %}
{% set search_disabled = current_page == 'statistics' %} {% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ {% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
current_page %} current_page %}
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %} {% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
<nav class="main-nav">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
</a>
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
id="recipesNavItem">
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
</a>
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
</a>
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a>
</nav>
<!-- Context-aware search container -->
<div class="{{ header_search_class }}" id="headerSearch"> <div class="{{ header_search_class }}" id="headerSearch">
<div class="search-container"> <div class="search-container">
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %} <input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
@@ -62,9 +65,9 @@
</div> </div>
</div> </div>
<div class="header-actions"> <!-- Right section: Controls -->
<!-- Integrated corner controls --> <div class="header-right">
<div class="header-controls"> <div class="header-controls" id="headerControls">
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}"> <div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
<i class="fas fa-moon dark-icon"></i> <i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i> <i class="fas fa-sun light-icon"></i>
@@ -85,6 +88,34 @@
<i class="fas fa-heart"></i> <i class="fas fa-heart"></i>
</div> </div>
</div> </div>
<!-- Hamburger menu button (visible on mobile) -->
<button class="hamburger-menu-btn" id="hamburgerMenuBtn" title="{{ t('common.actions.menu') }}">
<i class="fas fa-bars"></i>
</button>
<!-- Hamburger dropdown menu -->
<div class="hamburger-dropdown" id="hamburgerDropdown">
<div class="dropdown-item theme-toggle-item" data-action="theme">
<i class="fas fa-moon"></i>
<span>{{ t('header.theme.toggle') }}</span>
</div>
<div class="dropdown-item" data-action="settings">
<i class="fas fa-cog"></i>
<span>{{ t('common.actions.settings') }}</span>
</div>
<div class="dropdown-item" data-action="help">
<i class="fas fa-question-circle"></i>
<span>{{ t('common.actions.help') }}</span>
</div>
<div class="dropdown-item" data-action="notifications">
<i class="fas fa-bell"></i>
<span>{{ t('header.actions.notifications') }}</span>
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item" data-action="support">
<i class="fas fa-heart"></i>
<span>{{ t('header.actions.support') }}</span>
</div>
</div>
</div> </div>
</div> </div>
</header> </header>

View File

@@ -138,6 +138,9 @@
<div class="setting-info"> <div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label> <label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i> <i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div> </div>
<div class="setting-control select-control"> <div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')"> <select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
@@ -551,6 +554,24 @@
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="showVersionOnCard">
{{ t('settings.layoutSettings.showVersionOnCard') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.showVersionOnCardHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="showVersionOnCard"
onchange="settingsManager.saveToggleSetting('showVersionOnCard', 'show_version_on_card')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -1081,7 +1102,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -1099,6 +1120,63 @@
</div> </div>
</div> </div>
</div> </div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesOpenMode">
{{ t('settings.exampleImages.openMode') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.openModeHelp') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Remote-Open-for-Example-Images" target="_blank" rel="noopener" title="{{ t('settings.exampleImages.openModeWikiLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</label>
</div>
<div class="setting-control select-control">
<select id="exampleImagesOpenMode" onchange="settingsManager.handleExampleImagesOpenModeChange()">
<option value="system">{{ t('settings.exampleImages.openModeOptions.system') }}</option>
<option value="clipboard">{{ t('settings.exampleImages.openModeOptions.clipboard') }}</option>
<option value="uri_template">{{ t('settings.exampleImages.openModeOptions.uriTemplate') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="exampleImagesLocalRootSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesLocalRoot">
{{ t('settings.exampleImages.localRoot') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.localRootHelp') }}"></i>
</label>
</div>
<div class="setting-control path-control">
<input
type="text"
id="exampleImagesLocalRoot"
placeholder="{{ t('settings.exampleImages.localRootPlaceholder') }}"
onchange="settingsManager.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root')" />
</div>
</div>
</div>
<div class="setting-item" id="exampleImagesUriTemplateSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesOpenUriTemplate">
{{ t('settings.exampleImages.uriTemplate') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.uriTemplateHelp') }} {{ t('settings.exampleImages.uriTemplatePlaceholders') }}"></i>
</label>
</div>
<div class="setting-control path-control">
<input
type="text"
id="exampleImagesOpenUriTemplate"
placeholder="{{ t('settings.exampleImages.uriTemplatePlaceholder') }}"
onchange="settingsManager.saveInputSetting('exampleImagesOpenUriTemplate', 'example_images_open_uri_template')" />
</div>
</div>
</div>
</div> </div>
<!-- Auto-organize --> <!-- Auto-organize -->

View File

@@ -26,6 +26,7 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %} {% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %} {% include 'components/folder_sidebar.html' %}

View File

@@ -9,6 +9,7 @@
{% block content %} {% block content %}
{% include 'components/controls.html' %} {% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
{% include 'components/duplicates_banner.html' %} {% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %} {% include 'components/folder_sidebar.html' %}

View File

@@ -46,6 +46,7 @@ def test_save_paths_renames_default_library(monkeypatch: pytest.MonkeyPatch, tmp
self.delete_calls = [] self.delete_calls = []
self.upsert_calls = [] self.upsert_calls = []
self._renamed = False self._renamed = False
self.active_library = "default"
def get_libraries(self): def get_libraries(self):
if self._renamed: 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): def rename_library(self, old_name: str, new_name: str):
self.rename_calls.append((old_name, new_name)) self.rename_calls.append((old_name, new_name))
self._renamed = True 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 def delete_library(self, name: str): # pragma: no cover - defensive guard
self.delete_calls.append(name) self.delete_calls.append(name)
@@ -104,6 +110,7 @@ def test_save_paths_logs_warning_when_upsert_fails(
class RaisingSettingsService: class RaisingSettingsService:
def __init__(self): def __init__(self):
self.upsert_attempts = [] self.upsert_attempts = []
self.active_library = "comfyui"
def get_libraries(self): def get_libraries(self):
return { return {
@@ -116,6 +123,9 @@ def test_save_paths_logs_warning_when_upsert_fails(
def rename_library(self, *_): def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked") 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): def upsert_library(self, name: str, **payload):
self.upsert_attempts.append((name, payload)) self.upsert_attempts.append((name, payload))
raise RuntimeError("boom") 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) folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService: class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self): def get_libraries(self):
return { return {
"comfyui": { "comfyui": {
@@ -148,6 +160,9 @@ def test_save_paths_repairs_empty_default_roots(monkeypatch: pytest.MonkeyPatch,
def rename_library(self, *_): def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked") 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): def upsert_library(self, name: str, **payload):
self.name = name self.name = name
self.payload = payload 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) folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService: class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self): def get_libraries(self):
return { return {
"comfyui": { "comfyui": {
@@ -180,6 +197,9 @@ def test_save_paths_repairs_stale_default_roots(monkeypatch: pytest.MonkeyPatch,
def rename_library(self, *_): def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked") 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): def upsert_library(self, name: str, **payload):
self.name = name self.name = name
self.payload = payload 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) folder_paths = _setup_config_environment(monkeypatch, tmp_path)
class FakeSettingsService: class FakeSettingsService:
active_library = "comfyui"
def get_libraries(self): def get_libraries(self):
return { return {
"comfyui": { "comfyui": {
@@ -212,6 +234,9 @@ def test_save_paths_keeps_valid_default_roots(monkeypatch: pytest.MonkeyPatch, t
def rename_library(self, *_): def rename_library(self, *_):
raise AssertionError("rename_library should not be invoked") 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): def upsert_library(self, name: str, **payload):
self.name = name self.name = name
self.payload = payload self.payload = payload
@@ -258,6 +283,7 @@ def test_save_paths_removes_template_default_library(monkeypatch, tmp_path):
self.rename_calls = [] self.rename_calls = []
self.delete_calls = [] self.delete_calls = []
self.upsert_calls = [] self.upsert_calls = []
self.active_library = "default"
def get_libraries(self): def get_libraries(self):
return self.libraries 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): def rename_library(self, old_name: str, new_name: str):
self.rename_calls.append((old_name, new_name)) self.rename_calls.append((old_name, new_name))
self.libraries[new_name] = self.libraries.pop(old_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): def delete_library(self, name: str):
self.delete_calls.append(name) 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): def upsert_library(self, name: str, **payload):
self.upsert_calls.append((name, payload)) self.upsert_calls.append((name, payload))
self.libraries[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() fake_settings = FakeSettingsService()
monkeypatch.setattr(settings_manager_module, "settings", fake_settings) 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 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): def test_apply_library_settings_merges_extra_paths(monkeypatch, tmp_path):
"""Test that apply_library_settings correctly merges folder_paths with extra_folder_paths.""" """Test that apply_library_settings correctly merges folder_paths with extra_folder_paths."""
loras_dir = tmp_path / "loras" loras_dir = tmp_path / "loras"

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

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

@@ -340,4 +340,52 @@ describe('SettingsManager library controls', () => {
expect(aria2PathSetting.style.display).toBe('none'); expect(aria2PathSetting.style.display).toBe('none');
expect(saveSpy).toHaveBeenCalledWith('downloadBackend', 'download_backend'); expect(saveSpy).toHaveBeenCalledWith('downloadBackend', 'download_backend');
}); });
it('loads example image remote-open settings and updates field visibility', async () => {
const manager = createManager();
document.body.innerHTML = `
<select id="exampleImagesOpenMode">
<option value="system">System</option>
<option value="clipboard">Clipboard</option>
<option value="uri_template">URI</option>
</select>
<div id="exampleImagesLocalRootSetting" style="display: none;"></div>
<div id="exampleImagesUriTemplateSetting" style="display: none;"></div>
<input id="exampleImagesLocalRoot" />
<input id="exampleImagesOpenUriTemplate" />
`;
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
state.global.settings = {
example_images_open_mode: 'uri_template',
example_images_local_root: '/Volumes/ComfyUI/examples',
example_images_open_uri_template: 'shortcuts://run-shortcut?text={{encoded_local_path}}',
};
await manager.loadSettingsToUI();
expect(document.getElementById('exampleImagesOpenMode').value).toBe('uri_template');
expect(document.getElementById('exampleImagesLocalRoot').value).toBe('/Volumes/ComfyUI/examples');
expect(document.getElementById('exampleImagesOpenUriTemplate').value)
.toBe('shortcuts://run-shortcut?text={{encoded_local_path}}');
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('block');
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('block');
state.global.settings.example_images_open_mode = 'clipboard';
manager.updateExampleImagesOpenSettingsVisibility();
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('block');
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
state.global.settings.example_images_open_mode = 'system';
manager.updateExampleImagesOpenSettingsVisibility();
expect(document.getElementById('exampleImagesLocalRootSetting').style.display).toBe('none');
expect(document.getElementById('exampleImagesUriTemplateSetting').style.display).toBe('none');
});
}); });

View File

@@ -84,6 +84,8 @@ describe('UI helper DOM utilities', () => {
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
delete global.fetch; delete global.fetch;
delete navigator.clipboard;
delete window.open;
}); });
it('creates toast elements and cleans them up after timeout', async () => { it('creates toast elements and cleans them up after timeout', async () => {
@@ -230,4 +232,49 @@ describe('UI helper DOM utilities', () => {
'noopener,noreferrer' 'noopener,noreferrer'
); );
}); });
it('copies mapped local example-image paths when the backend requests clipboard mode', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
mode: 'clipboard',
path: '/Volumes/ComfyUI/examples/demo',
}),
});
navigator.clipboard = {
writeText: vi.fn().mockResolvedValue(),
};
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
const result = await openExampleImagesFolder('abc123');
expect(result).toBe(true);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/Volumes/ComfyUI/examples/demo');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/open-example-images-folder', expect.objectContaining({
method: 'POST',
}));
});
it('opens custom URIs for example-image folders when requested by the backend', async () => {
global.fetch = vi.fn().mockResolvedValue({
json: async () => ({
success: true,
mode: 'uri',
uri: 'shortcuts://run-shortcut?name=OpenFinder',
}),
});
window.open = vi.fn(() => ({}));
const { openExampleImagesFolder } = await import(UI_HELPERS_MODULE);
const result = await openExampleImagesFolder('abc123');
expect(result).toBe(true);
expect(window.open).toHaveBeenCalledWith(
'shortcuts://run-shortcut?name=OpenFinder',
'_blank',
'noopener,noreferrer'
);
});
}); });

View File

@@ -177,6 +177,383 @@ def test_attention_bias_clip_text_encode_prompts_are_collected(metadata_registry
assert prompt_results["negative_prompt"] == "low quality" assert prompt_results["negative_prompt"] == "low quality"
def test_myoriginalwaifu_text_provider_uses_processed_prompt_outputs(
metadata_registry, monkeypatch
):
prompt_graph = {
"text_provider": {
"class_type": "TextProvider",
"inputs": {
"positive": "raw positive",
"negative": "raw negative",
},
},
"encode_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["text_provider", 0], "clip": ["clip", 0]},
},
"encode_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["text_provider", 1], "clip": ["clip", 0]},
},
"sampler": {
"class_type": "KSampler",
"inputs": {
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": ["encode_pos", 0],
"negative": ["encode_neg", 0],
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
pos_conditioning = object()
neg_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-myoriginalwaifu-text")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"text_provider",
"TextProvider",
{"positive": "raw positive", "negative": "raw negative"},
None,
)
metadata_registry.update_node_execution(
"text_provider",
"TextProvider",
[("processed positive", "processed negative")],
)
metadata_registry.record_node_execution(
"encode_pos", "CLIPTextEncode", {"text": "processed positive"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "CLIPTextEncode", [(pos_conditioning,)]
)
metadata_registry.record_node_execution(
"encode_neg", "CLIPTextEncode", {"text": "processed negative"}, None
)
metadata_registry.update_node_execution(
"encode_neg", "CLIPTextEncode", [(neg_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"KSampler",
{
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": pos_conditioning,
"negative": neg_conditioning,
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-myoriginalwaifu-text")
params = MetadataProcessor.extract_generation_params(metadata)
assert metadata[PROMPTS]["text_provider"]["positive_text"] == "processed positive"
assert metadata[PROMPTS]["text_provider"]["negative_text"] == "processed negative"
assert params["prompt"] == "processed positive"
assert params["negative_prompt"] == "processed negative"
def test_myoriginalwaifu_clip_provider_prompts_are_collected_without_clip_text_encode(
metadata_registry, monkeypatch
):
prompt_graph = {
"clip_provider": {
"class_type": "ClipProvider",
"inputs": {
"positive": "direct positive",
"negative": "direct negative",
"clip": ["clip", 0],
},
},
"sampler": {
"class_type": "KSampler",
"inputs": {
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": ["clip_provider", 0],
"negative": ["clip_provider", 1],
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
pos_conditioning = object()
neg_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-myoriginalwaifu-clip")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"clip_provider",
"ClipProvider",
{"positive": "direct positive", "negative": "direct negative"},
None,
)
metadata_registry.update_node_execution(
"clip_provider", "ClipProvider", [(pos_conditioning, neg_conditioning)]
)
metadata_registry.record_node_execution(
"sampler",
"KSampler",
{
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": pos_conditioning,
"negative": neg_conditioning,
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-myoriginalwaifu-clip")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "direct positive"
assert params["negative_prompt"] == "direct negative"
def test_conditioning_provenance_recovers_combined_controlnet_prompts(
metadata_registry, monkeypatch
):
import types
prompt_graph = {
"encode_wd": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "wd14 tags", "clip": ["clip", 0]},
},
"encode_manual": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "manual tags", "clip": ["clip", 0]},
},
"encode_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "low quality", "clip": ["clip", 0]},
},
"combine": {
"class_type": "ConditioningCombine",
"inputs": {
"conditioning_1": ["encode_wd", 0],
"conditioning_2": ["encode_manual", 0],
},
},
"controlnet": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": ["combine", 0],
"negative": ["encode_neg", 0],
},
},
"sampler": {
"class_type": "KSampler",
"inputs": {
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": ["controlnet", 0],
"negative": ["controlnet", 1],
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
wd_conditioning = object()
manual_conditioning = object()
negative_conditioning = object()
combined_conditioning = object()
controlnet_positive = object()
controlnet_negative = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-provenance")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_wd", "CLIPTextEncode", {"text": "wd14 tags"}, None
)
metadata_registry.update_node_execution(
"encode_wd", "CLIPTextEncode", [(wd_conditioning,)]
)
metadata_registry.record_node_execution(
"encode_manual", "CLIPTextEncode", {"text": "manual tags"}, None
)
metadata_registry.update_node_execution(
"encode_manual", "CLIPTextEncode", [(manual_conditioning,)]
)
metadata_registry.record_node_execution(
"encode_neg", "CLIPTextEncode", {"text": "low quality"}, None
)
metadata_registry.update_node_execution(
"encode_neg", "CLIPTextEncode", [(negative_conditioning,)]
)
metadata_registry.record_node_execution(
"combine",
"ConditioningCombine",
{
"conditioning_1": wd_conditioning,
"conditioning_2": manual_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"combine", "ConditioningCombine", [(combined_conditioning,)]
)
metadata_registry.record_node_execution(
"controlnet",
"ControlNetApplyAdvanced",
{
"positive": combined_conditioning,
"negative": negative_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"controlnet",
"ControlNetApplyAdvanced",
[(controlnet_positive, controlnet_negative)],
)
metadata_registry.record_node_execution(
"sampler",
"KSampler",
{
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": controlnet_positive,
"negative": controlnet_negative,
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-provenance")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "wd14 tags, manual tags"
assert params["negative_prompt"] == "low quality"
def test_conditioning_provenance_recovers_kj_set_get_prompts(
metadata_registry, monkeypatch
):
import types
prompt_graph = {
"encode_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "from set node", "clip": ["clip", 0]},
},
"set_positive": {
"class_type": "SetNode",
"inputs": {"CONDITIONING": ["encode_pos", 0], "name": "positive"},
},
"get_positive": {
"class_type": "GetNode",
"inputs": {"name": "positive"},
},
"sampler": {
"class_type": "KSampler",
"inputs": {
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": ["get_positive", 0],
"negative": ["encode_pos", 0],
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
original_conditioning = object()
get_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-kj-get")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_pos", "CLIPTextEncode", {"text": "from set node"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "CLIPTextEncode", [(original_conditioning,)]
)
metadata_registry.record_node_execution(
"set_positive",
"SetNode",
{"CONDITIONING": original_conditioning, "name": "positive"},
None,
)
metadata_registry.record_node_execution(
"get_positive", "GetNode", {"name": "positive"}, None
)
metadata_registry.update_node_execution(
"get_positive", "GetNode", [(get_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"KSampler",
{
"seed": 123,
"steps": 20,
"cfg": 7.0,
"sampler_name": "Euler",
"scheduler": "karras",
"denoise": 1.0,
"positive": get_conditioning,
"negative": original_conditioning,
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-kj-get")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "from set node"
assert params["negative_prompt"] == "from set node"
def test_sampler_custom_advanced_recovers_prompt_text_through_guidance_nodes(metadata_registry, monkeypatch): def test_sampler_custom_advanced_recovers_prompt_text_through_guidance_nodes(metadata_registry, monkeypatch):
import types import types
@@ -354,3 +731,33 @@ def test_lora_manager_cache_updates_when_loras_removed(metadata_registry):
metadata = metadata_registry.get_metadata("prompt3") metadata = metadata_registry.get_metadata("prompt3")
assert "lora_node" not in metadata[LORAS] 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

@@ -1,9 +1,11 @@
import json import json
import os
import numpy as np import numpy as np
import piexif import piexif
from PIL import Image from PIL import Image
from py.services.service_registry import ServiceRegistry
from py.nodes.save_image import SaveImageLM from py.nodes.save_image import SaveImageLM
@@ -151,3 +153,213 @@ def test_process_image_returns_empty_ui_images_when_save_fails(monkeypatch, tmp_
assert result["result"] == (images,) assert result["result"] == (images,)
assert result["ui"] == {"images": []} assert result["ui"] == {"images": []}
def test_save_image_does_not_save_recipe_by_default(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
calls = []
monkeypatch.setattr(
SaveImageLM,
"_save_image_as_recipe",
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
)
node = SaveImageLM()
node.save_images([_make_image()], "ComfyUI", "png", id="node-1")
assert calls == []
def test_save_image_saves_recipe_when_enabled(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
metadata_dict = {"prompt": "prompt text", "seed": 123}
_configure_metadata(monkeypatch, metadata_dict)
calls = []
monkeypatch.setattr(
SaveImageLM,
"_save_image_as_recipe",
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
)
node = SaveImageLM()
node.save_images(
[_make_image()],
"ComfyUI",
"png",
id="node-1",
save_as_recipe=True,
)
assert calls == [(str(tmp_path / "sample_00001_.png"), metadata_dict)]
def test_save_image_saves_recipe_for_each_successful_batch_image(monkeypatch, tmp_path):
monkeypatch.setattr("folder_paths.get_output_directory", lambda: str(tmp_path), raising=False)
monkeypatch.setattr(
"folder_paths.get_save_image_path",
lambda *_args, **_kwargs: (str(tmp_path), "sample", 7, "", "sample"),
raising=False,
)
metadata_dict = {"prompt": "prompt text", "seed": 123}
_configure_metadata(monkeypatch, metadata_dict)
calls = []
monkeypatch.setattr(
SaveImageLM,
"_save_image_as_recipe",
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
)
node = SaveImageLM()
node.save_images(
[_make_image(), _make_image()],
"ComfyUI",
"png",
id="node-1",
save_as_recipe=True,
)
assert calls == [
(str(tmp_path / "sample_00007_.png"), metadata_dict),
(str(tmp_path / "sample_00008_.png"), metadata_dict),
]
def test_save_image_does_not_save_recipe_when_image_save_fails(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
def _raise_save_error(*args, **kwargs):
raise OSError("disk full")
calls = []
monkeypatch.setattr(Image.Image, "save", _raise_save_error)
monkeypatch.setattr(
SaveImageLM,
"_save_image_as_recipe",
lambda self, file_path, metadata_dict: calls.append((file_path, metadata_dict)),
)
node = SaveImageLM()
node.save_images(
[_make_image()],
"ComfyUI",
"png",
id="node-1",
save_as_recipe=True,
)
assert calls == []
def test_process_image_keeps_image_result_when_recipe_save_fails(monkeypatch, tmp_path):
_configure_save_paths(monkeypatch, tmp_path)
_configure_metadata(monkeypatch, {"prompt": "prompt text", "seed": 123})
def _raise_recipe_error(*args, **kwargs):
raise RuntimeError("recipe unavailable")
monkeypatch.setattr(SaveImageLM, "_save_image_as_recipe", _raise_recipe_error)
images = [_make_image()]
node = SaveImageLM()
result = node.process_image(images, id="node-1", save_as_recipe=True)
assert result["result"] == (images,)
assert result["ui"] == {
"images": [{"filename": "sample_00001_.png", "subfolder": "", "type": "output"}]
}
def test_save_image_as_recipe_writes_recipe_without_async_scanner_calls(
monkeypatch, tmp_path
):
_configure_save_paths(monkeypatch, tmp_path)
source_image = tmp_path / "source.png"
Image.new("RGB", (16, 16), color=(10, 20, 30)).save(source_image)
recipes_dir = tmp_path / "recipes"
class _Cache:
def __init__(self, raw_data=None):
self.raw_data = raw_data or []
self.sorted_by_name = []
self.sorted_by_date = []
self.folders = []
self.folder_tree = {}
class _ModelScanner:
def __init__(self, raw_data):
self._cache = _Cache(raw_data)
class _PersistentCache:
def __init__(self):
self.updates = []
def update_recipe(self, recipe_data, json_path):
self.updates.append((recipe_data, json_path))
class _RecipeScanner:
def __init__(self):
self.recipes_dir = str(recipes_dir)
self._cache = _Cache([])
self._json_path_map = {}
self._persistent_cache = _PersistentCache()
self._lora_scanner = _ModelScanner(
[
{
"file_name": "foo",
"sha256": "ABC123",
"base_model": "SDXL",
"civitai": {
"id": 456,
"name": "Foo v1",
"model": {"name": "Foo"},
},
}
]
)
self._checkpoint_scanner = _ModelScanner([])
self.fts_updates = []
def _update_folder_metadata(self, cache):
cache.folders = [""]
cache.folder_tree = {}
def _update_fts_index_for_recipe(self, recipe_data, operation):
self.fts_updates.append((recipe_data["id"], operation))
scanner = _RecipeScanner()
monkeypatch.setitem(ServiceRegistry._services, "recipe_scanner", scanner)
node = SaveImageLM()
node._save_image_as_recipe(
str(source_image),
{
"prompt": "prompt text",
"seed": 123,
"checkpoint": "model.safetensors",
"loras": "<lora:foo:0.7>",
},
)
recipe_files = list(recipes_dir.glob("*.recipe.json"))
preview_files = list(recipes_dir.glob("*.webp"))
assert len(recipe_files) == 1
assert len(preview_files) == 1
assert len(scanner._cache.raw_data) == 1
assert len(scanner._persistent_cache.updates) == 1
recipe = json.loads(recipe_files[0].read_text(encoding="utf-8"))
assert recipe["file_path"] == os.path.normpath(str(preview_files[0]))
assert recipe["title"] == "foo-0.70"
assert recipe["base_model"] == "SDXL"
assert recipe["loras"][0]["hash"] == "abc123"
assert recipe["loras"][0]["modelVersionId"] == 456
assert recipe["gen_params"] == {"prompt": "prompt text", "seed": 123}
assert scanner._json_path_map[recipe["id"]] == os.path.normpath(str(recipe_files[0]))
assert scanner.fts_updates == [(recipe["id"], "add")]

View File

@@ -99,6 +99,99 @@ def test_duplicate_groups_respect_active_state():
assert filtered == "A, B" 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(): def test_trigger_words_override_different_from_original():
node = TriggerWordToggleLM() node = TriggerWordToggleLM()
trigger_data = [ trigger_data = [

View File

@@ -10,6 +10,7 @@ from unittest.mock import patch, MagicMock
import pytest import pytest
from aiohttp import web from aiohttp import web
from py.services.model_hash_index import ModelHashIndex
from py.routes.handlers.misc_handlers import ( from py.routes.handlers.misc_handlers import (
BackupHandler, BackupHandler,
DoctorHandler, DoctorHandler,
@@ -78,10 +79,11 @@ async def dummy_downloader_factory():
class DummyDoctorScanner: class DummyDoctorScanner:
def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None): def __init__(self, *, model_type='lora', raw_data=None, rebuild_error=None, hash_index=None):
self.model_type = model_type self.model_type = model_type
self._raw_data = list(raw_data or []) self._raw_data = list(raw_data or [])
self._rebuild_error = rebuild_error self._rebuild_error = rebuild_error
self._hash_index = hash_index
self._persistent_cache = SimpleNamespace( self._persistent_cache = SimpleNamespace(
load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data)) load_cache=lambda _model_type: SimpleNamespace(raw_data=list(self._raw_data))
) )
@@ -91,6 +93,16 @@ class DummyDoctorScanner:
raise self._rebuild_error raise self._rebuild_error
return SimpleNamespace(raw_data=list(self._raw_data)) return SimpleNamespace(raw_data=list(self._raw_data))
async def update_single_model_cache(self, original_path, new_path, metadata):
for item in self._raw_data:
if item.get("file_path") == original_path:
item["file_path"] = new_path
item["file_name"] = metadata.get("file_name", item.get("file_name", ""))
if metadata.get("preview_url"):
item["preview_url"] = metadata["preview_url"]
break
return True
class DummyCivitaiClient: class DummyCivitaiClient:
def __init__(self, *, success=True, result=None): def __init__(self, *, success=True, result=None):
@@ -1582,3 +1594,107 @@ def test_wsl_to_windows_path_returns_none_on_subprocess_error(tmp_path):
): ):
result = _wsl_to_windows_path("/mnt/c/test") result = _wsl_to_windows_path("/mnt/c/test")
assert result is None assert result is None
# ── DoctorHandler filename conflict tests ──────────────────────────────
@pytest.mark.asyncio
async def test_check_filename_conflicts_returns_ok_when_no_duplicates():
hash_index = ModelHashIndex()
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora", raw_data=[], hash_index=hash_index
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
payload = json.loads(response.text)
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
assert diagnostic_map["filename_conflicts"]["status"] == "ok"
assert "No duplicate filenames" in diagnostic_map["filename_conflicts"]["summary"]
@pytest.mark.asyncio
async def test_check_filename_conflicts_detects_duplicates():
hash_index = ModelHashIndex()
hash_index.add_entry("abc123", "/a/lora.safetensors")
hash_index.add_entry("def456", "/b/lora.safetensors")
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora",
raw_data=[
{"file_path": "/a/lora.safetensors"},
{"file_path": "/b/lora.safetensors"},
],
hash_index=hash_index,
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.get_doctor_diagnostics(FakeRequest(method="GET"))
payload = json.loads(response.text)
diagnostic_map = {item["id"]: item for item in payload["diagnostics"]}
conflict_diag = diagnostic_map["filename_conflicts"]
assert conflict_diag["status"] == "warning"
assert "1 filename(s)" in conflict_diag["summary"]
assert any("resolve-filename-conflicts" in str(a) for a in conflict_diag.get("actions", []))
@pytest.mark.asyncio
async def test_resolve_filename_conflicts_returns_renamed_list():
hash_index = ModelHashIndex()
hash_index.add_entry("abc123", "lora.safetensors")
hash_index.add_entry("def456", "lora.safetensors")
async def scanner_factory():
return DummyDoctorScanner(
model_type="lora",
raw_data=[],
hash_index=hash_index,
)
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
payload = json.loads(response.text)
assert payload["success"] is True
# Files don't exist on disk, so nothing gets renamed
assert payload["count"] == 0
@pytest.mark.asyncio
async def test_resolve_filename_conflicts_handles_scanner_error_gracefully():
class ErrorScanner:
model_type = "lora"
async def get_cached_data(self):
raise RuntimeError("scanner unavailable")
async def scanner_factory():
return ErrorScanner()
handler = DoctorHandler(
settings_service=DummySettings({"civitai_api_key": "token"}),
scanner_factories=(("lora", "LoRAs", scanner_factory),),
)
response = await handler.resolve_filename_conflicts(FakeRequest(method="POST"))
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["count"] == 0

View File

@@ -1,5 +1,6 @@
"""Tests for checkpoint lazy hash calculation feature.""" """Tests for checkpoint lazy hash calculation feature."""
import asyncio
import json import json
import os import os
from pathlib import Path 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" 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 @pytest.mark.asyncio
async def test_calculate_hash_for_model_bootstraps_missing_metadata(tmp_path: Path, monkeypatch): 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.""" """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 import civitai_client as civitai_client_module
from py.services.civitai_client import CivitaiClient 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.errors import RateLimitError, ResourceNotFoundError
from py.services.model_metadata_provider import ModelMetadataProviderManager from py.services.model_metadata_provider import ModelMetadataProviderManager
@@ -115,6 +116,20 @@ async def test_get_model_by_hash_handles_not_found(monkeypatch, downloader):
assert error == "Model not found" 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 test_get_model_by_hash_propagates_rate_limit(monkeypatch, downloader):
async def fake_make_request(method, url, use_auth=True, **kwargs): async def fake_make_request(method, url, use_auth=True, **kwargs):
return False, RateLimitError("limited", retry_after=4) return False, RateLimitError("limited", retry_after=4)

View File

@@ -0,0 +1,125 @@
import asyncio
import errno
from datetime import datetime, timedelta
import pytest
from py.services.connectivity_guard import (
OFFLINE_COOLDOWN_ERROR,
OFFLINE_FRIENDLY_MESSAGE,
ConnectivityGuard,
)
from py.services.downloader import Downloader
@pytest.fixture(autouse=True)
def reset_connectivity_guard_singleton():
ConnectivityGuard._instance = None
yield
ConnectivityGuard._instance = None
async def test_connectivity_guard_enters_cooldown_after_threshold():
guard = await ConnectivityGuard.get_instance()
assert guard.online is True
assert guard.should_block_request() is False
guard.register_network_failure(OSError(errno.ENETUNREACH, "unreachable"))
guard.register_network_failure(asyncio.TimeoutError("timeout"))
assert guard.should_block_request() is False
assert guard.failure_count == 2
guard.register_network_failure(ConnectionRefusedError("refused"))
assert guard.online is False
assert guard.failure_count == 3
assert guard.should_block_request() is True
assert guard.cooldown_remaining_seconds() > 0
async def test_connectivity_guard_scopes_cooldown_to_destination():
guard = await ConnectivityGuard.get_instance()
destination_a = "civitai.com"
destination_b = "api.github.com"
guard.register_network_failure(
OSError(errno.ENETUNREACH, "unreachable"),
destination_a,
)
guard.register_network_failure(asyncio.TimeoutError("timeout"), destination_a)
guard.register_network_failure(ConnectionRefusedError("refused"), destination_a)
assert guard.should_block_request(destination_a) is True
assert guard.should_block_request(destination_b) is False
guard.register_success(destination_a)
assert guard.should_block_request(destination_a) is False
async def test_connectivity_guard_recovers_after_success():
guard = await ConnectivityGuard.get_instance()
guard.online = False
guard.failure_count = 5
guard.cooldown_until = datetime.now() + timedelta(seconds=90)
guard.register_success()
assert guard.online is True
assert guard.failure_count == 0
assert guard.cooldown_until is None
assert guard.should_block_request() is False
async def test_downloader_short_circuits_all_request_helpers_during_cooldown():
guard = await ConnectivityGuard.get_instance()
destination = "example.invalid"
guard.register_network_failure(
OSError(errno.ENETUNREACH, "unreachable"),
destination,
)
guard.register_network_failure(asyncio.TimeoutError("timeout"), destination)
guard.register_network_failure(
ConnectionRefusedError("refused"),
destination,
)
downloader = Downloader()
ok, payload = await downloader.make_request("GET", f"https://{destination}")
assert ok is False
assert payload == OFFLINE_COOLDOWN_ERROR
ok, payload, headers = await downloader.download_to_memory(f"https://{destination}")
assert ok is False
assert payload == OFFLINE_FRIENDLY_MESSAGE
assert headers is None
ok, payload = await downloader.get_response_headers(f"https://{destination}")
assert ok is False
assert payload == OFFLINE_COOLDOWN_ERROR
async def test_downloader_only_short_circuits_requests_for_same_destination():
guard = await ConnectivityGuard.get_instance()
guard.register_network_failure(
OSError(errno.ENETUNREACH, "unreachable"),
"example.invalid",
)
guard.register_network_failure(asyncio.TimeoutError("timeout"), "example.invalid")
guard.register_network_failure(
ConnectionRefusedError("refused"),
"example.invalid",
)
downloader = Downloader()
ok, payload = await downloader.make_request("GET", "https://example.invalid")
assert ok is False
assert payload == OFFLINE_COOLDOWN_ERROR
assert (
guard.should_block_request(downloader._guard_destination("https://example.com"))
is False
)

View File

@@ -233,6 +233,136 @@ async def test_successful_download_uses_defaults(
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"] assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
@pytest.mark.asyncio
async def test_successful_download_schedules_auto_example_images(
monkeypatch, scanners, metadata_provider, tmp_path
):
manager = DownloadManager()
scheduled = []
async def fake_execute_download(
self,
*,
download_urls,
save_dir,
metadata,
version_info,
relative_path,
progress_callback,
model_type,
download_id,
transfer_backend=None,
):
return {"success": True}
async def fake_schedule(self, *, metadata, model_type):
scheduled.append({"metadata": metadata, "model_type": model_type})
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
monkeypatch.setattr(
DownloadManager,
"_schedule_auto_example_images_download",
fake_schedule,
raising=False,
)
result = await manager.download_from_civitai(
model_version_id=99,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert len(scheduled) == 1
assert scheduled[0]["model_type"] == "lora"
assert scheduled[0]["metadata"].sha256 == "sha256"
@pytest.mark.asyncio
async def test_auto_example_images_download_uses_settings_payload(
monkeypatch, tmp_path
):
manager = DownloadManager()
settings = get_settings_manager()
settings.settings["auto_download_example_images"] = True
settings.settings["example_images_path"] = str(tmp_path / "examples")
settings.settings["optimize_example_images"] = False
calls = []
class DummyExampleImagesManager:
async def start_force_download(self, payload):
calls.append(payload)
return {"success": True}
from py.utils import example_images_download_manager
monkeypatch.setattr(
ServiceRegistry,
"get_websocket_manager",
AsyncMock(return_value=object()),
)
monkeypatch.setattr(
example_images_download_manager,
"get_default_download_manager",
lambda _ws_manager: DummyExampleImagesManager(),
)
metadata = SimpleNamespace(sha256="ABCDEF", file_path="model.safetensors")
await manager._schedule_auto_example_images_download(
metadata=metadata,
model_type="lora",
)
for _ in range(10):
if calls:
break
await asyncio.sleep(0)
assert calls == [
{
"model_hashes": ["abcdef"],
"optimize": False,
"model_types": ["lora"],
"delay": 0,
}
]
@pytest.mark.asyncio
async def test_auto_example_images_download_skips_without_configuration(
monkeypatch, tmp_path
):
manager = DownloadManager()
settings = get_settings_manager()
settings.settings["auto_download_example_images"] = True
settings.settings["example_images_path"] = ""
get_ws_manager = AsyncMock(return_value=object())
monkeypatch.setattr(ServiceRegistry, "get_websocket_manager", get_ws_manager)
await manager._schedule_auto_example_images_download(
metadata=SimpleNamespace(sha256="abcdef", file_path="model.safetensors"),
model_type="lora",
)
await asyncio.sleep(0)
get_ws_manager.assert_not_called()
settings.settings["example_images_path"] = str(tmp_path / "examples")
await manager._schedule_auto_example_images_download(
metadata=SimpleNamespace(sha256="", file_path="model.safetensors"),
model_type="lora",
)
await asyncio.sleep(0)
get_ws_manager.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_uses_active_mirrors( async def test_download_uses_active_mirrors(
monkeypatch, scanners, metadata_provider, tmp_path monkeypatch, scanners, metadata_provider, tmp_path

View File

@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock
import pytest import pytest
from py.services.connectivity_guard import OFFLINE_COOLDOWN_ERROR, OFFLINE_FRIENDLY_MESSAGE
from py.services.errors import RateLimitError from py.services.errors import RateLimitError
from py.services.metadata_sync_service import MetadataSyncService from py.services.metadata_sync_service import MetadataSyncService
@@ -243,17 +244,32 @@ async def test_fetch_and_update_model_handles_missing_remote_metadata(tmp_path):
assert not ok assert not ok
assert "Model not found" in error assert "Model not found" in error
assert model_data["from_civitai"] is False
assert model_data["civitai_deleted"] is True
helpers.metadata_manager.hydrate_model_data.assert_not_awaited()
assert model_data["hydrated"] is True
helpers.metadata_manager.save_metadata.assert_awaited_once() @pytest.mark.asyncio
call_args = helpers.metadata_manager.save_metadata.await_args async def test_fetch_and_update_model_returns_friendly_offline_message(tmp_path):
assert call_args.args[0].endswith("model.safetensors") helpers = build_service()
assert "folder" not in call_args.args[1] helpers.default_provider.get_model_by_hash.return_value = (None, OFFLINE_COOLDOWN_ERROR)
assert call_args.args[1]["hydrated"] is True
model_path = tmp_path / "model.safetensors"
model_data = {
"model_name": "Local",
"folder": "root",
"file_path": str(model_path),
}
update_cache = AsyncMock(return_value=True)
ok, error = await helpers.service.fetch_and_update_model(
sha256="abc",
file_path=str(model_path),
model_data=model_data,
update_cache_func=update_cache,
)
assert ok is False
assert error is not None
assert OFFLINE_FRIENDLY_MESSAGE in error
update_cache.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -0,0 +1,113 @@
import pytest
from py.services.model_hash_index import ModelHashIndex
class TestModelHashIndexRemoveByPath:
def test_remove_by_path_finds_hash_in_hash_to_path(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
index.remove_by_path("/models/lora.safetensors")
assert len(index) == 0
assert not index.get_duplicate_filenames()
def test_remove_by_path_falls_back_to_duplicate_hashes(self):
"""When a path is only tracked in _duplicate_hashes, remove_by_path
should still find and remove it."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora_v1.safetensors")
index.add_entry("abc123", "/models/lora_v2.safetensors")
# lora_v1 is the primary (_hash_to_path), lora_v2 is in _duplicate_hashes
index.remove_by_path("/models/lora_v2.safetensors")
assert len(index) == 1
assert index._hash_to_path.get("abc123") == "/models/lora_v1.safetensors"
assert "abc123" not in index._duplicate_hashes
def test_remove_by_path_cleans_up_duplicate_filenames(self):
"""After removing a path, _duplicate_filenames should be updated."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/mylora.safetensors")
index.add_entry("def456", "/other/mylora.safetensors")
assert "mylora" in index.get_duplicate_filenames()
assert len(index.get_duplicate_filenames()["mylora"]) == 2
index.remove_by_path("/other/mylora.safetensors")
# After removing one duplicate, only one path remains — no longer a duplicate
assert "mylora" not in index.get_duplicate_filenames()
def test_remove_by_path_keeps_duplicate_filenames_with_three_entries(self):
"""With 3 entries for the same filename, removing one should leave 2."""
index = ModelHashIndex()
index.add_entry("abc123", "/models/mylora.safetensors")
index.add_entry("def456", "/other/mylora.safetensors")
index.add_entry("ghi789", "/third/mylora.safetensors")
index.remove_by_path("/other/mylora.safetensors")
assert "mylora" in index.get_duplicate_filenames()
paths = index.get_duplicate_filenames()["mylora"]
assert len(paths) == 2
assert "/other/mylora.safetensors" not in paths
def test_remove_by_path_noop_on_unknown_path(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
# Should not raise
index.remove_by_path("/nonexistent/lora.safetensors")
assert len(index) == 1
def test_remove_by_path_handles_hash_from_duplicate_hashes_only(self):
"""Remove a path whose hash exists ONLY in _duplicate_hashes,
not in _hash_to_path (edge case from index rebuilds)."""
index = ModelHashIndex()
index.add_entry("abc123", "/a/model.safetensors")
index.add_entry("abc123", "/b/model.safetensors")
# Manually remove the primary entry to simulate edge case
del index._hash_to_path["abc123"]
# Now the path is only referenced in _duplicate_hashes
assert "abc123" in index._duplicate_hashes
index.remove_by_path("/b/model.safetensors")
# The remaining path is promoted to _hash_to_path, duplicates cleared
assert "abc123" not in index._duplicate_hashes
assert index._hash_to_path.get("abc123") == "/a/model.safetensors"
class TestModelHashIndexGetDuplicateFilenames:
def test_empty_index_returns_empty_dict(self):
index = ModelHashIndex()
assert index.get_duplicate_filenames() == {}
def test_no_duplicates_returns_empty_dict(self):
index = ModelHashIndex()
index.add_entry("abc123", "/models/lora.safetensors")
index.add_entry("def456", "/models/other.safetensors")
assert index.get_duplicate_filenames() == {}
def test_duplicate_filenames_detected(self):
index = ModelHashIndex()
index.add_entry("abc123", "/a/mylora.safetensors")
index.add_entry("def456", "/b/mylora.safetensors")
dupes = index.get_duplicate_filenames()
assert "mylora" in dupes
assert len(dupes["mylora"]) == 2
def test_same_hash_same_name_not_a_filename_duplicate(self):
"""Same hash with same filename = hash duplicate, not filename conflict."""
index = ModelHashIndex()
index.add_entry("abc123", "/a/lora.safetensors")
# Same hash, same filename — this is a true duplicate (hash collision)
# but the filename index only tracks different files with same name
# Currently add_entry for same hash+path would update, not create duplicate
# This is correct behavior — filename dupes are for different files
def test_add_entry_idempotent_for_same_path_and_hash(self):
index = ModelHashIndex()
index.add_entry("abc123", "/a/lora.safetensors")
index.add_entry("abc123", "/a/lora.safetensors")
assert len(index) == 1
assert index.get_duplicate_filenames() == {}

View File

@@ -624,3 +624,42 @@ async def test_reconcile_cache_removes_duplicate_alias_when_same_real_file_seen_
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
cached_paths = {item["file_path"] for item in cache.raw_data} cached_paths = {item["file_path"] for item in cache.raw_data}
assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")} assert cached_paths == {_normalize_path(loras_root / "link" / "one.txt")}
@pytest.mark.asyncio
async def test_log_duplicate_filename_summary_logs_warning(tmp_path: Path, caplog):
"""When duplicate filenames exist, _log_duplicate_filename_summary should emit
a single warning log with the conflict count and total file count."""
import logging
caplog.set_level(logging.WARNING)
root = tmp_path / "loras"
root.mkdir()
scanner = DummyScanner(root)
# Simulate duplicate filenames in the hash index
scanner._hash_index.add_entry("aaa111", str(root / "model.safetensors"))
scanner._hash_index.add_entry("bbb222", str(root / "dir" / "model.safetensors"))
scanner._log_duplicate_filename_summary()
assert len(caplog.records) >= 1
log_msg = caplog.records[-1].message
assert "Duplicate filename conflict detected" in log_msg
assert "1 dummy filename(s)" in log_msg
assert "2 files total" in log_msg
@pytest.mark.asyncio
async def test_log_duplicate_filename_summary_silent_when_no_duplicates(tmp_path: Path, caplog):
import logging
caplog.set_level(logging.WARNING)
root = tmp_path / "loras"
root.mkdir()
scanner = DummyScanner(root)
scanner._log_duplicate_filename_summary()
# No warning should be logged when there are no duplicates
for record in caplog.records:
assert "Duplicate filename conflict detected" not in record.message

View File

@@ -442,6 +442,42 @@ async def test_has_updates_bulk_returns_mapping(tmp_path):
assert await service.has_update("lora", 9) is True assert await service.has_update("lora", 9) is True
@pytest.mark.asyncio
async def test_has_updates_bulk_handles_more_than_sqlite_max_variables(tmp_path):
"""Bulk query with >999 model IDs must not raise 'too many SQL variables'."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path), ttl_seconds=3600)
model_ids = list(range(1, 1201))
with sqlite3.connect(str(db_path)) as conn:
conn.execute("INSERT INTO model_update_status (model_id, model_type) VALUES (?, ?)", (1, "lora"))
conn.execute("INSERT INTO model_update_versions (model_id, version_id, sort_index, name) VALUES (?, ?, ?, ?)", (1, 10, 0, "v1"))
mapping = await service.has_updates_bulk("lora", model_ids)
assert mapping[1] is True
assert len(mapping) == len(model_ids)
assert all(v is False for k, v in mapping.items() if k != 1)
@pytest.mark.asyncio
async def test_get_records_bulk_handles_more_than_sqlite_max_variables(tmp_path):
"""Bulk record fetch with >999 model IDs must not raise 'too many SQL variables'."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path), ttl_seconds=3600)
model_ids = list(range(1, 1201))
with sqlite3.connect(str(db_path)) as conn:
conn.execute("INSERT INTO model_update_status (model_id, model_type) VALUES (?, ?)", (1, "lora"))
conn.execute("INSERT INTO model_update_versions (model_id, version_id, sort_index, name) VALUES (?, ?, ?, ?)", (1, 10, 0, "v1"))
records = await service.get_records_bulk("lora", model_ids)
assert 1 in records
assert records[1].model_id == 1
assert len(records) == 1
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_refresh_allows_duplicate_version_ids_across_models(tmp_path): async def test_refresh_allows_duplicate_version_ids_across_models(tmp_path):
db_path = tmp_path / "updates.sqlite" db_path = tmp_path / "updates.sqlite"

View File

@@ -1,10 +1,13 @@
import json import json
import logging import logging
import os import os
from io import BytesIO
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
import piexif
import pytest import pytest
from PIL import Image, PngImagePlugin
from py.services.recipes.analysis_service import RecipeAnalysisService from py.services.recipes.analysis_service import RecipeAnalysisService
from py.services.recipes.errors import ( from py.services.recipes.errors import (
@@ -13,6 +16,7 @@ from py.services.recipes.errors import (
RecipeValidationError, RecipeValidationError,
) )
from py.services.recipes.persistence_service import RecipePersistenceService from py.services.recipes.persistence_service import RecipePersistenceService
from py.utils.exif_utils import ExifUtils
class DummyExifUtils: class DummyExifUtils:
@@ -420,6 +424,56 @@ async def test_save_recipe_derives_allowed_fields_from_raw_metadata(tmp_path):
} }
@pytest.mark.asyncio
async def test_save_recipe_preserves_workflow_when_png_is_converted_to_webp(tmp_path):
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
png_info = PngImagePlugin.PngInfo()
png_info.add_text("parameters", "prompt text\nSteps: 20")
png_info.add_text("workflow", '{"nodes":[{"id":1}]}')
image_buffer = BytesIO()
Image.new("RGB", (96, 48), color="purple").save(
image_buffer, format="PNG", pnginfo=png_info
)
service = RecipePersistenceService(
exif_utils=ExifUtils,
card_preview_width=64,
logger=logging.getLogger("test"),
)
result = await service.save_recipe(
recipe_scanner=DummyScanner(tmp_path),
image_bytes=image_buffer.getvalue(),
image_base64=None,
name="Workflow Recipe",
tags=["workflow"],
metadata={"base_model": "sd", "loras": []},
extension=".png",
)
image_path = Path(result.payload["image_path"])
exif_dict = piexif.load(str(image_path))
assert (
exif_dict["0th"][piexif.ImageIFD.ImageDescription].decode("utf-8")
== 'Workflow:{"nodes":[{"id":1}]}'
)
user_comment = exif_dict["Exif"][piexif.ExifIFD.UserComment]
decoded_comment = user_comment[8:].decode("utf-16be")
assert "prompt text" in decoded_comment
assert "Recipe metadata:" in decoded_comment
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_save_recipe_strips_checkpoint_local_fields(tmp_path): async def test_save_recipe_strips_checkpoint_local_fields(tmp_path):
exif_utils = DummyExifUtils() exif_utils = DummyExifUtils()
@@ -491,6 +545,9 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
async def get_local_lora(self, name): # pragma: no cover - no lookups expected async def get_local_lora(self, name): # pragma: no cover - no lookups expected
return None return None
async def get_local_checkpoint(self, name):
return None
async def add_recipe(self, recipe_data): async def add_recipe(self, recipe_data):
self.added.append(recipe_data) self.added.append(recipe_data)
@@ -518,9 +575,90 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
assert stored["loras"] == [] assert stored["loras"] == []
assert stored["title"] == "recipe" assert stored["title"] == "recipe"
assert stored["checkpoint"] == {
"type": "checkpoint",
"name": "base-model.safetensors",
"file_name": "base-model",
"hash": "",
}
assert scanner.added and scanner.added[0]["loras"] == [] assert scanner.added and scanner.added[0]["loras"] == []
@pytest.mark.asyncio
async def test_save_recipe_from_widget_enriches_checkpoint_from_local_cache(tmp_path):
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
self.added = []
self.checkpoint_queries = []
async def get_local_lora(self, name): # pragma: no cover - no loras
return None
async def get_local_checkpoint(self, name):
self.checkpoint_queries.append(name)
if name != "matched-model":
return None
return {
"file_name": "matched-model",
"file_path": "/models/checkpoints/folder/matched-model.safetensors",
"sha256": "ABC123",
"base_model": "Illustrious",
"civitai": {
"id": 456,
"name": "v1.0",
"baseModel": "Illustrious",
"model": {
"id": 123,
"name": "Matched Model",
},
},
}
async def add_recipe(self, recipe_data):
self.added.append(recipe_data)
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
result = await service.save_recipe_from_widget(
recipe_scanner=scanner,
metadata={
"loras": "",
"checkpoint": "folder/matched-model.safetensors",
"prompt": "a calm scene",
},
image_bytes=b"image-bytes",
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert scanner.checkpoint_queries == [
"folder/matched-model.safetensors",
"matched-model.safetensors",
"matched-model",
]
assert stored["base_model"] == "Illustrious"
assert stored["checkpoint"] == {
"type": "checkpoint",
"modelId": 123,
"modelVersionId": 456,
"name": "Matched Model",
"version": "v1.0",
"hash": "abc123",
"file_name": "matched-model",
"modelName": "Matched Model",
"modelVersionName": "v1.0",
"baseModel": "Illustrious",
}
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_move_recipe_updates_paths(tmp_path): async def test_move_recipe_updates_paths(tmp_path):
exif_utils = DummyExifUtils() exif_utils = DummyExifUtils()

View File

@@ -332,6 +332,62 @@ def test_auto_set_default_roots_keeps_valid_values(manager):
assert manager.get("default_embedding_root") == "/embeddings" assert manager.get("default_embedding_root") == "/embeddings"
def test_auto_set_default_roots_keeps_valid_extra_values(manager):
manager.settings["default_lora_root"] = "/extra-loras"
manager.settings["default_checkpoint_root"] = "/extra-checkpoints"
manager.settings["default_embedding_root"] = "/extra-embeddings"
manager.settings["default_unet_root"] = "/extra-unet"
manager.settings["folder_paths"] = {
"loras": ["/loras"],
"checkpoints": ["/checkpoints"],
"unet": ["/unet"],
"embeddings": ["/embeddings"],
}
manager.settings["extra_folder_paths"] = {
"loras": ["/extra-loras"],
"checkpoints": ["/extra-checkpoints"],
"unet": ["/extra-unet"],
"embeddings": ["/extra-embeddings"],
}
manager._auto_set_default_roots()
assert manager.get("default_lora_root") == "/extra-loras"
assert manager.get("default_checkpoint_root") == "/extra-checkpoints"
assert manager.get("default_unet_root") == "/extra-unet"
assert manager.get("default_embedding_root") == "/extra-embeddings"
def test_auto_set_default_roots_keeps_valid_extra_values_with_windows_slash_mismatch(manager):
manager.settings["default_lora_root"] = "U:/Lora7/Loras"
manager.settings["default_checkpoint_root"] = "U:/Lora7/Models"
manager.settings["folder_paths"] = {
"loras": ["R:/ComfyUI/models/loras"],
"checkpoints": ["R:/ComfyUI/models/checkpoints"],
}
manager.settings["extra_folder_paths"] = {
"loras": ["U:\\Lora7\\Loras"],
"checkpoints": ["U:\\Lora7\\Models"],
}
manager._auto_set_default_roots()
assert manager.get("default_lora_root") == "U:/Lora7/Loras"
assert manager.get("default_checkpoint_root") == "U:/Lora7/Models"
def test_auto_set_default_roots_falls_back_to_extra_when_primary_missing(manager):
manager.settings["default_lora_root"] = ""
manager.settings["folder_paths"] = {"loras": []}
manager.settings["extra_folder_paths"] = {"loras": ["/extra-loras"]}
manager._auto_set_default_roots()
assert manager.get("default_lora_root") == "/extra-loras"
def test_delete_setting(manager): def test_delete_setting(manager):
manager.set("example", 1) manager.set("example", 1)
manager.delete("example") manager.delete("example")

View File

@@ -66,6 +66,97 @@ async def test_open_folder_requires_existing_model_directory(monkeypatch: pytest
assert model_hash in popen_calls[0][-1] assert model_hash in popen_calls[0][-1]
async def test_open_folder_returns_clipboard_mode_with_mapped_local_path(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["example_images_open_mode"] = "clipboard"
settings_manager.settings["example_images_local_root"] = "/Volumes/ComfyUI/examples"
model_hash = "d" * 64
model_folder = tmp_path / "library-a" / model_hash
model_folder.mkdir(parents=True)
(model_folder / "image.png").write_text("data", encoding="utf-8")
popen_calls: list[list[str]] = []
class DummyPopen:
def __init__(self, cmd, *_args, **_kwargs):
popen_calls.append(cmd)
monkeypatch.setattr("subprocess.Popen", DummyPopen)
monkeypatch.setattr("py.utils.example_images_file_manager.get_model_folder", lambda _hash: str(model_folder))
request = JsonRequest({"model_hash": model_hash})
response = await ExampleImagesFileManager.open_folder(request)
body = json.loads(response.text)
assert response.status == 200
assert body == {
"success": True,
"mode": "clipboard",
"path": f"/Volumes/ComfyUI/examples/library-a/{model_hash}",
"relative_path": f"library-a/{model_hash}",
}
assert popen_calls == []
async def test_open_folder_returns_uri_mode_with_rendered_template(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["example_images_open_mode"] = "uri_template"
settings_manager.settings["example_images_local_root"] = "/Volumes/ComfyUI/examples"
settings_manager.settings["example_images_open_uri_template"] = (
"shortcuts://run-shortcut?name=OpenFinder&input=text&text={{encoded_local_path}}"
)
model_hash = "e" * 64
model_folder = tmp_path / model_hash
model_folder.mkdir()
(model_folder / "image.png").write_text("data", encoding="utf-8")
popen_calls: list[list[str]] = []
class DummyPopen:
def __init__(self, cmd, *_args, **_kwargs):
popen_calls.append(cmd)
monkeypatch.setattr("subprocess.Popen", DummyPopen)
request = JsonRequest({"model_hash": model_hash})
response = await ExampleImagesFileManager.open_folder(request)
body = json.loads(response.text)
assert response.status == 200
assert body["success"] is True
assert body["mode"] == "uri"
assert body["path"] == f"/Volumes/ComfyUI/examples/{model_hash}"
assert body["relative_path"] == model_hash
assert body["uri"] == (
"shortcuts://run-shortcut?name=OpenFinder&input=text&text="
f"%2FVolumes%2FComfyUI%2Fexamples%2F{model_hash}"
)
assert popen_calls == []
async def test_open_folder_rejects_missing_uri_template(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path)
settings_manager.settings["example_images_open_mode"] = "uri_template"
model_hash = "f" * 64
model_folder = tmp_path / model_hash
model_folder.mkdir()
(model_folder / "image.png").write_text("data", encoding="utf-8")
response = await ExampleImagesFileManager.open_folder(JsonRequest({"model_hash": model_hash}))
body = json.loads(response.text)
assert response.status == 400
assert body["success"] is False
assert body["error"] == "No example image open URI template configured."
async def test_open_folder_rejects_invalid_paths(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: async def test_open_folder_rejects_invalid_paths(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
settings_manager = get_settings_manager() settings_manager = get_settings_manager()
settings_manager.settings["example_images_path"] = str(tmp_path) settings_manager.settings["example_images_path"] = str(tmp_path)

View File

@@ -1,5 +1,8 @@
import json import json
import piexif
from PIL import Image, PngImagePlugin
from py.utils.exif_utils import ExifUtils from py.utils.exif_utils import ExifUtils
@@ -59,3 +62,82 @@ def test_append_recipe_metadata_includes_checkpoint(monkeypatch, tmp_path):
"baseModel": "Illustrious", "baseModel": "Illustrious",
} }
assert payload["base_model"] == "Illustrious" assert payload["base_model"] == "Illustrious"
def test_optimize_image_preserves_workflow_when_converting_png_to_webp(tmp_path):
image_path = tmp_path / "source.png"
png_info = PngImagePlugin.PngInfo()
png_info.add_text("parameters", "prompt text\nSteps: 20")
png_info.add_text("workflow", json.dumps({"nodes": [{"id": 1}]}))
Image.new("RGB", (64, 32), color="red").save(image_path, pnginfo=png_info)
optimized_data, extension = ExifUtils.optimize_image(
str(image_path),
target_width=32,
format="webp",
quality=85,
preserve_metadata=True,
)
optimized_path = tmp_path / f"optimized{extension}"
optimized_path.write_bytes(optimized_data)
exif_dict = piexif.load(str(optimized_path))
assert (
exif_dict["0th"][piexif.ImageIFD.ImageDescription].decode("utf-8")
== 'Workflow:{"nodes": [{"id": 1}]}'
)
user_comment = exif_dict["Exif"][piexif.ExifIFD.UserComment]
assert user_comment.startswith(b"UNICODE\0")
assert user_comment[8:].decode("utf-16be") == "prompt text\nSteps: 20"
def test_update_image_metadata_preserves_webp_workflow(tmp_path):
image_path = tmp_path / "recipe.webp"
exif_dict = {
"0th": {
piexif.ImageIFD.ImageDescription: 'Workflow:{"nodes":[{"id":1}]}',
},
"Exif": {
piexif.ExifIFD.UserComment: b"UNICODE\0"
+ "prompt text".encode("utf-16be")
},
}
Image.new("RGB", (32, 32), color="blue").save(
image_path, format="WEBP", exif=piexif.dump(exif_dict), quality=85
)
ExifUtils.update_image_metadata(
str(image_path), 'prompt text\nRecipe metadata: {"title":"recipe"}'
)
updated_exif = piexif.load(str(image_path))
assert (
updated_exif["0th"][piexif.ImageIFD.ImageDescription].decode("utf-8")
== 'Workflow:{"nodes":[{"id":1}]}'
)
updated_comment = updated_exif["Exif"][piexif.ExifIFD.UserComment]
assert (
updated_comment[8:].decode("utf-16be")
== 'prompt text\nRecipe metadata: {"title":"recipe"}'
)
def test_update_image_metadata_preserves_png_workflow(tmp_path):
image_path = tmp_path / "recipe.png"
png_info = PngImagePlugin.PngInfo()
png_info.add_text("parameters", "prompt text")
png_info.add_text("workflow", '{"nodes":[{"id":1}]}')
Image.new("RGB", (32, 32), color="green").save(image_path, pnginfo=png_info)
ExifUtils.update_image_metadata(
str(image_path), 'prompt text\nRecipe metadata: {"title":"recipe"}'
)
with Image.open(image_path) as img:
assert img.info["workflow"] == '{"nodes":[{"id":1}]}'
assert (
img.info["parameters"]
== 'prompt text\nRecipe metadata: {"title":"recipe"}'
)

View File

@@ -176,7 +176,7 @@ onMounted(() => {
;(textareaRef.value as any)._autocompleteHostWidget = props.widget ;(textareaRef.value as any)._autocompleteHostWidget = props.widget
;(textareaRef.value as any)._autocompleteMetadataWidget = props.widget.metadataWidget ;(textareaRef.value as any)._autocompleteMetadataWidget = props.widget.metadataWidget
;(textareaRef.value as any)._autocompleteTextWidgetName = props.widget.name ?? 'text' ;(textareaRef.value as any)._autocompleteTextWidgetName = props.widget.name ?? 'text'
// Also store on the container element for cloned widgets (subgraph promotion) // Also store on the container element for cloned widgets (subgraph promotion)
// When widgets are promoted to subgraph nodes, the cloned widget shares the same // When widgets are promoted to subgraph nodes, the cloned widget shares the same
// DOM element but has its own inputEl property. We store the reference on the // DOM element but has its own inputEl property. We store the reference on the
@@ -185,10 +185,24 @@ onMounted(() => {
if (container && (container as any).__widgetInputEl) { if (container && (container as any).__widgetInputEl) {
(container as any).__widgetInputEl.inputEl = textareaRef.value (container as any).__widgetInputEl.inputEl = textareaRef.value
} }
// Initialize hasText state // Apply pending value from setValue if exists (workflow loading before Vue mount)
hasText.value = textareaRef.value.value.length > 0 const pendingValue = (props.widget as any)._pendingValue
if (pendingValue !== undefined) {
textareaRef.value.value = pendingValue
hasText.value = pendingValue.length > 0
delete (props.widget as any)._pendingValue
// Dispatch event to notify autocomplete of value change
textareaRef.value.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: pendingValue }
}))
}
// Initialize hasText state (already done if pendingValue was applied, but safe to re-check)
if (pendingValue === undefined) {
hasText.value = textareaRef.value.value.length > 0
}
// Listen for external value change events from setValue // Listen for external value change events from setValue
textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener) textareaRef.value.addEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
} }

View File

@@ -5,6 +5,7 @@ import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
import LoraCyclerWidget from '@/components/LoraCyclerWidget.vue' import LoraCyclerWidget from '@/components/LoraCyclerWidget.vue'
import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue' import JsonDisplayWidget from '@/components/JsonDisplayWidget.vue'
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue' import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
import { createVueWidgetCleanup } from './vue-widget-cleanup'
import type { LoraPoolConfig, RandomizerConfig, CyclerConfig } from './composables/types' import type { LoraPoolConfig, RandomizerConfig, CyclerConfig } from './composables/types'
import { import {
setupModeChangeHandler, setupModeChangeHandler,
@@ -66,6 +67,12 @@ function forwardMiddleMouseToCanvas(container: HTMLElement) {
} }
const vueApps = new Map<number, VueApp>() const vueApps = new Map<number, VueApp>()
let autocompleteTextWidgetInstanceId = 0
export function createAutocompleteTextWidgetInstanceId() {
autocompleteTextWidgetInstanceId += 1
return autocompleteTextWidgetInstanceId
}
// Cache for dynamically loaded addLorasWidget module // Cache for dynamically loaded addLorasWidget module
let addLorasWidgetCache: any = null let addLorasWidgetCache: any = null
@@ -425,7 +432,7 @@ function shouldBypassAutocompleteWidgetMigration(
} }
const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) => const originalWidgetsInputs = Object.values(inputDefs).filter((input: any) =>
widgetNames.has(input.name) widgetNames.has(input.name) || input.forceInput
) )
const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) => const widgetIndexHasForceInput = originalWidgetsInputs.flatMap((input: any) =>
@@ -434,10 +441,12 @@ function shouldBypassAutocompleteWidgetMigration(
: [!!input.forceInput] : [!!input.forceInput]
) )
return ( const result = (
widgetIndexHasForceInput.some(Boolean) && widgetIndexHasForceInput.some(Boolean) &&
widgetIndexHasForceInput.length === widgetValues.length widgetIndexHasForceInput.length === widgetValues.length
) )
return result
} }
function remapWidgetValuesByName( function remapWidgetValuesByName(
@@ -452,6 +461,7 @@ function remapWidgetValuesByName(
} }
}) })
const currentWidgetNameSet = new Set(currentWidgetNames)
const remappedValues: unknown[] = [] const remappedValues: unknown[] = []
for (const name of currentWidgetNames) { for (const name of currentWidgetNames) {
if (valueByName.has(name)) { if (valueByName.has(name)) {
@@ -459,6 +469,18 @@ function remapWidgetValuesByName(
} }
} }
// Append values for saved widget names that are NOT in the current widget
// list (e.g. forceInput widgets like "seed" that haven't been converted
// back to DOM widgets yet at configure time). Without these, the
// resulting array may accidentally match the length of ComfyUI's
// widgetIndexHasForceInput array, causing migrateWidgetsValues to
// incorrectly filter out the wrong values and drop real widget content.
for (const name of savedWidgetNames) {
if (!currentWidgetNameSet.has(name) && valueByName.has(name)) {
remappedValues.push(valueByName.get(name))
}
}
return remappedValues return remappedValues
} }
@@ -491,6 +513,7 @@ function normalizeAutocompleteWidgetValues(node: any, info: any) {
} }
const currentWidgetNames = getSerializableWidgetNames(node) const currentWidgetNames = getSerializableWidgetNames(node)
if (currentWidgetNames.length === 0) { if (currentWidgetNames.length === 0) {
return return
} }
@@ -562,8 +585,9 @@ function createAutocompleteTextWidgetFactory(
inputOptions: { placeholder?: string } = {} inputOptions: { placeholder?: string } = {}
) { ) {
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}` const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
const instanceId = createAutocompleteTextWidgetInstanceId()
const container = document.createElement('div') const container = document.createElement('div')
container.id = `autocomplete-text-widget-${node.id}-${widgetName}` container.id = `autocomplete-text-widget-${instanceId}`
container.style.width = '100%' container.style.width = '100%'
container.style.height = '100%' container.style.height = '100%'
container.style.display = 'flex' container.style.display = 'flex'
@@ -607,6 +631,8 @@ function createAutocompleteTextWidgetFactory(
inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', { inputEl.dispatchEvent(new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: v ?? '' } detail: { value: v ?? '' }
})) }))
} else {
;(widget as any)._pendingValue = v ?? ''
} }
// Also call onSetValue if defined (for Vue component integration) // Also call onSetValue if defined (for Vue component integration)
if (typeof widget.onSetValue === 'function') { if (typeof widget.onSetValue === 'function') {
@@ -644,17 +670,12 @@ function createAutocompleteTextWidgetFactory(
}) })
vueApp.mount(container) vueApp.mount(container)
// Use a unique key combining node.id and widget name to avoid collisions const appKey = instanceId
const appKey = node.id * 100000 + widgetName.charCodeAt(0)
vueApps.set(appKey, vueApp) vueApps.set(appKey, vueApp)
widget.onRemove = () => { widget.onRemove = createVueWidgetCleanup(vueApp, () => {
const vueApp = vueApps.get(appKey) vueApps.delete(appKey)
if (vueApp) { })
vueApp.unmount()
vueApps.delete(appKey)
}
}
return { widget } return { widget }
} }
@@ -748,10 +769,16 @@ app.registerExtension({
nodeType.prototype.configure = function (info: any) { nodeType.prototype.configure = function (info: any) {
normalizeAutocompleteWidgetValues(this, info) normalizeAutocompleteWidgetValues(this, info)
if (shouldBypassAutocompleteWidgetMigration(this, info?.widgets_values ?? [])) {
const bypassResult = shouldBypassAutocompleteWidgetMigration(this, info?.widgets_values ?? [])
if (bypassResult) {
info.widgets_values = [...(info.widgets_values ?? []), null] info.widgets_values = [...(info.widgets_values ?? []), null]
} }
return originalConfigure?.apply(this, arguments)
const result = originalConfigure?.apply(this, arguments)
return result
} }
} }

View File

@@ -0,0 +1,15 @@
import type { App as VueApp } from 'vue'
export function createVueWidgetCleanup(vueApp: VueApp, onCleanup?: () => void) {
let didUnmount = false
return () => {
if (didUnmount) {
return
}
vueApp.unmount()
didUnmount = true
onCleanup?.()
}
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import { createVueWidgetCleanup } from '@/vue-widget-cleanup'
describe('createVueWidgetCleanup', () => {
it('cleans up only the Vue app bound to the widget remove handler', () => {
const firstCleanup = vi.fn()
const secondCleanup = vi.fn()
const firstApp = { unmount: vi.fn() }
const secondApp = { unmount: vi.fn() }
const removeFirst = createVueWidgetCleanup(firstApp as any, firstCleanup)
const removeSecond = createVueWidgetCleanup(secondApp as any, secondCleanup)
removeFirst()
expect(firstApp.unmount).toHaveBeenCalledTimes(1)
expect(firstCleanup).toHaveBeenCalledTimes(1)
expect(secondApp.unmount).not.toHaveBeenCalled()
expect(secondCleanup).not.toHaveBeenCalled()
removeSecond()
expect(secondApp.unmount).toHaveBeenCalledTimes(1)
expect(secondCleanup).toHaveBeenCalledTimes(1)
})
it('is idempotent when ComfyUI calls the remove handler more than once', () => {
const cleanup = vi.fn()
const app = { unmount: vi.fn() }
const remove = createVueWidgetCleanup(app as any, cleanup)
remove()
remove()
expect(app.unmount).toHaveBeenCalledTimes(1)
expect(cleanup).toHaveBeenCalledTimes(1)
})
})

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